Skip to content

Commit 30c6721

Browse files
groksrcclaude
andcommitted
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 <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent a5114ae commit 30c6721

2 files changed

Lines changed: 49 additions & 26 deletions

File tree

src/basic_memory/cli/commands/tool.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from basic_memory.cli.app import app
3737
from basic_memory.cli.commands.command_utils import run_with_cleanup
3838
from basic_memory.config import ConfigManager
39+
from basic_memory.file_utils import has_frontmatter, remove_frontmatter
3940
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
4041
from basic_memory.mcp.tools import build_context as mcp_build_context
4142
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
214215
fm_table.add_row(markup_escape(str(key)), markup_escape(str(value)))
215216
console.print(Panel(fm_table, title="[dim]frontmatter[/dim]", expand=False))
216217

217-
if content:
218-
console.print(Markdown(content))
218+
# Trigger: --include-frontmatter makes the API return the literal file, so
219+
# content starts with the frontmatter block the panel above already shows.
220+
# Why: rendering it again through Markdown duplicates the frontmatter (and
221+
# Markdown mangles the --- fences into rules/headings).
222+
# Outcome: strip the block from the body; the panel is the frontmatter view.
223+
body = content
224+
if include_frontmatter and content and has_frontmatter(content):
225+
body = remove_frontmatter(content)
226+
if body and body.strip():
227+
console.print(Markdown(body))
219228
else:
220229
console.print(Text("(no content)", style="dim"))
221230

@@ -385,24 +394,17 @@ def _plain_read_note(result: dict[str, Any], *, include_frontmatter: bool = Fals
385394
title = result.get("title", "")
386395
permalink = result.get("permalink", "")
387396
content = result.get("content", "")
388-
frontmatter: dict[str, Any] = result.get("frontmatter") or {}
389397

390398
header = f"{title} [{permalink}]" if permalink else title
391399
print(header)
392400

393-
# Trigger: --include-frontmatter was passed and the payload carries frontmatter.
394-
# Why: the JSON payload always includes a "frontmatter" key, so the flag (not
395-
# mere presence) gates whether the key/value block is printed -- matching
396-
# the Rich path's gating behavior.
397-
# Outcome: a blank line then "key: value" lines above the body.
398-
if include_frontmatter and frontmatter:
399-
print()
400-
for key, value in frontmatter.items():
401-
print(f"{key}: {value}")
402-
403401
print()
404-
# The API's content field keeps the blank line left by frontmatter
405-
# stripping; trim newlines so the header gap stays a single blank line.
402+
# Trigger: --include-frontmatter makes the API return the literal file
403+
# (frontmatter block included) as content.
404+
# Why: plain mode should show that file verbatim -- synthesizing a separate
405+
# key/value block would print the frontmatter twice.
406+
# Outcome: with the flag, the body IS the frontmatter view; either way trim
407+
# surrounding newlines so the header gap stays a single blank line.
406408
body = content.strip("\n") if content else ""
407409
if body:
408410
print(body)

tests/cli/test_cli_tool_rich_output.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@
2828
"frontmatter": {"title": "Test Note", "tags": ["test"]},
2929
}
3030

31+
# With --include-frontmatter the API returns the LITERAL FILE as content
32+
# (frontmatter block included) alongside the parsed frontmatter dict.
33+
READ_NOTE_RESULT_WITH_FRONTMATTER = {
34+
"title": "Test Note",
35+
"permalink": "notes/test-note",
36+
"file_path": "notes/Test Note.md",
37+
"content": "---\ntitle: Test Note\ntags:\n- test\n---\n\n# Test Note\n\nhello world",
38+
"frontmatter": {"title": "Test Note", "tags": ["test"]},
39+
}
40+
3141
SEARCH_RESULT = {
3242
# Real SearchResponse.model_dump() uses "current_page", not "page".
3343
# 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):
436446
@patch(
437447
"basic_memory.cli.commands.tool.mcp_read_note",
438448
new_callable=AsyncMock,
439-
return_value=READ_NOTE_RESULT,
449+
return_value=READ_NOTE_RESULT_WITH_FRONTMATTER,
440450
)
441451
def test_read_note_rich_include_frontmatter(mock_mcp):
442-
"""read-note --include-frontmatter renders frontmatter keys in Rich path.
452+
"""read-note --include-frontmatter renders the panel once, not twice.
443453
444-
Regression: previously the Rich renderer silently dropped frontmatter even
445-
when --include-frontmatter was passed, requiring --json to see the data.
454+
Regression 1: the Rich renderer silently dropped frontmatter even with the
455+
flag. Regression 2: with the flag, content is the LITERAL FILE, so the
456+
frontmatter block must be stripped from the Markdown body or it renders
457+
again under the panel.
446458
"""
447459
result = _tty_runner(["tool", "read-note", "test-note", "--include-frontmatter"])
448460

449461
assert result.exit_code == 0, f"CLI failed: {result.output}"
450-
# Frontmatter section header should appear
462+
# Frontmatter panel appears with key/value data
451463
assert "frontmatter" in result.output
452-
# The frontmatter key and value from READ_NOTE_RESULT should be visible
453464
assert "tags" in result.output
454465
assert "test" in result.output
455-
# The note content should still appear
466+
# The note content still appears
456467
assert "hello world" in result.output
468+
# The frontmatter block is NOT rendered a second time through Markdown:
469+
# the raw fence is stripped, and the title key appears only in the panel.
470+
assert "---" not in result.output
471+
assert result.output.count("title") == 1
457472

458473

459474
@patch(
@@ -758,16 +773,22 @@ def test_read_note_plain_output(mock_mcp):
758773
@patch(
759774
"basic_memory.cli.commands.tool.mcp_read_note",
760775
new_callable=AsyncMock,
761-
return_value=READ_NOTE_RESULT,
776+
return_value=READ_NOTE_RESULT_WITH_FRONTMATTER,
762777
)
763778
def test_read_note_plain_include_frontmatter(mock_mcp):
764-
"""read-note --plain --include-frontmatter renders key: value lines."""
779+
"""read-note --plain --include-frontmatter shows the literal file, once.
780+
781+
With the flag, content IS the file (frontmatter block included); plain mode
782+
prints it verbatim and must not prepend a synthesized key/value block.
783+
"""
765784
result = _tty_runner(["tool", "read-note", "test-note", "--plain", "--include-frontmatter"])
766785

767786
assert result.exit_code == 0, f"CLI failed: {result.output}"
768-
assert "title: Test Note" in result.output
769-
assert "tags:" in result.output
787+
# The literal file: fences and YAML lines exactly as stored
788+
assert "---\ntitle: Test Note\ntags:\n- test\n---" in result.output
770789
assert "hello world" in result.output
790+
# No duplicated frontmatter from a synthesized block
791+
assert result.output.count("title: Test Note") == 1
771792

772793

773794
@patch(

0 commit comments

Comments
 (0)