feat: min-similarity override, edit-note CLI, and strip-frontmatter (#571)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2026-02-16 17:56:00 -06:00
committed by GitHub
parent 55d675e278
commit 9259a7eb59
7 changed files with 803 additions and 5 deletions
+10
View File
@@ -1,5 +1,15 @@
# CHANGELOG
## Unreleased
### Features
- Add `--strip-frontmatter` to `basic-memory tool read-note`
- Default behavior is unchanged: `content` still includes raw markdown with frontmatter.
- With `--strip-frontmatter`, both text and JSON modes return body-only markdown content.
- JSON output now includes an additive `frontmatter` field with parsed YAML metadata (or `null`
when no valid opening frontmatter block exists).
## v0.18.3 (2026-02-12)
### Bug Fixes
+16
View File
@@ -393,6 +393,22 @@ basic-memory project info my-project --cloud
The local MCP server (`basic-memory mcp`) automatically uses local routing, so you can use both local Claude Desktop and cloud-based clients simultaneously.
**CLI Note Editing (`tool edit-note`):**
```bash
# Append content
basic-memory tool edit-note project-plan --operation append --content $'\n## Next Steps\n- Finalize rollout'
# Find/replace with replacement count validation
basic-memory tool edit-note docs/api --operation find_replace --find-text "v0.14.0" --content "v0.15.0" --expected-replacements 2
# Replace a section body
basic-memory tool edit-note docs/setup --operation replace_section --section "## Installation" --content $'Updated install steps\n- Run just install'
# JSON metadata output for integrations
basic-memory tool edit-note docs/setup --operation append --content $'\n- Added note' --format json
```
4. In Claude Desktop, the LLM can now use these tools:
**Content Management:**
+216 -1
View File
@@ -2,9 +2,10 @@
import json
import sys
from typing import Annotated, List, Optional
from typing import Annotated, Any, List, Optional
import typer
import yaml
from loguru import logger
from rich import print as rprint
@@ -28,6 +29,7 @@ from basic_memory.mcp.prompts.recent_activity import (
recent_activity_prompt as recent_activity_prompt,
)
from basic_memory.mcp.tools import build_context as mcp_build_context
from basic_memory.mcp.tools import edit_note as mcp_edit_note
from basic_memory.mcp.tools import read_note as mcp_read_note
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
from basic_memory.mcp.tools import search_notes as mcp_search
@@ -36,6 +38,60 @@ from basic_memory.mcp.tools import write_note as mcp_write_note
tool_app = typer.Typer()
app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
VALID_EDIT_OPERATIONS = ["append", "prepend", "find_replace", "replace_section"]
# --- Frontmatter helpers ---
def _parse_opening_frontmatter(content: str) -> tuple[str, dict[str, Any] | None]:
"""Parse and strip an opening YAML frontmatter block if valid.
Returns a tuple of (body_content_or_original, parsed_frontmatter_or_none).
Behavior:
- Only parses frontmatter if the first line is an opening '---' delimiter.
- Requires a closing '---' delimiter.
- Accepts mapping YAML only; malformed or non-mapping YAML is ignored.
- Supports UTF-8 BOM at document start.
"""
if not content:
return content, None
original_content = content
if content.startswith("\ufeff"):
content = content[1:]
lines = content.splitlines(keepends=True)
if not lines:
return original_content, None
if lines[0].rstrip("\r\n").strip() != "---":
return original_content, None
closing_index = None
for index in range(1, len(lines)):
if lines[index].rstrip("\r\n").strip() == "---":
closing_index = index
break
if closing_index is None:
return original_content, None
frontmatter_text = "".join(lines[1:closing_index])
try:
parsed = yaml.safe_load(frontmatter_text) if frontmatter_text else {}
except yaml.YAMLError:
return original_content, None
if parsed is None:
parsed = {}
if not isinstance(parsed, dict):
return original_content, None
body_content = "".join(lines[closing_index + 1 :])
return body_content, parsed
# --- JSON output helpers ---
# These async functions bypass the MCP tool (which returns formatted strings)
@@ -111,6 +167,61 @@ async def _read_note_json(
}
async def _edit_note_json(
identifier: str,
operation: str,
content: str,
project_name: Optional[str],
section: Optional[str],
find_text: Optional[str],
expected_replacements: int,
) -> dict:
"""Edit a note and return structured JSON metadata."""
async with get_client(project_name=project_name) as client:
active_project = await get_active_project(client, project_name)
knowledge_client = KnowledgeClient(client, active_project.external_id)
entity_id = await knowledge_client.resolve_entity(identifier)
edit_data: dict[str, Any] = {
"operation": operation,
"content": content,
"expected_replacements": expected_replacements,
}
if section:
edit_data["section"] = section
if find_text:
edit_data["find_text"] = find_text
result = await knowledge_client.patch_entity(entity_id, edit_data, fast=False)
return {
"title": result.title,
"permalink": result.permalink,
"file_path": result.file_path,
"operation": operation,
"checksum": result.checksum,
}
def _validate_edit_note_args(
operation: str, find_text: Optional[str], section: Optional[str]
) -> None:
"""Validate operation-specific required arguments for edit-note."""
if operation not in VALID_EDIT_OPERATIONS:
raise ValueError(
f"Invalid operation '{operation}'. Must be one of: {', '.join(VALID_EDIT_OPERATIONS)}"
)
if operation == "find_replace" and not find_text:
raise ValueError("find_text parameter is required for find_replace operation")
if operation == "replace_section" and not section:
raise ValueError("section parameter is required for replace_section operation")
def _is_edit_note_failure_response(result: str) -> bool:
"""Check whether the MCP edit_note text response indicates a failed edit."""
return result.lstrip().startswith("# Edit Failed")
async def _recent_activity_json(
type: Optional[List[SearchItemType]],
depth: Optional[int],
@@ -281,6 +392,14 @@ def read_note(
page: int = 1,
page_size: int = 10,
format: str = typer.Option("text", "--format", help="Output format: text or json"),
strip_frontmatter: bool = typer.Option(
False,
"--strip-frontmatter",
help=(
"Strip opening YAML frontmatter from content. "
"JSON output includes parsed frontmatter under 'frontmatter'."
),
),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
@@ -290,6 +409,7 @@ def read_note(
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
Use --strip-frontmatter to return body-only markdown content.
"""
try:
validate_routing_flags(local, cloud)
@@ -311,9 +431,15 @@ def read_note(
result = run_with_cleanup(
_read_note_json(identifier, project_name, page, page_size)
)
stripped_content, parsed_frontmatter = _parse_opening_frontmatter(result["content"])
result["frontmatter"] = parsed_frontmatter
if strip_frontmatter:
result["content"] = stripped_content
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
else:
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
if strip_frontmatter:
note, _ = _parse_opening_frontmatter(note)
rprint(note)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
@@ -325,6 +451,95 @@ def read_note(
raise
@tool_app.command()
def edit_note(
identifier: str,
operation: Annotated[str, typer.Option("--operation", help="Edit operation to apply")],
content: Annotated[str, typer.Option("--content", help="Content for the edit operation")],
project: Annotated[
Optional[str],
typer.Option(
help="The project to edit. If not provided, the default project will be used."
),
] = None,
find_text: Annotated[
Optional[str], typer.Option("--find-text", help="Text to find for find_replace operation")
] = None,
section: Annotated[
Optional[str],
typer.Option("--section", help="Section heading for replace_section operation"),
] = None,
expected_replacements: int = typer.Option(
1,
"--expected-replacements",
help="Expected replacement count for find_replace operation",
),
format: str = typer.Option("text", "--format", help="Output format: text or json"),
local: bool = typer.Option(
False, "--local", help="Force local API routing (ignore cloud mode)"
),
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
):
"""Edit an existing markdown note using append/prepend/find_replace/replace_section.
Use --local to force local routing when cloud mode is enabled.
Use --cloud to force cloud routing when cloud mode is disabled.
"""
try:
validate_routing_flags(local, cloud)
_validate_edit_note_args(operation, find_text, section)
# look for the project in the config
config_manager = ConfigManager()
project_name = None
if project is not None:
project_name, _ = config_manager.get_project(project)
if not project_name:
typer.echo(f"No project found named: {project}", err=True)
raise typer.Exit(1)
# use the project name, or the default from the config
project_name = project_name or config_manager.default_project
with force_routing(local=local, cloud=cloud):
if format == "json":
result = run_with_cleanup(
_edit_note_json(
identifier=identifier,
operation=operation,
content=content,
project_name=project_name,
section=section,
find_text=find_text,
expected_replacements=expected_replacements,
)
)
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
else:
result = run_with_cleanup(
mcp_edit_note.fn(
identifier=identifier,
operation=operation,
content=content,
project=project_name,
section=section,
find_text=find_text,
expected_replacements=expected_replacements,
)
)
rprint(result)
if _is_edit_note_failure_response(result):
raise typer.Exit(1)
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except Exception as e: # pragma: no cover
if not isinstance(e, typer.Exit):
typer.echo(f"Error during edit_note: {e}", err=True)
raise typer.Exit(1)
raise
@tool_app.command()
def build_context(
url: MemoryUrl,
@@ -0,0 +1,323 @@
"""Integration tests for `basic-memory tool edit-note`."""
import json
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
runner = CliRunner()
def _write_note(title: str, folder: str, content: str, project: str | None = None) -> dict:
args = [
"tool",
"write-note",
"--title",
title,
"--folder",
folder,
"--content",
content,
"--format",
"json",
]
if project:
args.extend(["--project", project])
result = runner.invoke(cli_app, args)
assert result.exit_code == 0, result.output
return json.loads(result.stdout)
def _read_note(identifier: str, project: str | None = None) -> dict:
args = ["tool", "read-note", identifier, "--format", "json"]
if project:
args.extend(["--project", project])
result = runner.invoke(cli_app, args)
assert result.exit_code == 0, result.output
return json.loads(result.stdout)
def test_edit_note_append_success(app, app_config, test_project, config_manager):
"""append operation adds content to the end of the note."""
note = _write_note(
"Edit Append Note",
"edit-tests",
"# Append\n\nBASE_APPEND_MARKER",
)
result = runner.invoke(
cli_app,
[
"tool",
"edit-note",
note["permalink"],
"--operation",
"append",
"--content",
"\nAPPENDED_MARKER",
],
)
assert result.exit_code == 0, result.output
updated = _read_note(note["permalink"])
assert updated["content"].index("APPENDED_MARKER") > updated["content"].index(
"BASE_APPEND_MARKER"
)
def test_edit_note_prepend_success(app, app_config, test_project, config_manager):
"""prepend operation inserts content before existing body content."""
note = _write_note(
"Edit Prepend Note",
"edit-tests",
"# Prepend\n\nBASE_PREPEND_MARKER",
)
result = runner.invoke(
cli_app,
[
"tool",
"edit-note",
note["permalink"],
"--operation",
"prepend",
"--content",
"PREPENDED_MARKER\n",
],
)
assert result.exit_code == 0, result.output
updated = _read_note(note["permalink"])
assert updated["content"].index("PREPENDED_MARKER") < updated["content"].index(
"BASE_PREPEND_MARKER"
)
def test_edit_note_find_replace_success_with_expected_count(
app, app_config, test_project, config_manager
):
"""find_replace succeeds when expected replacement count matches actual count."""
note = _write_note(
"Edit Replace Note",
"edit-tests",
"# Replace\n\nFIND_ME_MARKER and FIND_ME_MARKER",
)
result = runner.invoke(
cli_app,
[
"tool",
"edit-note",
note["permalink"],
"--operation",
"find_replace",
"--content",
"REPLACED_MARKER",
"--find-text",
"FIND_ME_MARKER",
"--expected-replacements",
"2",
],
)
assert result.exit_code == 0, result.output
updated = _read_note(note["permalink"])
assert "FIND_ME_MARKER" not in updated["content"]
assert updated["content"].count("REPLACED_MARKER") == 2
def test_edit_note_find_replace_fails_without_find_text(
app, app_config, test_project, config_manager
):
"""find_replace requires --find-text."""
note = _write_note(
"Edit Missing Find Note",
"edit-tests",
"# Missing Find\n\nOriginal",
)
result = runner.invoke(
cli_app,
[
"tool",
"edit-note",
note["permalink"],
"--operation",
"find_replace",
"--content",
"Replacement",
],
)
assert result.exit_code != 0
assert "find_text parameter is required for find_replace operation" in result.output
def test_edit_note_replace_section_success(app, app_config, test_project, config_manager):
"""replace_section updates exactly the targeted section body."""
note = _write_note(
"Edit Section Note",
"edit-tests",
"# Header\n\n## Keep\nKeep body\n\n## Target Section\nOld section body\n\n## After\nAfter body",
)
result = runner.invoke(
cli_app,
[
"tool",
"edit-note",
note["permalink"],
"--operation",
"replace_section",
"--content",
"New section body",
"--section",
"## Target Section",
],
)
assert result.exit_code == 0, result.output
updated = _read_note(note["permalink"])
assert "New section body" in updated["content"]
assert "Old section body" not in updated["content"]
assert "## After" in updated["content"]
def test_edit_note_replace_section_fails_without_section(
app, app_config, test_project, config_manager
):
"""replace_section requires --section."""
note = _write_note(
"Edit Missing Section Note",
"edit-tests",
"# Missing Section\n\nBody",
)
result = runner.invoke(
cli_app,
[
"tool",
"edit-note",
note["permalink"],
"--operation",
"replace_section",
"--content",
"Replacement body",
],
)
assert result.exit_code != 0
assert "section parameter is required for replace_section operation" in result.output
def test_edit_note_json_format_contract(app, app_config, test_project, config_manager):
"""JSON format returns only metadata keys required by contract."""
note = _write_note(
"Edit JSON Note",
"edit-tests",
"# JSON\n\nBody",
)
result = runner.invoke(
cli_app,
[
"tool",
"edit-note",
note["permalink"],
"--operation",
"append",
"--content",
"\nJSON_MARKER",
"--format",
"json",
],
)
assert result.exit_code == 0, result.output
data = json.loads(result.stdout)
assert set(data.keys()) == {"title", "permalink", "file_path", "operation", "checksum"}
assert data["operation"] == "append"
assert data["title"] == "Edit JSON Note"
def test_edit_note_text_backend_failure_returns_nonzero(
app, app_config, test_project, config_manager
):
"""Text mode should return non-zero when backend edit operation fails."""
note = _write_note(
"Edit Backend Failure Note",
"edit-tests",
"# Failure\n\nGamma",
)
result = runner.invoke(
cli_app,
[
"tool",
"edit-note",
note["permalink"],
"--operation",
"find_replace",
"--find-text",
"Gamma",
"--content",
"Delta",
"--expected-replacements",
"2",
],
)
assert result.exit_code != 0
assert "# Edit Failed - Wrong Replacement Count" in result.output
assert "Expected 2 occurrences of 'Gamma' but found 1" in result.output
def test_edit_note_project_and_routing_flag_parity(app, app_config, test_project, config_manager):
"""edit-note supports --project/--local and validates --local/--cloud conflict."""
note = _write_note(
"Edit Project Flag Note",
"edit-tests",
"# Project Flag\n\nPROJECT_FLAG_MARKER",
project=test_project.name,
)
success = runner.invoke(
cli_app,
[
"tool",
"edit-note",
note["permalink"],
"--operation",
"append",
"--content",
"\nPROJECT_UPDATE_MARKER",
"--project",
test_project.name,
"--local",
],
)
assert success.exit_code == 0, success.output
assert "No such option" not in success.output
updated = _read_note(note["permalink"], project=test_project.name)
assert "PROJECT_UPDATE_MARKER" in updated["content"]
conflict = runner.invoke(
cli_app,
[
"tool",
"edit-note",
note["permalink"],
"--operation",
"append",
"--content",
"ignored",
"--local",
"--cloud",
],
)
assert conflict.exit_code != 0
assert "Cannot specify both --local and --cloud" in conflict.output
@@ -78,6 +78,92 @@ def test_read_note_json_format(app, app_config, test_project, config_manager):
assert data["permalink"] == permalink
assert "content" in data
assert "file_path" in data
assert "frontmatter" in data
assert isinstance(data["frontmatter"], dict)
def test_read_note_json_strip_frontmatter_permalink(app, app_config, test_project, config_manager):
"""read-note strips frontmatter in JSON mode for permalink lookup."""
write_result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Read Strip Permalink Note",
"--folder",
"test-notes",
"--content",
"# Read Strip Permalink Note\n\nPermalink lookup content.",
"--format",
"json",
],
)
assert write_result.exit_code == 0
write_data = json.loads(write_result.stdout)
result = runner.invoke(
cli_app,
[
"tool",
"read-note",
write_data["permalink"],
"--format",
"json",
"--strip-frontmatter",
],
)
assert result.exit_code == 0
data = json.loads(result.stdout)
assert data["title"] == "Read Strip Permalink Note"
assert data["permalink"] == write_data["permalink"]
assert not data["content"].startswith("---")
assert "# Read Strip Permalink Note" in data["content"]
assert isinstance(data["frontmatter"], dict)
assert data["frontmatter"].get("title") == "Read Strip Permalink Note"
def test_read_note_json_strip_frontmatter_title(app, app_config, test_project, config_manager):
"""read-note strips frontmatter in JSON mode for title-based lookup."""
write_result = runner.invoke(
cli_app,
[
"tool",
"write-note",
"--title",
"Read Strip Title Note",
"--folder",
"test-notes",
"--content",
"# Read Strip Title Note\n\nTitle lookup content.",
"--format",
"json",
],
)
assert write_result.exit_code == 0
write_data = json.loads(write_result.stdout)
result = runner.invoke(
cli_app,
[
"tool",
"read-note",
"Read Strip Title Note",
"--format",
"json",
"--strip-frontmatter",
],
)
assert result.exit_code == 0
data = json.loads(result.stdout)
assert data["title"] == "Read Strip Title Note"
assert data["permalink"] == write_data["permalink"]
assert not data["content"].startswith("---")
assert "# Read Strip Title Note" in data["content"]
assert isinstance(data["frontmatter"], dict)
assert data["frontmatter"].get("title") == "Read Strip Title Note"
def test_recent_activity_json_format(app, app_config, test_project, config_manager, monkeypatch):
+21
View File
@@ -60,6 +60,25 @@ class TestRoutingFlagsValidation:
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
def test_tool_edit_note_both_flags_error(self):
"""Using both --local and --cloud should produce an error."""
result = runner.invoke(
cli_app,
[
"tool",
"edit-note",
"test",
"--operation",
"append",
"--content",
"test",
"--local",
"--cloud",
],
)
assert result.exit_code != 0
assert "Cannot specify both --local and --cloud" in result.output
class TestMcpCommandForcesLocal:
"""Tests that the MCP command forces local routing."""
@@ -99,6 +118,7 @@ class TestToolCommandsAcceptFlags:
("search-notes", ["test query"]),
("recent-activity", []),
("read-note", ["test"]),
("edit-note", ["test", "--operation", "append", "--content", "test"]),
("build-context", ["memory://test"]),
("continue-conversation", []),
],
@@ -116,6 +136,7 @@ class TestToolCommandsAcceptFlags:
("search-notes", ["test query"]),
("recent-activity", []),
("read-note", ["test"]),
("edit-note", ["test", "--operation", "append", "--content", "test"]),
("build-context", ["memory://test"]),
("continue-conversation", []),
],
+131 -4
View File
@@ -26,7 +26,7 @@ WRITE_NOTE_RESULT = {
READ_NOTE_RESULT = {
"title": "Test Note",
"permalink": "notes/test-note",
"content": "# Test Note\n\nhello world",
"content": "---\ntitle: Test Note\ntags:\n- test\n---\n# Test Note\n\nhello world",
"file_path": "notes/Test Note.md",
}
@@ -145,7 +145,10 @@ def test_read_note_json_output(mock_read_json, mock_config_cls):
data = json.loads(result.output)
assert data["title"] == "Test Note"
assert data["permalink"] == "notes/test-note"
assert data["content"] == "# Test Note\n\nhello world"
assert (
data["content"] == "---\ntitle: Test Note\ntags:\n- test\n---\n# Test Note\n\nhello world"
)
assert data["frontmatter"] == {"title": "Test Note", "tags": ["test"]}
assert data["file_path"] == "notes/Test Note.md"
mock_read_json.assert_called_once()
@@ -158,7 +161,9 @@ def test_read_note_text_output(mock_mcp_read, mock_config_cls):
"""read-note with default text format uses the MCP tool path."""
mock_config_cls.return_value = _mock_config_manager()
mock_mcp_read.fn = AsyncMock(return_value="# Test Note\n\nhello world")
mock_mcp_read.fn = AsyncMock(
return_value="---\ntitle: Test Note\n---\n# Test Note\n\nhello world"
)
result = runner.invoke(
cli_app,
@@ -166,10 +171,132 @@ def test_read_note_text_output(mock_mcp_read, mock_config_cls):
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert "Test Note" in result.output
assert "---" in result.output
mock_mcp_read.fn.assert_called_once()
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool._read_note_json",
new_callable=AsyncMock,
return_value=READ_NOTE_RESULT,
)
def test_read_note_json_strip_frontmatter(mock_read_json, mock_config_cls):
"""read-note --format json --strip-frontmatter strips content but keeps frontmatter object."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note", "--format", "json", "--strip-frontmatter"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["title"] == "Test Note"
assert data["permalink"] == "notes/test-note"
assert data["content"] == "# Test Note\n\nhello world"
assert data["frontmatter"] == {"title": "Test Note", "tags": ["test"]}
assert data["file_path"] == "notes/Test Note.md"
mock_read_json.assert_called_once()
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
)
def test_read_note_text_strip_frontmatter(mock_mcp_read, mock_config_cls):
"""read-note --strip-frontmatter strips opening frontmatter in text mode."""
mock_config_cls.return_value = _mock_config_manager()
mock_mcp_read.fn = AsyncMock(
return_value="---\ntitle: Test Note\n---\n# Test Note\n\nhello world"
)
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note", "--strip-frontmatter"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert "---" not in result.output
assert "# Test Note" in result.output
mock_mcp_read.fn.assert_called_once()
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool.mcp_read_note",
)
def test_read_note_text_strip_frontmatter_no_frontmatter(mock_mcp_read, mock_config_cls):
"""read-note --strip-frontmatter keeps notes unchanged when no frontmatter exists."""
mock_config_cls.return_value = _mock_config_manager()
mock_mcp_read.fn = AsyncMock(return_value="# Test Note\n\nhello world")
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note", "--strip-frontmatter"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert result.output.strip() == "# Test Note\n\nhello world"
mock_mcp_read.fn.assert_called_once()
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool._read_note_json",
new_callable=AsyncMock,
return_value={
"title": "Test Note",
"permalink": "notes/test-note",
"content": "---\ntitle: [bad yaml\n# Test Note\n\nhello world",
"file_path": "notes/Test Note.md",
},
)
def test_read_note_json_malformed_frontmatter_kept(mock_read_json, mock_config_cls):
"""Malformed opening frontmatter should remain unchanged with frontmatter set to null."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note", "--format", "json", "--strip-frontmatter"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["content"] == "---\ntitle: [bad yaml\n# Test Note\n\nhello world"
assert data["frontmatter"] is None
mock_read_json.assert_called_once()
@patch("basic_memory.cli.commands.tool.ConfigManager")
@patch(
"basic_memory.cli.commands.tool._read_note_json",
new_callable=AsyncMock,
return_value={
"title": "No Frontmatter Note",
"permalink": "notes/no-frontmatter-note",
"content": "# No Frontmatter Note\n\nhello world",
"file_path": "notes/No Frontmatter Note.md",
},
)
def test_read_note_json_strip_frontmatter_no_frontmatter(mock_read_json, mock_config_cls):
"""JSON strip mode should keep content unchanged when no frontmatter exists."""
mock_config_cls.return_value = _mock_config_manager()
result = runner.invoke(
cli_app,
["tool", "read-note", "test-note", "--format", "json", "--strip-frontmatter"],
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert data["content"] == "# No Frontmatter Note\n\nhello world"
assert data["frontmatter"] is None
mock_read_json.assert_called_once()
# --- recent-activity --format json ---