From cc49468d0ca7d369c6b03cc0f23003d65c2e04b7 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 11 Jun 2026 01:12:37 -0500 Subject: [PATCH 01/11] feat(cli): add Rich human-readable output to bm tool commands search-notes, read-note, build-context, and recent-activity now display formatted Rich output (tables, panels, Markdown rendering) when stdout is an interactive TTY. When piped or redirected the commands continue to emit raw JSON exactly as before. A new --json flag is available on each command to force JSON output even in a TTY. Follows the bm status / bm project list precedent: Rich by default for humans, JSON for machines. Closes #678 Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 197 ++++++++++++- tests/cli/test_cli_tool_rich_output.py | 370 +++++++++++++++++++++++++ 2 files changed, 562 insertions(+), 5 deletions(-) create mode 100644 tests/cli/test_cli_tool_rich_output.py diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 3df836d99..3526f89ff 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -1,7 +1,10 @@ """CLI tool commands for Basic Memory. Every command calls its MCP tool with output_format="json" and prints the result. -No text formatting, no separate code paths, no duplicate data fetching. +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. """ import json @@ -10,6 +13,12 @@ import typer from loguru import logger +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich.tree import Tree from basic_memory.cli.app import app from basic_memory.cli.commands.command_utils import run_with_cleanup @@ -32,15 +41,135 @@ VALID_EDIT_OPERATIONS = ["append", "prepend", "find_replace", "replace_section"] +# Shared Rich console (stderr=False so output goes to stdout, matching _print_json). +console = Console() + # --- Shared helpers --- +def _use_rich() -> bool: + """Return True when stdout is an interactive TTY and Rich output is appropriate. + + 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. + """ + return sys.stdout.isatty() + + def _print_json(result: Any) -> None: """Print a result as formatted JSON.""" print(json.dumps(result, indent=2, ensure_ascii=True, default=str)) +# --- Rich formatters --- + + +def _display_search_results(result: dict[str, Any]) -> None: + """Render search-notes results as a Rich table.""" + results = result.get("results", []) + total = result.get("total", len(results)) + query = result.get("query") or "" + page = result.get("page", 1) + page_size = result.get("page_size", len(results)) + + title = f"Search results for [bold cyan]{query}[/bold cyan]" if query else "Search results" + subtitle = f"{total} result(s) • page {page} of {max(1, -(-total // page_size))}" + + if not results: + console.print(Panel(Text("No results found.", style="dim"), title=title, expand=False)) + return + + table = Table(show_header=True, header_style="bold", expand=False) + table.add_column("Type", style="dim", width=12) + table.add_column("Title", style="bold cyan") + table.add_column("Permalink", style="green") + + for item in results: + item_type = item.get("type", "") + item_title = item.get("title") or item.get("permalink", "") + permalink = item.get("permalink", "") + table.add_row(item_type, item_title, permalink) + + console.print(Panel(table, title=title, subtitle=subtitle, expand=False)) + + +def _display_read_note(result: dict[str, Any]) -> None: + """Render read-note result: header panel + rendered Markdown content.""" + title = result.get("title", "") + permalink = result.get("permalink", "") + content = result.get("content", "") + + header = Text() + header.append(title, style="bold cyan") + if permalink: + header.append(f" [{permalink}]", style="dim green") + + console.print(Panel(header, expand=False)) + + if content: + console.print(Markdown(content)) + else: + console.print(Text("(no content)", style="dim")) + + +def _display_build_context(result: dict[str, Any]) -> None: + """Render build-context result as a Rich tree.""" + metadata = result.get("metadata", {}) + uri = metadata.get("uri", "") + results = result.get("results", []) + total = len(results) + + label = f"[bold cyan]{uri}[/bold cyan]" if uri else "Context" + tree = Tree(f"[bold]Context:[/bold] {label}") + + if not results: + tree.add("[dim]No related content found.[/dim]") + else: + for item in results: + item_title = item.get("title") or item.get("permalink", "") + relation = item.get("relation_type", "") + item_type = item.get("type", "") + + parts = [] + if relation: + parts.append(f"[yellow]{relation}[/yellow]") + if item_type: + parts.append(f"[dim]{item_type}[/dim]") + parts.append(f"[cyan]{item_title}[/cyan]") + + tree.add(" ".join(parts)) + + subtitle = f"{total} related item(s)" + console.print(Panel(tree, subtitle=subtitle, expand=False)) + + +def _display_recent_activity(result: list[dict[str, Any]]) -> None: + """Render recent-activity results as a Rich table.""" + if not result: + console.print( + Panel(Text("No recent activity.", style="dim"), title="Recent Activity", expand=False) + ) + return + + table = Table(show_header=True, header_style="bold", expand=False) + table.add_column("Type", style="dim", width=12) + table.add_column("Title", style="bold cyan") + table.add_column("Permalink", style="green") + table.add_column("Updated", style="dim") + + 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 "") + table.add_row(item_type, item_title, permalink, updated) + + console.print(Panel(table, title="Recent Activity", expand=False)) + + 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") @@ -183,6 +312,9 @@ def read_note( include_frontmatter: bool = typer.Option( False, "--include-frontmatter", help="Include YAML frontmatter in output" ), + json_output: bool = typer.Option( + False, "--json", help="Output raw JSON instead of formatted display" + ), project: Annotated[ Optional[str], typer.Option(help="The project to use. If not provided, the default project will be used."), @@ -201,10 +333,14 @@ 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. + Examples: bm tool read-note my-note bm tool read-note my-note --include-frontmatter + bm tool read-note my-note --json """ try: validate_routing_flags(local, cloud) @@ -232,7 +368,14 @@ def read_note( _print_json(result) raise typer.Exit(1) - _print_json(result) + # 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(): + _print_json(result) + else: + _display_read_note(result) except ValueError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) @@ -390,6 +533,9 @@ def build_context( page: int = typer.Option(1, "--page", help="Page number for pagination"), page_size: int = typer.Option(10, "--page-size", help="Number of results per page"), max_related: int = typer.Option(10, "--max-related", help="Maximum related items to return"), + json_output: bool = typer.Option( + False, "--json", help="Output raw JSON instead of formatted display" + ), project: Annotated[ Optional[str], typer.Option(help="The project to use. If not provided, the default project will be used."), @@ -408,10 +554,14 @@ 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. + 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 --json """ try: validate_routing_flags(local, cloud) @@ -430,7 +580,15 @@ def build_context( output_format="json", ) ) - _print_json(result) + + # 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(): + _print_json(result) + else: + _display_build_context(result) except ValueError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) @@ -452,6 +610,9 @@ def recent_activity( # Match the MCP recent_activity default (page_size=10) so identical default # invocations return the same number of rows from CLI and MCP. page_size: int = typer.Option(10, "--page-size", help="Number of results per page"), + json_output: bool = typer.Option( + False, "--json", help="Output raw JSON instead of formatted display" + ), project: Annotated[ Optional[str], typer.Option(help="The project to use. If not provided, the default project will be used."), @@ -470,11 +631,15 @@ 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. + 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 --json """ try: validate_routing_flags(local, cloud) @@ -492,7 +657,15 @@ def recent_activity( output_format="json", ) ) - _print_json(result) + + # 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(): + _print_json(result) + else: + _display_recent_activity(result) except ValueError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) @@ -556,6 +729,9 @@ def search_notes( ] = None, page: int = typer.Option(1, "--page", help="Page number for pagination"), page_size: int = typer.Option(10, "--page-size", help="Number of results per page"), + json_output: bool = typer.Option( + False, "--json", help="Output raw JSON instead of formatted display" + ), project: Annotated[ Optional[str], typer.Option(help="The project to use. If not provided, the default project will be used."), @@ -574,6 +750,9 @@ 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. + Examples: bm tool search-notes "my query" @@ -581,6 +760,7 @@ 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" --json """ try: validate_routing_flags(local, cloud) @@ -658,7 +838,14 @@ def search_notes( typer.echo(result, err=True) raise typer.Exit(1) - _print_json(result) + # 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(): + _print_json(result) + else: + _display_search_results(result) except ValueError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py new file mode 100644 index 000000000..610c96ea4 --- /dev/null +++ b/tests/cli/test_cli_tool_rich_output.py @@ -0,0 +1,370 @@ +"""Tests for Rich (human-readable) output mode for bm tool commands. + +Commands default to Rich output when stdout is a TTY and fall back to raw JSON +when stdout is piped or --json is supplied. These tests verify both modes. +""" + +import json +from unittest.mock import AsyncMock, patch + +import pytest +from typer.testing import CliRunner + +from basic_memory.cli.main import app as cli_app + +runner = CliRunner() + +# --------------------------------------------------------------------------- +# Shared mock payloads (mirrors test_cli_tool_json_output.py for symmetry) +# --------------------------------------------------------------------------- + +READ_NOTE_RESULT = { + "title": "Test Note", + "permalink": "notes/test-note", + "file_path": "notes/Test Note.md", + "content": "# Test Note\n\nhello world", + "frontmatter": {"title": "Test Note", "tags": ["test"]}, +} + +SEARCH_RESULT = { + "query": "test", + "total": 2, + "page": 1, + "page_size": 10, + "results": [ + { + "type": "entity", + "title": "Test Note", + "permalink": "notes/test-note", + "file_path": "notes/Test Note.md", + }, + { + "type": "observation", + "title": "Another Note", + "permalink": "notes/another-note", + "file_path": "notes/Another Note.md", + }, + ], +} + +SEARCH_RESULT_EMPTY = { + "query": "nothing", + "total": 0, + "page": 1, + "page_size": 10, + "results": [], +} + +BUILD_CONTEXT_RESULT = { + "results": [ + { + "type": "entity", + "title": "Related Note", + "permalink": "notes/related", + "relation_type": "references", + } + ], + "metadata": {"uri": "notes/test-note", "depth": 1}, + "page": 1, + "page_size": 10, +} + +BUILD_CONTEXT_EMPTY = { + "results": [], + "metadata": {"uri": "notes/test-note", "depth": 1}, + "page": 1, + "page_size": 10, +} + +RECENT_ACTIVITY_RESULT = [ + { + "type": "entity", + "title": "Note A", + "permalink": "notes/note-a", + "file_path": "notes/Note A.md", + "created_at": "2025-01-01 00:00:00", + "updated_at": "2025-01-01 12:00:00", + }, + { + "type": "entity", + "title": "Note B", + "permalink": "notes/note-b", + "file_path": "notes/Note B.md", + "created_at": "2025-01-02 00:00:00", + "updated_at": None, + }, +] + + +# --------------------------------------------------------------------------- +# Helper: simulate a TTY by patching _use_rich to return True +# --------------------------------------------------------------------------- + + +def _tty_runner(args, **kwargs): + """Invoke CLI as if stdout is a TTY (Rich output enabled).""" + with patch("basic_memory.cli.commands.tool._use_rich", return_value=True): + return runner.invoke(cli_app, args, **kwargs) + + +# --------------------------------------------------------------------------- +# search-notes – Rich output +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_search_notes_rich_output_default(mock_mcp): + """search-notes produces Rich table output when stdout is a TTY.""" + result = _tty_runner(["tool", "search-notes", "test"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # Rich output should NOT be valid JSON + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + # But it should contain the result titles + assert "Test Note" in result.output + assert "Another Note" in result.output + assert "notes/test-note" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT_EMPTY, +) +def test_search_notes_rich_empty(mock_mcp): + """search-notes Rich output handles empty results gracefully.""" + result = _tty_runner(["tool", "search-notes", "nothing"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "No results found" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_search_notes_json_flag_overrides_tty(mock_mcp): + """search-notes --json outputs raw JSON even when stdout is 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 + assert data["results"][0]["title"] == "Test Note" + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT, +) +def test_search_notes_non_tty_gives_json(mock_mcp): + """search-notes outputs JSON when stdout is not a TTY (default runner behaviour).""" + # CliRunner does not set isatty(); _use_rich() returns False → JSON path. + result = runner.invoke(cli_app, ["tool", "search-notes", "test"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["total"] == 2 + + +# --------------------------------------------------------------------------- +# read-note – Rich output +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_rich_output_default(mock_mcp): + """read-note produces Rich formatted output when stdout is a TTY.""" + result = _tty_runner(["tool", "read-note", "test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # Rich output contains the note title + assert "Test Note" in result.output + # And the rendered markdown content + assert "hello world" in result.output + # Not raw JSON + with pytest.raises((json.JSONDecodeError, ValueError)): + json.loads(result.output) + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_json_flag_overrides_tty(mock_mcp): + """read-note --json outputs raw JSON even when stdout is a TTY.""" + result = _tty_runner(["tool", "read-note", "test-note", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["title"] == "Test Note" + assert data["content"] == "# Test Note\n\nhello world" + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value={"title": "", "permalink": "", "content": "", "frontmatter": {}}, +) +def test_read_note_rich_empty_content(mock_mcp): + """read-note Rich output handles empty content without crashing.""" + result = _tty_runner(["tool", "read-note", "empty-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "no content" in result.output.lower() + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_non_tty_gives_json(mock_mcp): + """read-note outputs JSON when stdout is not a TTY.""" + result = runner.invoke(cli_app, ["tool", "read-note", "test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert data["title"] == "Test Note" + + +# --------------------------------------------------------------------------- +# build-context – Rich output +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_RESULT, +) +def test_build_context_rich_output_default(mock_mcp): + """build-context produces Rich tree output when stdout is a TTY.""" + result = _tty_runner(["tool", "build-context", "memory://notes/test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "notes/test-note" in result.output + assert "Related Note" in result.output + # Not raw 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_EMPTY, +) +def test_build_context_rich_empty(mock_mcp): + """build-context Rich output handles empty results gracefully.""" + result = _tty_runner(["tool", "build-context", "memory://notes/test-note"]) + + 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_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_RESULT, +) +def test_build_context_json_flag_overrides_tty(mock_mcp): + """build-context --json outputs raw JSON even when stdout is a TTY.""" + result = _tty_runner(["tool", "build-context", "memory://notes/test-note", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert "results" in data + assert data["results"][0]["title"] == "Related Note" + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_RESULT, +) +def test_build_context_non_tty_gives_json(mock_mcp): + """build-context outputs JSON when stdout is not a TTY.""" + result = runner.invoke(cli_app, ["tool", "build-context", "memory://notes/test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert "results" in data + + +# --------------------------------------------------------------------------- +# recent-activity – Rich output +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_recent_activity", + new_callable=AsyncMock, + return_value=RECENT_ACTIVITY_RESULT, +) +def test_recent_activity_rich_output_default(mock_mcp): + """recent-activity produces Rich table output when stdout is a TTY.""" + result = _tty_runner(["tool", "recent-activity"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "Note A" in result.output + assert "Note B" in result.output + assert "notes/note-a" in result.output + # Not raw 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_rich_empty(mock_mcp): + """recent-activity Rich output handles empty results gracefully.""" + result = _tty_runner(["tool", "recent-activity"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "No recent activity" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_recent_activity", + new_callable=AsyncMock, + return_value=RECENT_ACTIVITY_RESULT, +) +def test_recent_activity_json_flag_overrides_tty(mock_mcp): + """recent-activity --json outputs raw JSON even when stdout is a TTY.""" + result = _tty_runner(["tool", "recent-activity", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert isinstance(data, list) + assert data[0]["title"] == "Note A" + + +@patch( + "basic_memory.cli.commands.tool.mcp_recent_activity", + new_callable=AsyncMock, + return_value=RECENT_ACTIVITY_RESULT, +) +def test_recent_activity_non_tty_gives_json(mock_mcp): + """recent-activity outputs JSON when stdout is not a TTY.""" + result = runner.invoke(cli_app, ["tool", "recent-activity"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = json.loads(result.output) + assert isinstance(data, list) + assert len(data) == 2 From c6cdf147c2e6a1c1b593e85f28ffc4d5ca6c9bf2 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 11 Jun 2026 01:23:47 -0500 Subject: [PATCH 02/11] fix(cli): fix build-context Rich formatter to use real nested ContextResult shape _display_build_context was reading top-level item.get("title")/item.get("type") on each results[i], but the real GraphContext.model_dump() shape wraps every item as a ContextResult with primary_result + related_results nested inside. This made every related note render as an empty tree node. Fix: - Rewrite _display_build_context to iterate context_items, build a primary-result node from item["primary_result"], then add each item["related_results"] entry as a child with relation_type/type/title rendered. - Update _display_search_results to use the real SearchResponse key "current_page" (not "page"), pass query from the CLI argument, add Score and Snippet columns (score + matched_chunk/content truncated to 200 chars) as the issue requested. - Update test fixtures in test_cli_tool_rich_output.py to match the real payload shapes (nested ContextResult, current_page, score/matched_chunk fields, no updated_at in recent-activity). - Fix test_build_context_json_flag_overrides_tty assertion to navigate the nested primary_result/related_results shape. Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 96 ++++++++++++++++++-------- tests/cli/test_cli_tool_rich_output.py | 56 +++++++++++---- 2 files changed, 112 insertions(+), 40 deletions(-) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 3526f89ff..e7422d0af 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -67,15 +67,23 @@ def _print_json(result: Any) -> None: # --- Rich formatters --- -def _display_search_results(result: dict[str, Any]) -> None: - """Render search-notes results as a Rich table.""" +def _display_search_results(result: dict[str, Any], query: str = "") -> None: + """Render search-notes results as a Rich table. + + Real SearchResponse.model_dump() shape: + results: list of SearchResult dicts (title, type, permalink, score, matched_chunk, content) + current_page: int (NOT "page") + page_size: int + total: int + has_more: bool + """ results = result.get("results", []) total = result.get("total", len(results)) - query = result.get("query") or "" - page = result.get("page", 1) - page_size = result.get("page_size", len(results)) + # Real key is "current_page"; fall back to "page" for forward-compat. + page = result.get("current_page") or result.get("page", 1) + page_size = result.get("page_size", len(results)) or 1 - title = f"Search results for [bold cyan]{query}[/bold cyan]" if query else "Search results" + title = f"Search: [bold cyan]{query}[/bold cyan]" if query else "Search results" subtitle = f"{total} result(s) • page {page} of {max(1, -(-total // page_size))}" if not results: @@ -85,13 +93,21 @@ def _display_search_results(result: dict[str, Any]) -> None: table = Table(show_header=True, header_style="bold", expand=False) table.add_column("Type", style="dim", width=12) table.add_column("Title", style="bold cyan") + table.add_column("Score", style="yellow", width=7) table.add_column("Permalink", style="green") + table.add_column("Snippet", style="dim", max_width=60) for item in results: item_type = item.get("type", "") item_title = item.get("title") or item.get("permalink", "") permalink = item.get("permalink", "") - table.add_row(item_type, item_title, permalink) + score = item.get("score") + score_str = f"{score:.2f}" if score is not None else "" + # Prefer matched_chunk as the most relevant snippet; fall back to content. + raw_snippet = item.get("matched_chunk") or item.get("content") or "" + # Truncate to ~200 chars so the table stays readable. + snippet = raw_snippet[:200].replace("\n", " ") if raw_snippet else "" + table.add_row(item_type, item_title, score_str, permalink, snippet) console.print(Panel(table, title=title, subtitle=subtitle, expand=False)) @@ -116,33 +132,59 @@ def _display_read_note(result: dict[str, Any]) -> None: def _display_build_context(result: dict[str, Any]) -> None: - """Render build-context result as a Rich tree.""" + """Render build-context result as a Rich tree. + + Real GraphContext.model_dump() shape: + results: list of ContextResult dicts, each with: + primary_result: EntitySummary | RelationSummary | ObservationSummary + observations: list of ObservationSummary + related_results: list of EntitySummary | RelationSummary | ObservationSummary + metadata: {"uri": ..., ...} + page/page_size/has_more + + Each summary has: type, title (EntitySummary/RelationSummary), permalink, + and relation_type (RelationSummary only). + """ metadata = result.get("metadata", {}) uri = metadata.get("uri", "") - results = result.get("results", []) - total = len(results) + context_items: list[dict[str, Any]] = list(result.get("results", [])) label = f"[bold cyan]{uri}[/bold cyan]" if uri else "Context" tree = Tree(f"[bold]Context:[/bold] {label}") - if not results: + if not context_items: tree.add("[dim]No related content found.[/dim]") else: - for item in results: - item_title = item.get("title") or item.get("permalink", "") - relation = item.get("relation_type", "") - item_type = item.get("type", "") - - parts = [] - if relation: - parts.append(f"[yellow]{relation}[/yellow]") - if item_type: - parts.append(f"[dim]{item_type}[/dim]") - parts.append(f"[cyan]{item_title}[/cyan]") - - tree.add(" ".join(parts)) - - subtitle = f"{total} related item(s)" + for context_result in context_items: + # --- Primary result node --- + primary = context_result.get("primary_result", {}) + p_title = primary.get("title") or primary.get("permalink", "") + p_type = primary.get("type", "") + primary_label = f"[cyan]{p_title}[/cyan]" + if p_type: + primary_label = f"[dim]{p_type}[/dim] {primary_label}" + primary_node = tree.add(primary_label) + + # --- Related items as children --- + 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 = [] + if relation: + parts.append(f"[yellow]{relation}[/yellow]") + if rel_type: + parts.append(f"[dim]{rel_type}[/dim]") + parts.append(f"[cyan]{rel_title}[/cyan]") + primary_node.add(" ".join(parts)) + + # Count total related items across all primary results. + total_related = sum( + len(cr.get("related_results", [])) for cr in context_items + ) + subtitle = f"{len(context_items)} primary • {total_related} related" console.print(Panel(tree, subtitle=subtitle, expand=False)) @@ -845,7 +887,7 @@ def search_notes( if json_output or not _use_rich(): _print_json(result) else: - _display_search_results(result) + _display_search_results(result, query=query or "") except ValueError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index 610c96ea4..23a2a5c7c 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -27,46 +27,72 @@ } SEARCH_RESULT = { - "query": "test", + # Real SearchResponse.model_dump() uses "current_page", not "page". + # No "query" key in the response -- the query comes from the CLI argument. "total": 2, - "page": 1, + "current_page": 1, "page_size": 10, + "has_more": False, "results": [ { "type": "entity", "title": "Test Note", "permalink": "notes/test-note", "file_path": "notes/Test Note.md", + "score": 0.95, + "matched_chunk": "A snippet about test notes", + "content": None, }, { "type": "observation", "title": "Another Note", "permalink": "notes/another-note", "file_path": "notes/Another Note.md", + "score": 0.72, + "matched_chunk": None, + "content": "Full content here", }, ], } SEARCH_RESULT_EMPTY = { - "query": "nothing", "total": 0, - "page": 1, + "current_page": 1, "page_size": 10, + "has_more": False, "results": [], } BUILD_CONTEXT_RESULT = { + # Real GraphContext.model_dump() shape: results is a list of ContextResult dicts. + # Each ContextResult has primary_result + observations + related_results. "results": [ { - "type": "entity", - "title": "Related Note", - "permalink": "notes/related", - "relation_type": "references", + "primary_result": { + "type": "entity", + "external_id": "abc123", + "title": "Test Note", + "permalink": "notes/test-note", + "file_path": "notes/Test Note.md", + "created_at": "2025-01-01T00:00:00", + }, + "observations": [], + "related_results": [ + { + "type": "relation", + "title": "Related Note", + "permalink": "notes/related", + "file_path": "notes/Related Note.md", + "relation_type": "references", + "created_at": "2025-01-01T00:00:00", + } + ], } ], "metadata": {"uri": "notes/test-note", "depth": 1}, "page": 1, "page_size": 10, + "has_more": False, } BUILD_CONTEXT_EMPTY = { @@ -74,16 +100,18 @@ "metadata": {"uri": "notes/test-note", "depth": 1}, "page": 1, "page_size": 10, + "has_more": False, } RECENT_ACTIVITY_RESULT = [ + # Real _extract_recent_rows output keys: type/title/permalink/file_path/created_at + # (optional: project). No "updated_at" key in the real output. { "type": "entity", "title": "Note A", "permalink": "notes/note-a", "file_path": "notes/Note A.md", "created_at": "2025-01-01 00:00:00", - "updated_at": "2025-01-01 12:00:00", }, { "type": "entity", @@ -91,7 +119,6 @@ "permalink": "notes/note-b", "file_path": "notes/Note B.md", "created_at": "2025-01-02 00:00:00", - "updated_at": None, }, ] @@ -125,10 +152,11 @@ def test_search_notes_rich_output_default(mock_mcp): # Rich output should NOT be valid JSON with pytest.raises((json.JSONDecodeError, ValueError)): json.loads(result.output) - # But it should contain the result titles + # But it should contain the result titles and partial permalink assert "Test Note" in result.output assert "Another Note" in result.output - assert "notes/test-note" in result.output + # Rich may truncate long permalinks with ellipsis; check the prefix. + assert "notes/test-no" in result.output @patch( @@ -287,7 +315,9 @@ def test_build_context_json_flag_overrides_tty(mock_mcp): assert result.exit_code == 0, f"CLI failed: {result.output}" data = json.loads(result.output) assert "results" in data - assert data["results"][0]["title"] == "Related Note" + # Real shape: results[i] is a ContextResult with primary_result nested inside. + assert data["results"][0]["primary_result"]["title"] == "Test Note" + assert data["results"][0]["related_results"][0]["title"] == "Related Note" @patch( From 457cd0ed3bfaf0ae9ea89d95886c2d479a644469 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 11 Jun 2026 02:04:06 -0500 Subject: [PATCH 03/11] fix(cli): route string tool results to JSON path to satisfy ty narrowing MCP tools return str | dict depending on output_format; the Rich display helpers require the dict (or list) shape. String payloads keep main's behavior of printing through the JSON path. Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index e7422d0af..aae42932b 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -181,9 +181,7 @@ def _display_build_context(result: dict[str, Any]) -> None: primary_node.add(" ".join(parts)) # Count total related items across all primary results. - total_related = sum( - len(cr.get("related_results", [])) for cr in context_items - ) + total_related = sum(len(cr.get("related_results", [])) for cr in context_items) subtitle = f"{len(context_items)} primary • {total_related} related" console.print(Panel(tree, subtitle=subtitle, expand=False)) @@ -414,7 +412,7 @@ def read_note( # 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(): + if json_output or not _use_rich() or isinstance(result, str): _print_json(result) else: _display_read_note(result) @@ -627,7 +625,7 @@ def build_context( # 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(): + if json_output or not _use_rich() or isinstance(result, str): _print_json(result) else: _display_build_context(result) @@ -704,7 +702,7 @@ def recent_activity( # 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(): + if json_output or not _use_rich() or isinstance(result, str): _print_json(result) else: _display_recent_activity(result) From e079eca42bf40d266c5373883dba96a3fce8baca Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 11 Jun 2026 09:37:51 -0500 Subject: [PATCH 04/11] fix(cli): render frontmatter and observations in Rich TTY output - `_display_read_note`: render frontmatter key/value panel when present so `--include-frontmatter` is not silently dropped in the Rich path - `_display_build_context`: render ContextResult.observations under each primary node (category + truncated content) so interactive users see the same core facts as `--json` output; update subtitle to include count - Update BUILD_CONTEXT_RESULT fixture with real ObservationSummary shape (type/category/content/permalink/file_path/created_at) - Add test_read_note_rich_include_frontmatter asserting frontmatter keys appear - Add test_build_context_rich_renders_observations asserting category/content visible Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 36 +++++++++++++- tests/cli/test_cli_tool_rich_output.py | 67 +++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index aae42932b..e6643b153 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -113,10 +113,11 @@ def _display_search_results(result: dict[str, Any], query: str = "") -> None: def _display_read_note(result: dict[str, Any]) -> None: - """Render read-note result: header panel + rendered Markdown content.""" + """Render read-note result: header panel + optional frontmatter + rendered Markdown content.""" title = result.get("title", "") permalink = result.get("permalink", "") content = result.get("content", "") + frontmatter: dict[str, Any] = result.get("frontmatter") or {} header = Text() header.append(title, style="bold cyan") @@ -125,6 +126,18 @@ def _display_read_note(result: dict[str, Any]) -> None: console.print(Panel(header, expand=False)) + # Trigger: --include-frontmatter was passed; the MCP tool populates "frontmatter". + # Why: without rendering it here, the explicitly requested metadata is silently + # dropped in the Rich path — users must also know to add --json to see it. + # Outcome: print a dim key/value block above the content when frontmatter is present. + if frontmatter: + fm_table = Table(show_header=False, box=None, padding=(0, 1), expand=False) + fm_table.add_column("key", style="dim") + fm_table.add_column("value", style="dim") + for key, value in frontmatter.items(): + fm_table.add_row(str(key), str(value)) + console.print(Panel(fm_table, title="[dim]frontmatter[/dim]", expand=False)) + if content: console.print(Markdown(content)) else: @@ -165,6 +178,24 @@ def _display_build_context(result: dict[str, Any]) -> None: primary_label = f"[dim]{p_type}[/dim] {primary_label}" primary_node = tree.add(primary_label) + # --- Observations as children (category + truncated content) --- + # Trigger: ContextResult.observations exists in the JSON output but was + # never rendered in the Rich path. + # Why: users running interactively lost core entity facts (observations) + # that the --json path exposes; the TTY view must be at least as + # informative as the JSON view for the primary entity. + # Outcome: each observation appears as a dim "[category] content" leaf + # under its primary node, truncated at 120 chars. + observations: list[dict[str, Any]] = list(context_result.get("observations", [])) + for obs in observations: + category = obs.get("category", "") + obs_content = obs.get("content", "") + # Truncate long observations so the tree stays readable. + if len(obs_content) > 120: + obs_content = obs_content[:117] + "..." + obs_label = f"[dim][{category}] {obs_content}[/dim]" + primary_node.add(obs_label) + # --- Related items as children --- related: list[dict[str, Any]] = list(context_result.get("related_results", [])) for rel_item in related: @@ -182,7 +213,8 @@ def _display_build_context(result: dict[str, Any]) -> None: # Count total related items across all primary results. total_related = sum(len(cr.get("related_results", [])) for cr in context_items) - subtitle = f"{len(context_items)} primary • {total_related} related" + total_observations = sum(len(cr.get("observations", [])) for cr in context_items) + subtitle = f"{len(context_items)} primary • {total_observations} observations • {total_related} related" console.print(Panel(tree, subtitle=subtitle, expand=False)) diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index 23a2a5c7c..9a7963ce8 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -66,6 +66,7 @@ BUILD_CONTEXT_RESULT = { # Real GraphContext.model_dump() shape: results is a list of ContextResult dicts. # Each ContextResult has primary_result + observations + related_results. + # ObservationSummary fields: type, category, content, permalink, file_path, created_at. "results": [ { "primary_result": { @@ -76,7 +77,16 @@ "file_path": "notes/Test Note.md", "created_at": "2025-01-01T00:00:00", }, - "observations": [], + "observations": [ + { + "type": "observation", + "category": "fact", + "content": "This is a key fact about the test note", + "permalink": "notes/test-note", + "file_path": "notes/Test Note.md", + "created_at": "2025-01-01T00:00:00", + } + ], "related_results": [ { "type": "relation", @@ -398,3 +408,58 @@ def test_recent_activity_non_tty_gives_json(mock_mcp): data = json.loads(result.output) assert isinstance(data, list) assert len(data) == 2 + + +# --------------------------------------------------------------------------- +# read-note – frontmatter rendering (issue #678) +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_rich_include_frontmatter(mock_mcp): + """read-note --include-frontmatter renders frontmatter keys in Rich path. + + Regression: previously the Rich renderer silently dropped frontmatter even + when --include-frontmatter was passed, requiring --json to see the data. + """ + result = _tty_runner(["tool", "read-note", "test-note", "--include-frontmatter"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # Frontmatter section header should appear + assert "frontmatter" in result.output + # The frontmatter key and value from READ_NOTE_RESULT should be visible + assert "tags" in result.output + assert "test" in result.output + # The note content should still appear + assert "hello world" in result.output + + +# --------------------------------------------------------------------------- +# build-context – observations rendering (issue #678) +# --------------------------------------------------------------------------- + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_RESULT, +) +def test_build_context_rich_renders_observations(mock_mcp): + """build-context Rich tree includes observations under each primary node. + + Regression: ContextResult.observations was exposed in JSON output but never + rendered in the Rich path, so interactive users lost core entity facts. + """ + result = _tty_runner(["tool", "build-context", "memory://notes/test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # The observation category should appear in the tree + assert "fact" in result.output + # The observation content should appear (possibly truncated) + assert "key fact" in result.output + # The subtitle should include an observations count + assert "observations" in result.output From 32e7afe3ce698fe18b63eaf2d4bb9f1faae1225b Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 11 Jun 2026 12:28:36 -0500 Subject: [PATCH 05/11] fix(cli): escape user-sourced values in Rich output and fix frontmatter flag gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — Rich markup injection: user-sourced titles, permalinks, snippets, observation categories/content, and frontmatter keys/values were interpolated directly into Rich markup strings, causing bracketed text (e.g. "[draft]", "[fact]") to be silently swallowed or restyled. Apply markup_escape() at every injection point in _display_search_results, _display_read_note, _display_build_context, and _display_recent_activity. Observation labels use markup_escape on the full "[category] content" fragment so the literal brackets around the category are also escaped. Bug 2 — frontmatter panel ignores flag: _display_read_note rendered the frontmatter panel whenever result["frontmatter"] was non-empty, but the JSON payload always carries that key regardless of --include-frontmatter. Thread the boolean flag as a keyword argument and gate the panel on both the flag and non-empty content. Bug 3 — "0 result(s)" subtitle: the search API returns total=0 even when results is non-empty. result.get("total", len(results)) never triggered its default because the key exists; fall back to len(results) when total is falsy but results is non-empty, keeping page-count math consistent. Tests: add cases for bracketed title surviving search output, "[fact]" category surviving build-context tree, read-note without --include-frontmatter asserting no frontmatter panel, and search with total=0 asserting correct subtitle count. Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 86 +++++++++--- tests/cli/test_cli_tool_rich_output.py | 179 +++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 21 deletions(-) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index e6643b153..83a24eef8 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -15,6 +15,7 @@ from loguru import logger from rich.console import Console from rich.markdown import Markdown +from rich.markup import escape as markup_escape from rich.panel import Panel from rich.table import Table from rich.text import Text @@ -78,12 +79,23 @@ def _display_search_results(result: dict[str, Any], query: str = "") -> None: has_more: bool """ results = result.get("results", []) - total = result.get("total", len(results)) + # Trigger: API returns total=0 even when results is non-empty (upstream quirk). + # Why: the key exists with value 0, so result.get("total", len(results)) never + # applies its default, leaving the subtitle as "0 result(s)" under a + # populated table. + # Outcome: fall back to len(results) when total is falsy but results is non-empty. + raw_total = result.get("total", len(results)) + total = raw_total if raw_total else len(results) # Real key is "current_page"; fall back to "page" for forward-compat. page = result.get("current_page") or result.get("page", 1) page_size = result.get("page_size", len(results)) or 1 - title = f"Search: [bold cyan]{query}[/bold cyan]" if query else "Search results" + # Trigger: query is user-supplied text that may contain Rich markup characters. + # Why: interpolating it directly into a markup string causes brackets to be + # parsed as style tags, swallowing or restyling bracketed content. + # Outcome: escape the query so its literal characters are always displayed. + escaped_query = markup_escape(query) if query else query + title = f"Search: [bold cyan]{escaped_query}[/bold cyan]" if query else "Search results" subtitle = f"{total} result(s) • page {page} of {max(1, -(-total // page_size))}" if not results: @@ -99,26 +111,31 @@ def _display_search_results(result: dict[str, Any], query: str = "") -> None: for item in results: item_type = item.get("type", "") - item_title = item.get("title") or item.get("permalink", "") - permalink = item.get("permalink", "") + # Trigger: user-sourced title/permalink may contain bracketed text. + # Why: Rich table cells with a style column interpret markup in cell values, + # swallowing brackets (e.g. "Spec [draft] v2" → "Spec v2"). + # Outcome: escape every user-sourced cell value before adding to the table. + item_title = markup_escape(item.get("title") or item.get("permalink", "")) + permalink = markup_escape(item.get("permalink", "")) score = item.get("score") score_str = f"{score:.2f}" if score is not None else "" # Prefer matched_chunk as the most relevant snippet; fall back to content. raw_snippet = item.get("matched_chunk") or item.get("content") or "" # Truncate to ~200 chars so the table stays readable. - snippet = raw_snippet[:200].replace("\n", " ") if raw_snippet else "" + snippet = markup_escape(raw_snippet[:200].replace("\n", " ")) if raw_snippet else "" table.add_row(item_type, item_title, score_str, permalink, snippet) console.print(Panel(table, title=title, subtitle=subtitle, expand=False)) -def _display_read_note(result: dict[str, Any]) -> None: +def _display_read_note(result: dict[str, Any], *, include_frontmatter: bool = False) -> None: """Render read-note result: header panel + optional frontmatter + rendered Markdown content.""" title = result.get("title", "") permalink = result.get("permalink", "") content = result.get("content", "") frontmatter: dict[str, Any] = result.get("frontmatter") or {} + # The header already uses Text.append so title is never markup-interpreted. header = Text() header.append(title, style="bold cyan") if permalink: @@ -127,15 +144,20 @@ def _display_read_note(result: dict[str, Any]) -> None: console.print(Panel(header, expand=False)) # Trigger: --include-frontmatter was passed; the MCP tool populates "frontmatter". - # Why: without rendering it here, the explicitly requested metadata is silently - # dropped in the Rich path — users must also know to add --json to see it. - # Outcome: print a dim key/value block above the content when frontmatter is present. - if frontmatter: + # Why: the JSON payload always carries a "frontmatter" key regardless of the flag, + # so checking non-empty alone would render it even without the flag. The flag + # must be threaded in to gate the panel. + # Outcome: print a dim key/value block above the content only when the flag is set. + if include_frontmatter and frontmatter: fm_table = Table(show_header=False, box=None, padding=(0, 1), expand=False) fm_table.add_column("key", style="dim") fm_table.add_column("value", style="dim") for key, value in frontmatter.items(): - fm_table.add_row(str(key), str(value)) + # Trigger: frontmatter keys/values are user-sourced and may contain markup. + # Why: Rich table cells with a style column parse markup, so bracketed + # keys or values would be silently consumed or restyled. + # Outcome: escape both key and value before adding them to the table. + fm_table.add_row(markup_escape(str(key)), markup_escape(str(value))) console.print(Panel(fm_table, title="[dim]frontmatter[/dim]", expand=False)) if content: @@ -162,7 +184,11 @@ def _display_build_context(result: dict[str, Any]) -> None: uri = metadata.get("uri", "") context_items: list[dict[str, Any]] = list(result.get("results", [])) - label = f"[bold cyan]{uri}[/bold cyan]" if uri else "Context" + # Trigger: uri is user-sourced and may contain Rich markup characters. + # Why: interpolating it directly into a markup string causes brackets to be + # parsed as style tags, swallowing or restyling bracketed content. + # Outcome: escape the uri so its literal characters are always displayed. + label = f"[bold cyan]{markup_escape(uri)}[/bold cyan]" if uri else "Context" tree = Tree(f"[bold]Context:[/bold] {label}") if not context_items: @@ -171,8 +197,13 @@ def _display_build_context(result: dict[str, Any]) -> None: for context_result in context_items: # --- Primary result node --- primary = context_result.get("primary_result", {}) - p_title = primary.get("title") or primary.get("permalink", "") - p_type = primary.get("type", "") + # Trigger: p_title and p_type are user-sourced values from the knowledge graph. + # Why: embedding them in markup strings without escaping would cause any + # bracketed text (e.g. an entity titled "Spec [draft]") to be consumed + # by the Rich markup parser and silently dropped from output. + # Outcome: escape all user values before interpolating into markup strings. + p_title = markup_escape(primary.get("title") or primary.get("permalink", "")) + p_type = markup_escape(primary.get("type", "")) primary_label = f"[cyan]{p_title}[/cyan]" if p_type: primary_label = f"[dim]{p_type}[/dim] {primary_label}" @@ -193,15 +224,23 @@ def _display_build_context(result: dict[str, Any]) -> None: # Truncate long observations so the tree stays readable. if len(obs_content) > 120: obs_content = obs_content[:117] + "..." - obs_label = f"[dim][{category}] {obs_content}[/dim]" + # Trigger: category and obs_content are user-sourced strings that may + # contain Rich markup characters. The category is also wrapped + # in literal "[" "]" brackets in the label, which must be + # escaped too so Rich does not treat "[fact]" as a style tag. + # Why: embedding "[fact]" in a markup string causes Rich to parse it as + # an unknown tag and silently drop the text. + # Outcome: escape the full "[category] content" fragment including the + # surrounding brackets before embedding it in a styled label. + obs_label = f"[dim]{markup_escape(f'[{category}] {obs_content}')}[/dim]" primary_node.add(obs_label) # --- Related items as children --- 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", "") + rel_title = markup_escape(rel_item.get("title") or rel_item.get("permalink", "")) + rel_type = markup_escape(rel_item.get("type", "")) + relation = markup_escape(rel_item.get("relation_type", "")) parts = [] if relation: @@ -234,8 +273,13 @@ def _display_recent_activity(result: list[dict[str, Any]]) -> None: for item in result: item_type = item.get("type", "") - item_title = item.get("title") or item.get("permalink", "") - permalink = item.get("permalink", "") + # Trigger: title, permalink, and timestamps are user-sourced strings from the + # knowledge graph and may contain Rich markup characters. + # Why: Rich table cells with a style column parse markup in cell values, so + # bracketed content would be silently consumed or restyled. + # Outcome: escape all user-sourced cell values before adding to the table. + item_title = markup_escape(item.get("title") or item.get("permalink", "")) + permalink = markup_escape(item.get("permalink", "")) updated = str(item.get("updated_at") or item.get("created_at") or "") table.add_row(item_type, item_title, permalink, updated) @@ -447,7 +491,7 @@ def read_note( if json_output or not _use_rich() or isinstance(result, str): _print_json(result) else: - _display_read_note(result) + _display_read_note(result, include_frontmatter=include_frontmatter) except ValueError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index 9a7963ce8..cb3fea8ee 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -438,6 +438,28 @@ def test_read_note_rich_include_frontmatter(mock_mcp): assert "hello world" in result.output +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT, +) +def test_read_note_rich_no_frontmatter_without_flag(mock_mcp): + """read-note WITHOUT --include-frontmatter must not render the frontmatter panel. + + Regression (Bug 2): the JSON payload always contains a non-empty "frontmatter" + key, so the previous `if frontmatter:` guard rendered it even without the flag. + The flag must be threaded into _display_read_note to gate the panel. + """ + result = _tty_runner(["tool", "read-note", "test-note"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # The note title and content must still appear + assert "Test Note" in result.output + assert "hello world" in result.output + # The frontmatter panel must NOT appear + assert "frontmatter" not in result.output + + # --------------------------------------------------------------------------- # build-context – observations rendering (issue #678) # --------------------------------------------------------------------------- @@ -463,3 +485,160 @@ def test_build_context_rich_renders_observations(mock_mcp): assert "key fact" in result.output # The subtitle should include an observations count assert "observations" in result.output + + +# --------------------------------------------------------------------------- +# Rich markup injection – bracketed user text must survive (Bug 1, issue #678) +# --------------------------------------------------------------------------- + +# Search result whose title contains a bracket expression like "[draft]". +SEARCH_RESULT_BRACKETED_TITLE = { + "total": 1, + "current_page": 1, + "page_size": 10, + "has_more": False, + "results": [ + { + "type": "entity", + "title": "Spec [draft] v2", + "permalink": "specs/spec-draft-v2", + "file_path": "specs/Spec [draft] v2.md", + "score": 0.90, + "matched_chunk": "An important [red] section", + "content": None, + }, + ], +} + +# build-context payload where the observation category is "fact" — previously +# `[fact]` in the obs_label markup was interpreted as an unknown Rich tag and +# the text was swallowed. +BUILD_CONTEXT_BRACKETED_OBS = { + "results": [ + { + "primary_result": { + "type": "entity", + "external_id": "xyz", + "title": "Joanna", + "permalink": "people/joanna", + "file_path": "people/Joanna.md", + "created_at": "2025-01-01T00:00:00", + }, + "observations": [ + { + "type": "observation", + "category": "fact", + "content": "Joanna lives in Austin", + "permalink": "people/joanna", + "file_path": "people/Joanna.md", + "created_at": "2025-01-01T00:00:00", + } + ], + "related_results": [], + } + ], + "metadata": {"uri": "people/joanna", "depth": 1}, + "page": 1, + "page_size": 10, + "has_more": False, +} + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT_BRACKETED_TITLE, +) +def test_search_notes_rich_title_with_brackets_survives(mock_mcp): + """Bracketed text in a search result title must appear literally in Rich output. + + Regression (Bug 1): user-sourced titles were interpolated directly into Rich + markup strings, so "[draft]" was treated as an unknown style tag and stripped. + After escaping, the literal text "[draft]" must be present in the output. + """ + result = _tty_runner(["tool", "search-notes", "spec"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # The full title including the bracket expression must survive + assert "[draft]" in result.output + # The snippet "[red]" should also survive (not restyle the output) + assert "[red]" in result.output + + +@patch( + "basic_memory.cli.commands.tool.mcp_build_context", + new_callable=AsyncMock, + return_value=BUILD_CONTEXT_BRACKETED_OBS, +) +def test_build_context_rich_observation_category_bracket_survives(mock_mcp): + """Observation category "[fact]" must appear literally in build-context Rich tree. + + Regression (Bug 1): the obs_label was built as f"[dim][{category}] content[/dim]", + which caused the inner "[fact]" to be parsed as an unknown Rich tag and dropped, + rendering "Joanna lives in Austin" without the category prefix. + After escaping the category, "[fact]" must be present in the tree output. + """ + result = _tty_runner(["tool", "build-context", "memory://people/joanna"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # The observation category prefix must appear literally + assert "[fact]" in result.output + # The observation content must also appear + assert "Joanna lives in Austin" in result.output + + +# --------------------------------------------------------------------------- +# search-notes – total=0 with non-empty results subtitle (Bug 3, issue #678) +# --------------------------------------------------------------------------- + +# Fixture that mirrors the upstream quirk: total=0 but results is non-empty. +SEARCH_RESULT_ZERO_TOTAL = { + "total": 0, + "current_page": 1, + "page_size": 10, + "has_more": False, + "results": [ + { + "type": "entity", + "title": "Found Note", + "permalink": "notes/found-note", + "file_path": "notes/Found Note.md", + "score": 0.80, + "matched_chunk": "some content", + "content": None, + }, + { + "type": "entity", + "title": "Another Found", + "permalink": "notes/another-found", + "file_path": "notes/Another Found.md", + "score": 0.70, + "matched_chunk": "more content", + "content": None, + }, + ], +} + + +@patch( + "basic_memory.cli.commands.tool.mcp_search", + new_callable=AsyncMock, + return_value=SEARCH_RESULT_ZERO_TOTAL, +) +def test_search_notes_rich_zero_total_falls_back_to_result_count(mock_mcp): + """When the API returns total=0 but results is non-empty, subtitle shows real count. + + Regression (Bug 3): result.get("total", len(results)) never triggered its + default because the "total" key exists (with value 0), so the subtitle read + "0 result(s)" under a table showing rows. The fix detects a falsy total with + non-empty results and falls back to len(results). + """ + result = _tty_runner(["tool", "search-notes", "found"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + # Both result rows must appear + assert "Found Note" in result.output + assert "Another Found" in result.output + # 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 From eedafd6db2f676099acf23acef2981f519a0b6ed Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Thu, 11 Jun 2026 20:58:55 -0500 Subject: [PATCH 06/11] 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 83a24eef8..bf12f91bd 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 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 @@ # --- 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. + + 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. - 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. + 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 6578bff5f..87581b925 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -442,6 +442,18 @@ def __init__(self, **data: Any) -> None: ... ), ) + 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 cb3fea8ee..608296ed4 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -5,6 +5,7 @@ """ import json +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest @@ -139,11 +140,25 @@ 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 From a5114aec83e5e578f845431e402122afd0df8b3e Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Fri, 12 Jun 2026 10:56:36 -0500 Subject: [PATCH 07/11] fix(cli): trim frontmatter-strip newlines in plain read-note body The API content field keeps the blank line left by frontmatter stripping; plain print() rendered it as a double gap under the header. JSON mode stays byte-faithful. Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 7 +++++-- tests/cli/test_cli_tool_rich_output.py | 10 ++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index bf12f91bd..8fe60a29f 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -401,8 +401,11 @@ def _plain_read_note(result: dict[str, Any], *, include_frontmatter: bool = Fals print(f"{key}: {value}") print() - if content: - print(content) + # The API's content field keeps the blank line left by frontmatter + # stripping; trim newlines so the header gap stays a single blank line. + body = content.strip("\n") if content else "" + if body: + print(body) else: print("(no content)") diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index 608296ed4..0ec65eff3 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -23,7 +23,8 @@ "title": "Test Note", "permalink": "notes/test-note", "file_path": "notes/Test Note.md", - "content": "# Test Note\n\nhello world", + # Real payloads keep the leading newline left by frontmatter stripping. + "content": "\n# Test Note\n\nhello world", "frontmatter": {"title": "Test Note", "tags": ["test"]}, } @@ -263,7 +264,9 @@ def test_read_note_json_flag_overrides_tty(mock_mcp): assert result.exit_code == 0, f"CLI failed: {result.output}" data = json.loads(result.output) assert data["title"] == "Test Note" - assert data["content"] == "# Test Note\n\nhello world" + # JSON mode is byte-faithful: the payload's leading newline (frontmatter-strip + # artifact) is preserved here even though display modes trim it. + assert data["content"] == "\n# Test Note\n\nhello world" @patch( @@ -747,6 +750,9 @@ def test_read_note_plain_output(mock_mcp): assert "hello world" in result.output # No frontmatter without the flag assert "tags:" not in result.output + # Exactly one blank line between header and body: the payload's leading + # newline (frontmatter-strip artifact) must not stack with the renderer's. + assert "Test Note [notes/test-note]\n\n# Test Note" in result.output @patch( From 30c6721fbd17911be57f81f10fec9f3fc85839ca Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Fri, 12 Jun 2026 11:05:35 -0500 Subject: [PATCH 08/11] fix(cli): render frontmatter once with --include-frontmatter With the flag the API returns the literal file as content, so both display paths printed the frontmatter twice (synthesized block/panel + the block inside content). Plain now prints the file verbatim; Rich keeps the panel and strips the block from the Markdown body. Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 32 ++++++++++--------- tests/cli/test_cli_tool_rich_output.py | 43 +++++++++++++++++++------- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 8fe60a29f..256f2a922 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -36,6 +36,7 @@ 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.file_utils import has_frontmatter, remove_frontmatter 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 @@ -214,8 +215,16 @@ def _display_read_note(result: dict[str, Any], *, include_frontmatter: bool = Fa fm_table.add_row(markup_escape(str(key)), markup_escape(str(value))) console.print(Panel(fm_table, title="[dim]frontmatter[/dim]", expand=False)) - if content: - console.print(Markdown(content)) + # Trigger: --include-frontmatter makes the API return the literal file, so + # content starts with the frontmatter block the panel above already shows. + # Why: rendering it again through Markdown duplicates the frontmatter (and + # Markdown mangles the --- fences into rules/headings). + # Outcome: strip the block from the body; the panel is the frontmatter view. + body = content + if include_frontmatter and content and has_frontmatter(content): + body = remove_frontmatter(content) + if body and body.strip(): + console.print(Markdown(body)) else: console.print(Text("(no content)", style="dim")) @@ -385,24 +394,17 @@ def _plain_read_note(result: dict[str, Any], *, include_frontmatter: bool = Fals 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() - # The API's content field keeps the blank line left by frontmatter - # stripping; trim newlines so the header gap stays a single blank line. + # Trigger: --include-frontmatter makes the API return the literal file + # (frontmatter block included) as content. + # Why: plain mode should show that file verbatim -- synthesizing a separate + # key/value block would print the frontmatter twice. + # Outcome: with the flag, the body IS the frontmatter view; either way trim + # surrounding newlines so the header gap stays a single blank line. body = content.strip("\n") if content else "" if body: print(body) diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index 0ec65eff3..8443c8762 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -28,6 +28,16 @@ "frontmatter": {"title": "Test Note", "tags": ["test"]}, } +# With --include-frontmatter the API returns the LITERAL FILE as content +# (frontmatter block included) alongside the parsed frontmatter dict. +READ_NOTE_RESULT_WITH_FRONTMATTER = { + "title": "Test Note", + "permalink": "notes/test-note", + "file_path": "notes/Test Note.md", + "content": "---\ntitle: Test Note\ntags:\n- test\n---\n\n# Test Note\n\nhello world", + "frontmatter": {"title": "Test Note", "tags": ["test"]}, +} + SEARCH_RESULT = { # Real SearchResponse.model_dump() uses "current_page", not "page". # No "query" key in the response -- the query comes from the CLI argument. @@ -436,24 +446,29 @@ def test_recent_activity_non_tty_gives_json(mock_mcp): @patch( "basic_memory.cli.commands.tool.mcp_read_note", new_callable=AsyncMock, - return_value=READ_NOTE_RESULT, + return_value=READ_NOTE_RESULT_WITH_FRONTMATTER, ) def test_read_note_rich_include_frontmatter(mock_mcp): - """read-note --include-frontmatter renders frontmatter keys in Rich path. + """read-note --include-frontmatter renders the panel once, not twice. - Regression: previously the Rich renderer silently dropped frontmatter even - when --include-frontmatter was passed, requiring --json to see the data. + Regression 1: the Rich renderer silently dropped frontmatter even with the + flag. Regression 2: with the flag, content is the LITERAL FILE, so the + frontmatter block must be stripped from the Markdown body or it renders + again under the panel. """ result = _tty_runner(["tool", "read-note", "test-note", "--include-frontmatter"]) assert result.exit_code == 0, f"CLI failed: {result.output}" - # Frontmatter section header should appear + # Frontmatter panel appears with key/value data assert "frontmatter" in result.output - # The frontmatter key and value from READ_NOTE_RESULT should be visible assert "tags" in result.output assert "test" in result.output - # The note content should still appear + # The note content still appears assert "hello world" in result.output + # The frontmatter block is NOT rendered a second time through Markdown: + # the raw fence is stripped, and the title key appears only in the panel. + assert "---" not in result.output + assert result.output.count("title") == 1 @patch( @@ -758,16 +773,22 @@ def test_read_note_plain_output(mock_mcp): @patch( "basic_memory.cli.commands.tool.mcp_read_note", new_callable=AsyncMock, - return_value=READ_NOTE_RESULT, + return_value=READ_NOTE_RESULT_WITH_FRONTMATTER, ) def test_read_note_plain_include_frontmatter(mock_mcp): - """read-note --plain --include-frontmatter renders key: value lines.""" + """read-note --plain --include-frontmatter shows the literal file, once. + + With the flag, content IS the file (frontmatter block included); plain mode + prints it verbatim and must not prepend a synthesized key/value block. + """ 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 + # The literal file: fences and YAML lines exactly as stored + assert "---\ntitle: Test Note\ntags:\n- test\n---" in result.output assert "hello world" in result.output + # No duplicated frontmatter from a synthesized block + assert result.output.count("title: Test Note") == 1 @patch( From cab4bf10d0c7b7a529f284e5e85dc1bf6e9a366f Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Fri, 12 Jun 2026 11:14:08 -0500 Subject: [PATCH 09/11] fix(cli): emit only the literal file for plain read-note with --include-frontmatter The file's own frontmatter carries title/permalink, so the header line duplicated it. Plain without the flag keeps the header. Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 17 +++++++++-------- tests/cli/test_cli_tool_rich_output.py | 7 ++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 256f2a922..54fc51faf 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -395,16 +395,17 @@ def _plain_read_note(result: dict[str, Any], *, include_frontmatter: bool = Fals permalink = result.get("permalink", "") content = result.get("content", "") - header = f"{title} [{permalink}]" if permalink else title - print(header) - - print() # Trigger: --include-frontmatter makes the API return the literal file # (frontmatter block included) as content. - # Why: plain mode should show that file verbatim -- synthesizing a separate - # key/value block would print the frontmatter twice. - # Outcome: with the flag, the body IS the frontmatter view; either way trim - # surrounding newlines so the header gap stays a single blank line. + # Why: plain mode should show that file verbatim -- the file's own + # frontmatter already carries title/permalink, so a header line and a + # synthesized key/value block would both duplicate it. + # Outcome: with the flag, emit ONLY the file; without it, a title/permalink + # header then the body, trimmed to keep the gap a single blank line. + if not include_frontmatter: + header = f"{title} [{permalink}]" if permalink else title + print(header) + print() body = content.strip("\n") if content else "" if body: print(body) diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index 8443c8762..dfbb2b2c8 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -784,10 +784,11 @@ def test_read_note_plain_include_frontmatter(mock_mcp): result = _tty_runner(["tool", "read-note", "test-note", "--plain", "--include-frontmatter"]) assert result.exit_code == 0, f"CLI failed: {result.output}" - # The literal file: fences and YAML lines exactly as stored - assert "---\ntitle: Test Note\ntags:\n- test\n---" in result.output + # ONLY the literal file: no header line, output starts at the fence + assert "Test Note [notes/test-note]" not in result.output + assert result.output.startswith("---\ntitle: Test Note\ntags:\n- test\n---") assert "hello world" in result.output - # No duplicated frontmatter from a synthesized block + # No duplicated frontmatter from a synthesized block or header assert result.output.count("title: Test Note") == 1 From 6d2ff99b8f24fab3e8e9977070bd23d7338cd646 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Fri, 12 Jun 2026 11:29:13 -0500 Subject: [PATCH 10/11] fix(cli): plain read-note renders the payload faithfully, no decoration Drop the synthesized title/permalink header and the (no content) placeholder: plain mode is the content verbatim (note body, or the literal file with --include-frontmatter). Decoration belongs to the Rich path. Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 30 ++++++++++---------------- tests/cli/test_cli_tool_rich_output.py | 19 ++++++---------- 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 54fc51faf..8da155e45 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -389,28 +389,20 @@ def _plain_search_results(result: dict[str, Any], query: str = "") -> None: 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", "") +def _plain_read_note(result: dict[str, Any]) -> None: + """Render read-note content faithfully: the note body, or the literal file. + + Plain mode adds NO decoration: no header line, no synthesized frontmatter + block, no placeholder for empty notes. Without --include-frontmatter the + API returns the note body; with it, the literal file (frontmatter block + included). Either is printed verbatim, trimmed only of the surrounding + newline artifacts the API keeps from frontmatter stripping, so the output + round-trips (e.g. ``read-note X --plain --include-frontmatter > note.md``). + """ content = result.get("content", "") - - # Trigger: --include-frontmatter makes the API return the literal file - # (frontmatter block included) as content. - # Why: plain mode should show that file verbatim -- the file's own - # frontmatter already carries title/permalink, so a header line and a - # synthesized key/value block would both duplicate it. - # Outcome: with the flag, emit ONLY the file; without it, a title/permalink - # header then the body, trimmed to keep the gap a single blank line. - if not include_frontmatter: - header = f"{title} [{permalink}]" if permalink else title - print(header) - print() body = content.strip("\n") if content else "" if body: print(body) - else: - print("(no content)") def _plain_build_context(result: dict[str, Any]) -> None: @@ -679,7 +671,7 @@ def read_note( if mode == "json" or isinstance(result, str): _print_json(result) elif mode == "plain": - _plain_read_note(result, include_frontmatter=include_frontmatter) + _plain_read_note(result) else: _display_read_note(result, include_frontmatter=include_frontmatter) except ValueError as e: diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index dfbb2b2c8..5082b6104 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -755,19 +755,14 @@ def test_search_notes_plain_empty(mock_mcp): 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.""" + """read-note --plain emits the note body faithfully, with no decoration.""" 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 - # Exactly one blank line between header and body: the payload's leading - # newline (frontmatter-strip artifact) must not stack with the renderer's. - assert "Test Note [notes/test-note]\n\n# Test Note" in result.output + # No synthesized header line -- plain mode is the payload, faithfully. + assert "[notes/test-note]" not in result.output + # The body verbatim, trimmed of the API's frontmatter-strip newline artifact. + assert result.output == "# Test Note\n\nhello world\n" @patch( @@ -798,11 +793,11 @@ def test_read_note_plain_include_frontmatter(mock_mcp): return_value={"title": "", "permalink": "", "content": "", "frontmatter": {}}, ) def test_read_note_plain_empty_content(mock_mcp): - """read-note --plain handles empty content.""" + """read-note --plain prints nothing for an empty note (no placeholder).""" 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 + assert result.output == "" @patch( From 83f60054df03deeea12bbbd769a750c11c69e3b0 Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Fri, 12 Jun 2026 11:35:16 -0500 Subject: [PATCH 11/11] feat(cli): rename read-note --include-frontmatter to --frontmatter Keep --include-frontmatter as a deprecated alias for back-compat (the old name shipped in 0.22.0). The MCP tool parameter include_frontmatter is unchanged. Co-Authored-By: Claude Signed-off-by: Drew Cain --- src/basic_memory/cli/commands/tool.py | 15 +++++++++------ tests/cli/test_cli_tool_rich_output.py | 17 +++++++++++++++-- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index 8da155e45..23d394de2 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -198,7 +198,7 @@ def _display_read_note(result: dict[str, Any], *, include_frontmatter: bool = Fa console.print(Panel(header, expand=False)) - # Trigger: --include-frontmatter was passed; the MCP tool populates "frontmatter". + # Trigger: --frontmatter was passed; the MCP tool populates "frontmatter". # Why: the JSON payload always carries a "frontmatter" key regardless of the flag, # so checking non-empty alone would render it even without the flag. The flag # must be threaded in to gate the panel. @@ -215,7 +215,7 @@ def _display_read_note(result: dict[str, Any], *, include_frontmatter: bool = Fa fm_table.add_row(markup_escape(str(key)), markup_escape(str(value))) console.print(Panel(fm_table, title="[dim]frontmatter[/dim]", expand=False)) - # Trigger: --include-frontmatter makes the API return the literal file, so + # Trigger: --frontmatter makes the API return the literal file, so # content starts with the frontmatter block the panel above already shows. # Why: rendering it again through Markdown duplicates the frontmatter (and # Markdown mangles the --- fences into rules/headings). @@ -393,11 +393,11 @@ def _plain_read_note(result: dict[str, Any]) -> None: """Render read-note content faithfully: the note body, or the literal file. Plain mode adds NO decoration: no header line, no synthesized frontmatter - block, no placeholder for empty notes. Without --include-frontmatter the + block, no placeholder for empty notes. Without --frontmatter the API returns the note body; with it, the literal file (frontmatter block included). Either is printed verbatim, trimmed only of the surrounding newline artifacts the API keeps from frontmatter stripping, so the output - round-trips (e.g. ``read-note X --plain --include-frontmatter > note.md``). + round-trips (e.g. ``read-note X --plain --frontmatter > note.md``). """ content = result.get("content", "") body = content.strip("\n") if content else "" @@ -600,7 +600,10 @@ def write_note( def read_note( identifier: str, include_frontmatter: bool = typer.Option( - False, "--include-frontmatter", help="Include YAML frontmatter in output" + False, + "--frontmatter", + "--include-frontmatter", + help="Include YAML frontmatter in output (--include-frontmatter is a deprecated alias)", ), json_output: bool = typer.Option( False, "--json", help="Output raw JSON instead of formatted display" @@ -634,7 +637,7 @@ def read_note( Examples: bm tool read-note my-note - bm tool read-note my-note --include-frontmatter + bm tool read-note my-note --frontmatter bm tool read-note my-note --plain bm tool read-note my-note --json """ diff --git a/tests/cli/test_cli_tool_rich_output.py b/tests/cli/test_cli_tool_rich_output.py index 5082b6104..3e989064f 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -456,7 +456,7 @@ def test_read_note_rich_include_frontmatter(mock_mcp): frontmatter block must be stripped from the Markdown body or it renders again under the panel. """ - result = _tty_runner(["tool", "read-note", "test-note", "--include-frontmatter"]) + result = _tty_runner(["tool", "read-note", "test-note", "--frontmatter"]) assert result.exit_code == 0, f"CLI failed: {result.output}" # Frontmatter panel appears with key/value data @@ -776,7 +776,7 @@ def test_read_note_plain_include_frontmatter(mock_mcp): With the flag, content IS the file (frontmatter block included); plain mode prints it verbatim and must not prepend a synthesized key/value block. """ - result = _tty_runner(["tool", "read-note", "test-note", "--plain", "--include-frontmatter"]) + result = _tty_runner(["tool", "read-note", "test-note", "--plain", "--frontmatter"]) assert result.exit_code == 0, f"CLI failed: {result.output}" # ONLY the literal file: no header line, output starts at the fence @@ -787,6 +787,19 @@ def test_read_note_plain_include_frontmatter(mock_mcp): assert result.output.count("title: Test Note") == 1 +@patch( + "basic_memory.cli.commands.tool.mcp_read_note", + new_callable=AsyncMock, + return_value=READ_NOTE_RESULT_WITH_FRONTMATTER, +) +def test_read_note_include_frontmatter_alias(mock_mcp): + """--include-frontmatter still works as a deprecated alias for --frontmatter.""" + result = _tty_runner(["tool", "read-note", "test-note", "--plain", "--include-frontmatter"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert result.output.startswith("---\ntitle: Test Note") + + @patch( "basic_memory.cli.commands.tool.mcp_read_note", new_callable=AsyncMock,