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