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>
This commit is contained in:
Drew Cain
2026-06-12 11:05:35 -05:00
parent a5114aec83
commit 30c6721fbd
2 changed files with 49 additions and 26 deletions
+17 -15
View File
@@ -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)
+32 -11
View File
@@ -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(