From eedafd6db2f676099acf23acef2981f519a0b6ed Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 11 Jun 2026 20:58:55 -0500 Subject: [PATCH] feat(cli): add --plain output mode and cli_output_style config default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four interactive bm tool commands (search-notes, read-note, build-context, recent-activity) now support three output modes instead of two: - JSON — raw machine-readable output (--json, or automatically when piped). - Rich — colored Panel/Table/Tree/Markdown (the default TTY experience). - Plain — undecorated, greppable text with no ANSI colors, box-drawing, or markup (--plain, forced even when piped). Precedence, highest first: --json > --plain > non-TTY (JSON) > TTY (config style). Passing both --json and --plain is a typer error with a non-zero exit. Each TTY default is governed by the new BasicMemoryConfig field cli_output_style ("rich"/"plain", default "rich", env BASIC_MEMORY_CLI_OUTPUT_STYLE), mirroring the existing CLI-behavior config conventions; its Field description documents the setting since the repo has no general CLI-settings reference doc. Plain renderers deliberately do NOT apply rich.markup.escape — escaping is only correct on the Rich path and would corrupt literal brackets — so [draft]/[fact] survive verbatim. The plain search path keeps the total=0 fallback so the corrected result count matches the Rich path. String tool results still route to JSON to preserve ty narrowing. Module docstring and each command's help text now document the three modes and the config default. Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 278 +++++++++++++++++++--- src/basic_memory/config.py | 12 + tests/cli/test_cli_tool_rich_output.py | 313 ++++++++++++++++++++++++- 3 files changed, 565 insertions(+), 38 deletions(-) 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