diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 83a24eef..bf12f91b 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -2,14 +2,26 @@ Every command calls its MCP tool with output_format="json" and prints the result. Commands that benefit from human-readable output (search-notes, read-note, -build-context, recent-activity) default to Rich formatting when stdout is a TTY -and fall back to raw JSON when piped or when --json is supplied. This follows -the same bm status / bm project list precedent. +build-context, recent-activity) support three output modes: + +- **JSON** — raw machine-readable JSON. Used when ``--json`` is passed, or + automatically when stdout is not a TTY (piped/redirected), so scripts stay + parseable. This follows the same bm status / bm project list precedent. +- **Rich** — colored Panel/Table/Tree/Markdown output. The default interactive + experience when stdout is a TTY. +- **Plain** — undecorated, greppable text (no ANSI colors, no box-drawing, no + markup). Forced with ``--plain`` even when piped. + +Precedence, highest first: ``--json`` > ``--plain`` > non-TTY (JSON) > TTY +(config ``cli_output_style``, ``rich`` by default). Passing both ``--json`` and +``--plain`` is an error. The interactive default for a TTY is controlled by the +``cli_output_style`` config option (``rich``/``plain``; env +``BASIC_MEMORY_CLI_OUTPUT_STYLE``). """ import json import sys -from typing import Annotated, Any, Dict, List, Optional +from typing import Annotated, Any, Dict, List, Literal, Optional import typer from loguru import logger @@ -23,6 +35,7 @@ from rich.tree import Tree from basic_memory.cli.app import app from basic_memory.cli.commands.command_utils import run_with_cleanup +from basic_memory.config import ConfigManager from basic_memory.cli.commands.routing import force_routing, validate_routing_flags from basic_memory.mcp.tools import build_context as mcp_build_context from basic_memory.mcp.tools import delete_note as mcp_delete_note @@ -48,18 +61,59 @@ console = Console() # --- Shared helpers --- +OutputMode = Literal["json", "rich", "plain"] + def _use_rich() -> bool: - """Return True when stdout is an interactive TTY and Rich output is appropriate. + """Return True when stdout is an interactive TTY. - Trigger: caller did not pass --json and stdout is a TTY. - Why: piped output (scripts, jq, etc.) must stay machine-parseable; - human-readable formatting is only useful in an interactive terminal. - Outcome: Rich output in a terminal; raw JSON when piped or redirected. + Why: piped output (scripts, jq, etc.) must stay machine-parseable; the + interactive (Rich/plain) renderers are only the default in a terminal. + Outcome: a formatted renderer in a terminal; raw JSON when piped or redirected. + + Note: tests patch this to simulate a TTY, so the precedence logic in + ``_resolve_output_mode`` routes its terminal check through here. """ return sys.stdout.isatty() +def _validate_output_flags(json_output: bool, plain: bool) -> None: + """Reject the contradictory --json/--plain combination. + + Trigger: both --json and --plain were passed. + Why: they request mutually exclusive output modes (raw JSON vs undecorated + human text); silently picking one would hide a user mistake. + Outcome: a clear typer error with a non-zero exit. + """ + if json_output and plain: + typer.echo("Error: --json and --plain are mutually exclusive.", err=True) + raise typer.Exit(1) + + +def _resolve_output_mode(json_output: bool, plain: bool) -> OutputMode: + """Resolve the effective output mode from flags, TTY state, and config. + + Precedence, highest first: + 1. --json → raw JSON (wins over everything else) + 2. --plain → undecorated plain text (even when piped) + 3. non-TTY stdout → raw JSON (script compatibility, unchanged) + 4. TTY → config ``cli_output_style`` (rich by default) + + Callers must invoke ``_validate_output_flags`` first; this helper assumes the + --json/--plain combination has already been rejected. + """ + if json_output: + return "json" + if plain: + return "plain" + if not _use_rich(): + return "json" + # Trigger: interactive TTY with no explicit mode flag. + # Why: let users choose their default terminal experience without a flag. + # Outcome: honor cli_output_style (rich out of the box, plain if configured). + return ConfigManager().config.cli_output_style + + def _print_json(result: Any) -> None: """Print a result as formatted JSON.""" print(json.dumps(result, indent=2, ensure_ascii=True, default=str)) @@ -286,6 +340,128 @@ def _display_recent_activity(result: list[dict[str, Any]]) -> None: console.print(Panel(table, title="Recent Activity", expand=False)) +# --- Plain formatters --- +# +# Plain output is NOT Rich markup: it is undecorated, greppable text printed via +# the builtin print(). Literal brackets ([draft], [fact]) must survive verbatim, +# so we deliberately do NOT call rich.markup.escape here -- escaping is only for +# the Rich path and would corrupt literal brackets in plain text. + + +def _plain_search_results(result: dict[str, Any], query: str = "") -> None: + """Render search-notes results as numbered plain-text entries. + + Mirrors the Rich table content: a header line, then one numbered block per + result (title / score / permalink) with an indented snippet line. + """ + results = result.get("results", []) + # Mirror the Rich path's total fix: the API can return total=0 with a + # populated results list, so fall back to len(results) when total is falsy. + raw_total = result.get("total", len(results)) + total = raw_total if raw_total else len(results) + + header = f"Search: {query}" if query else "Search results" + print(header) + print(f"{total} result(s)") + + if not results: + print("No results found.") + return + + for index, item in enumerate(results, start=1): + item_title = item.get("title") or item.get("permalink", "") + permalink = item.get("permalink", "") + score = item.get("score") + score_str = f"{score:.2f}" if score is not None else "" + print(f"{index}. {item_title} (score: {score_str}) {permalink}") + raw_snippet = item.get("matched_chunk") or item.get("content") or "" + if raw_snippet: + snippet = raw_snippet[:200].replace("\n", " ") + print(f" {snippet}") + + +def _plain_read_note(result: dict[str, Any], *, include_frontmatter: bool = False) -> None: + """Render read-note as a plain title/permalink header, optional frontmatter, then body.""" + title = result.get("title", "") + permalink = result.get("permalink", "") + content = result.get("content", "") + frontmatter: dict[str, Any] = result.get("frontmatter") or {} + + header = f"{title} [{permalink}]" if permalink else title + print(header) + + # Trigger: --include-frontmatter was passed and the payload carries frontmatter. + # Why: the JSON payload always includes a "frontmatter" key, so the flag (not + # mere presence) gates whether the key/value block is printed -- matching + # the Rich path's gating behavior. + # Outcome: a blank line then "key: value" lines above the body. + if include_frontmatter and frontmatter: + print() + for key, value in frontmatter.items(): + print(f"{key}: {value}") + + print() + if content: + print(content) + else: + print("(no content)") + + +def _plain_build_context(result: dict[str, Any]) -> None: + """Render build-context as an ASCII-indented outline. + + Each primary result is a top-level line; its observations and related items + are two-space indented beneath it, mirroring the Rich tree content. + """ + metadata = result.get("metadata", {}) + uri = metadata.get("uri", "") + context_items: list[dict[str, Any]] = list(result.get("results", [])) + + print(f"Context: {uri}" if uri else "Context") + + if not context_items: + print("No related content found.") + return + + for context_result in context_items: + primary = context_result.get("primary_result", {}) + p_title = primary.get("title") or primary.get("permalink", "") + p_type = primary.get("type", "") + primary_line = f"{p_type} {p_title}" if p_type else p_title + print(primary_line) + + observations: list[dict[str, Any]] = list(context_result.get("observations", [])) + for obs in observations: + category = obs.get("category", "") + obs_content = obs.get("content", "") + if len(obs_content) > 120: + obs_content = obs_content[:117] + "..." + print(f" [{category}] {obs_content}") + + related: list[dict[str, Any]] = list(context_result.get("related_results", [])) + for rel_item in related: + rel_title = rel_item.get("title") or rel_item.get("permalink", "") + rel_type = rel_item.get("type", "") + relation = rel_item.get("relation_type", "") + parts = [part for part in (relation, rel_type, rel_title) if part] + print(f" {' '.join(parts)}") + + +def _plain_recent_activity(result: list[dict[str, Any]]) -> None: + """Render recent-activity as plain "- title (type) permalink updated" lines.""" + if not result: + print("No recent activity.") + return + + print("Recent Activity") + for item in result: + item_type = item.get("type", "") + item_title = item.get("title") or item.get("permalink", "") + permalink = item.get("permalink", "") + updated = str(item.get("updated_at") or item.get("created_at") or "") + print(f"- {item_title} ({item_type}) {permalink} {updated}".rstrip()) + + def _delete_note_failure_message(result: dict[str, Any]) -> str | None: """Return the CLI failure message for delete-note JSON results, if any.""" error = result.get("error") @@ -431,6 +607,9 @@ def read_note( json_output: bool = typer.Option( False, "--json", help="Output raw JSON instead of formatted display" ), + plain: bool = typer.Option( + False, "--plain", help="Output undecorated plain text (no colors/markup), even when piped" + ), project: Annotated[ Optional[str], typer.Option(help="The project to use. If not provided, the default project will be used."), @@ -449,17 +628,21 @@ def read_note( ): """Read a markdown note from the knowledge base. - Displays formatted Markdown output by default when run in a terminal. - Use --json for raw machine-readable output. + Three output modes: Rich formatted Markdown (default in a terminal), plain + undecorated text (--plain), and raw JSON (--json, or automatically when + piped). The interactive default is set by the cli_output_style config option + (rich/plain). --json and --plain are mutually exclusive. Examples: bm tool read-note my-note bm tool read-note my-note --include-frontmatter + bm tool read-note my-note --plain bm tool read-note my-note --json """ try: validate_routing_flags(local, cloud) + _validate_output_flags(json_output, plain) with force_routing(local=local, cloud=cloud): result = run_with_cleanup( @@ -484,12 +667,13 @@ def read_note( _print_json(result) raise typer.Exit(1) - # Trigger: --json flag or non-TTY stdout (piped output). - # Why: scripts and downstream tools need parseable JSON; Rich markup - # would corrupt those pipelines. - # Outcome: raw JSON for machine consumers; formatted display for humans. - if json_output or not _use_rich() or isinstance(result, str): + # A string result (e.g. a not-found message) has no structured shape to + # format, so always fall back to JSON regardless of the resolved mode. + mode = _resolve_output_mode(json_output, plain) + if mode == "json" or isinstance(result, str): _print_json(result) + elif mode == "plain": + _plain_read_note(result, include_frontmatter=include_frontmatter) else: _display_read_note(result, include_frontmatter=include_frontmatter) except ValueError as e: @@ -652,6 +836,9 @@ def build_context( json_output: bool = typer.Option( False, "--json", help="Output raw JSON instead of formatted display" ), + plain: bool = typer.Option( + False, "--plain", help="Output undecorated plain text (no colors/markup), even when piped" + ), project: Annotated[ Optional[str], typer.Option(help="The project to use. If not provided, the default project will be used."), @@ -670,17 +857,21 @@ def build_context( ): """Get context needed to continue a discussion. - Displays a Rich tree view by default when run in a terminal. - Use --json for raw machine-readable output. + Three output modes: a Rich tree view (default in a terminal), a plain + ASCII-indented outline (--plain), and raw JSON (--json, or automatically when + piped). The interactive default is set by the cli_output_style config option + (rich/plain). --json and --plain are mutually exclusive. Examples: bm tool build-context memory://specs/search bm tool build-context specs/search --depth 2 --timeframe 30d + bm tool build-context memory://specs/search --plain bm tool build-context memory://specs/search --json """ try: validate_routing_flags(local, cloud) + _validate_output_flags(json_output, plain) with force_routing(local=local, cloud=cloud): result = run_with_cleanup( @@ -697,12 +888,12 @@ def build_context( ) ) - # Trigger: --json flag or non-TTY stdout (piped output). - # Why: scripts and downstream tools need parseable JSON; Rich markup - # would corrupt those pipelines. - # Outcome: raw JSON for machine consumers; formatted display for humans. - if json_output or not _use_rich() or isinstance(result, str): + # A string result has no structured shape to format, so fall back to JSON. + mode = _resolve_output_mode(json_output, plain) + if mode == "json" or isinstance(result, str): _print_json(result) + elif mode == "plain": + _plain_build_context(result) else: _display_build_context(result) except ValueError as e: @@ -729,6 +920,9 @@ def recent_activity( json_output: bool = typer.Option( False, "--json", help="Output raw JSON instead of formatted display" ), + plain: bool = typer.Option( + False, "--plain", help="Output undecorated plain text (no colors/markup), even when piped" + ), project: Annotated[ Optional[str], typer.Option(help="The project to use. If not provided, the default project will be used."), @@ -747,18 +941,22 @@ def recent_activity( ): """Get recent activity across the knowledge base. - Displays a formatted table by default when run in a terminal. - Use --json for raw machine-readable output. + Three output modes: a formatted Rich table (default in a terminal), plain + undecorated lines (--plain), and raw JSON (--json, or automatically when + piped). The interactive default is set by the cli_output_style config option + (rich/plain). --json and --plain are mutually exclusive. Examples: bm tool recent-activity bm tool recent-activity --timeframe 30d --page-size 20 bm tool recent-activity --type entity --type observation + bm tool recent-activity --plain bm tool recent-activity --json """ try: validate_routing_flags(local, cloud) + _validate_output_flags(json_output, plain) with force_routing(local=local, cloud=cloud): result = run_with_cleanup( @@ -774,12 +972,12 @@ def recent_activity( ) ) - # Trigger: --json flag or non-TTY stdout (piped output). - # Why: scripts and downstream tools need parseable JSON; Rich markup - # would corrupt those pipelines. - # Outcome: raw JSON for machine consumers; formatted display for humans. - if json_output or not _use_rich() or isinstance(result, str): + # A string result has no structured shape to format, so fall back to JSON. + mode = _resolve_output_mode(json_output, plain) + if mode == "json" or isinstance(result, str): _print_json(result) + elif mode == "plain": + _plain_recent_activity(result) else: _display_recent_activity(result) except ValueError as e: @@ -848,6 +1046,9 @@ def search_notes( json_output: bool = typer.Option( False, "--json", help="Output raw JSON instead of formatted display" ), + plain: bool = typer.Option( + False, "--plain", help="Output undecorated plain text (no colors/markup), even when piped" + ), project: Annotated[ Optional[str], typer.Option(help="The project to use. If not provided, the default project will be used."), @@ -866,8 +1067,10 @@ def search_notes( ): """Search across all content in the knowledge base. - Displays a formatted table by default when run in a terminal. - Use --json for raw machine-readable output. + Three output modes: a formatted Rich table (default in a terminal), plain + numbered text results (--plain), and raw JSON (--json, or automatically when + piped). The interactive default is set by the cli_output_style config option + (rich/plain). --json and --plain are mutually exclusive. Examples: @@ -876,10 +1079,12 @@ def search_notes( bm tool search-notes --tag python --tag async bm tool search-notes --meta status=draft bm tool search-notes "auth" --entity-type observation --category requirement + bm tool search-notes "my query" --plain bm tool search-notes "my query" --json """ try: validate_routing_flags(local, cloud) + _validate_output_flags(json_output, plain) mode_flags = [permalink, title, vector, hybrid] if sum(1 for enabled in mode_flags if enabled) > 1: # pragma: no cover @@ -954,12 +1159,11 @@ def search_notes( typer.echo(result, err=True) raise typer.Exit(1) - # Trigger: --json flag or non-TTY stdout (piped output). - # Why: scripts and downstream tools need parseable JSON; Rich markup - # would corrupt those pipelines. - # Outcome: raw JSON for machine consumers; formatted display for humans. - if json_output or not _use_rich(): + mode = _resolve_output_mode(json_output, plain) + if mode == "json": _print_json(result) + elif mode == "plain": + _plain_search_results(result, query=query or "") else: _display_search_results(result, query=query or "") except ValueError as e: diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 6578bff5..87581b92 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -442,6 +442,18 @@ class BasicMemoryConfig(BaseSettings): ), ) + cli_output_style: Literal["rich", "plain"] = Field( + default="rich", + description=( + "Default human-readable output style for interactive `bm tool` commands " + "(search-notes, read-note, build-context, recent-activity) when stdout is a TTY. " + "'rich' (default) renders colored Panel/Table/Tree/Markdown output; " + "'plain' renders undecorated greppable text with no ANSI colors or box-drawing. " + "Overridden per-invocation by --json (raw JSON) or --plain (forces plain). " + "Env: BASIC_MEMORY_CLI_OUTPUT_STYLE" + ), + ) + ensure_frontmatter_on_sync: bool = Field( default=True, description="Ensure markdown files have frontmatter during sync by adding derived title/type/permalink when missing. When combined with disable_permalinks=True, this setting takes precedence for missing-frontmatter files and still writes permalinks.", diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index cb3fea8e..608296ed 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -5,6 +5,7 @@ when stdout is piped or --json is supplied. These tests verify both modes. """ import json +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest @@ -139,11 +140,25 @@ RECENT_ACTIVITY_RESULT = [ def _tty_runner(args, **kwargs): - """Invoke CLI as if stdout is a TTY (Rich output enabled).""" + """Invoke CLI as if stdout is a TTY (interactive output enabled).""" with patch("basic_memory.cli.commands.tool._use_rich", return_value=True): return runner.invoke(cli_app, args, **kwargs) +def _tty_runner_with_style(args, style, **kwargs): + """Invoke CLI as if stdout is a TTY with a given cli_output_style config value. + + Patches both _use_rich (simulate TTY) and tool.ConfigManager so that + _resolve_output_mode reads the configured interactive style. + """ + fake_cm = patch( + "basic_memory.cli.commands.tool.ConfigManager", + return_value=SimpleNamespace(config=SimpleNamespace(cli_output_style=style)), + ) + with patch("basic_memory.cli.commands.tool._use_rich", return_value=True), fake_cm: + return runner.invoke(cli_app, args, **kwargs) + + # --------------------------------------------------------------------------- # search-notes – Rich output # --------------------------------------------------------------------------- @@ -642,3 +657,299 @@ def test_search_notes_rich_zero_total_falls_back_to_result_count(mock_mcp): # The subtitle must show the real count (2), not 0 assert "2 result(s)" in result.output assert "0 result(s)" not in result.output + + +# --------------------------------------------------------------------------- +# --plain output mode (issue #678 follow-up) +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_search_notes_plain_output(mock_mcp): + """search-notes --plain emits undecorated numbered text, not JSON or Rich boxes.""" + result = _tty_runner(["tool", "search-notes", "test", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # Not JSON + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + # Numbered, greppable entries with titles, scores, and permalinks + assert "1. Test Note" in result.output + assert "2. Another Note" in result.output + assert "notes/test-note" in result.output + assert "0.95" in result.output + # Snippet line present + assert "A snippet about test notes" in result.output + # No Rich box-drawing characters + assert "─" not in result.output + assert "│" not in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT_BRACKETED_TITLE, +) +def test_search_notes_plain_brackets_survive(mock_mcp): + """Literal brackets must survive verbatim in plain output (no markup escaping).""" + result = _tty_runner(["tool", "search-notes", "spec", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # The literal title and snippet brackets must be present unmangled + assert "Spec [draft] v2" in result.output + assert "[red]" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT_ZERO_TOTAL, +) +def test_search_notes_plain_zero_total_fallback(mock_mcp): + """Plain search output shows the corrected count when the API returns total=0.""" + result = _tty_runner(["tool", "search-notes", "found", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "2 result(s)" in result.output + assert "0 result(s)" not in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT_EMPTY, +) +def test_search_notes_plain_empty(mock_mcp): + """Plain search output handles empty results.""" + result = _tty_runner(["tool", "search-notes", "nothing", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "No results found." in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_plain_output(mock_mcp): + """read-note --plain emits a header line and the raw markdown body.""" + result = _tty_runner(["tool", "read-note", "test-note", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "Test Note [notes/test-note]" in result.output + # Raw markdown body, not Rich-rendered + assert "# Test Note" in result.output + assert "hello world" in result.output + # No frontmatter without the flag + assert "tags:" not in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_plain_include_frontmatter(mock_mcp): + """read-note --plain --include-frontmatter renders key: value lines.""" + result = _tty_runner(["tool", "read-note", "test-note", "--plain", "--include-frontmatter"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "title: Test Note" in result.output + assert "tags:" in result.output + assert "hello world" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value={"title": "", "permalink": "", "content": "", "frontmatter": {}}, +) +def test_read_note_plain_empty_content(mock_mcp): + """read-note --plain handles empty content.""" + result = _tty_runner(["tool", "read-note", "empty-note", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "(no content)" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_RESULT, +) +def test_build_context_plain_output(mock_mcp): + """build-context --plain emits an ASCII-indented outline with observations.""" + result = _tty_runner(["tool", "build-context", "memory://notes/test-note", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "Context: notes/test-note" in result.output + assert "Test Note" in result.output + # Observation rendered with literal bracketed category, indented + assert " [fact] This is a key fact about the test note" in result.output + # Related item with relation type, indented + assert "Related Note" in result.output + assert "references" in result.output + # Not JSON + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_BRACKETED_OBS, +) +def test_build_context_plain_bracket_survives(mock_mcp): + """Observation category [fact] must appear literally in plain build-context output.""" + result = _tty_runner(["tool", "build-context", "memory://people/joanna", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "[fact]" in result.output + assert "Joanna lives in Austin" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_EMPTY, +) +def test_build_context_plain_empty(mock_mcp): + """build-context --plain handles empty results.""" + result = _tty_runner(["tool", "build-context", "memory://notes/test-note", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "No related content found." in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_recent_activity", + new_callable=AsyncMock, + return_value=RECENT_ACTIVITY_RESULT, +) +def test_recent_activity_plain_output(mock_mcp): + """recent-activity --plain emits "- title (type) permalink updated" lines.""" + result = _tty_runner(["tool", "recent-activity", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "- Note A (entity) notes/note-a" in result.output + assert "- Note B (entity) notes/note-b" in result.output + # Not JSON + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + + +@patch( + "basic_memory.cli.commands.tool.mcp_recent_activity", + new_callable=AsyncMock, + return_value=[], +) +def test_recent_activity_plain_empty(mock_mcp): + """recent-activity --plain handles empty results.""" + result = _tty_runner(["tool", "recent-activity", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "No recent activity." in result.output + + +# --------------------------------------------------------------------------- +# Precedence matrix: --json > --plain > non-TTY (JSON) > TTY (config style) +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_json_beats_plain(mock_mcp): + """--json wins over --plain... when they are not used together, --json alone is JSON. + + The contradictory combination is tested separately (must error); here we + confirm --json on its own produces JSON even in a TTY. + """ + result = _tty_runner(["tool", "search-notes", "test", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["total"] == 2 + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_json_and_plain_together_errors(mock_mcp): + """Passing both --json and --plain is a clear, non-zero error.""" + result = _tty_runner(["tool", "search-notes", "test", "--json", "--plain"]) + + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_json_and_plain_together_errors(mock_mcp): + """read-note also rejects --json --plain together.""" + result = _tty_runner(["tool", "read-note", "test-note", "--json", "--plain"]) + + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_plain_forces_plain_when_piped(mock_mcp): + """--plain forces plain output even when stdout is NOT a TTY (piped).""" + # No _use_rich patch: the default CliRunner stdout is not a TTY, so absent + # --plain this would be JSON. --plain must override that into plain text. + result = runner.invoke(cli_app, ["tool", "search-notes", "test", "--plain"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + assert "1. Test Note" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_tty_config_rich_renders_rich(mock_mcp): + """TTY + cli_output_style=rich → Rich output (box-drawing present).""" + result = _tty_runner_with_style(["tool", "search-notes", "test"], style="rich") + + assert result.exit_code == 0, f"CLI failed: {result.output}" + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + # Rich draws a Panel border + assert "─" in result.output or "│" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_tty_config_plain_renders_plain(mock_mcp): + """TTY + cli_output_style=plain → plain output (no box-drawing).""" + result = _tty_runner_with_style(["tool", "search-notes", "test"], style="plain") + + assert result.exit_code == 0, f"CLI failed: {result.output}" + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + assert "1. Test Note" in result.output + assert "─" not in result.output + assert "│" not in result.output