mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user