From 30c6721fbd17911be57f81f10fec9f3fc85839ca Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Fri, 12 Jun 2026 11:05:35 -0500 Subject: [PATCH] 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 8fe60a29..256f2a92 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -36,6 +36,7 @@ from rich.tree import Tree from basic_memory.cli.app import app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.config import ConfigManager +from basic_memory.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 0ec65eff..8443c876 100644 --- a/tests/cli/test_cli_tool_rich_output.py +++ b/tests/cli/test_cli_tool_rich_output.py @@ -28,6 +28,16 @@ READ_NOTE_RESULT = { "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(