diff --git a/src/basic_memory/cli/commands/tool.py b/src/basic_memory/cli/commands/tool.py index db901ef6e..368efdc5d 100644 --- a/src/basic_memory/cli/commands/tool.py +++ b/src/basic_memory/cli/commands/tool.py @@ -77,6 +77,16 @@ def write_note( tags: Annotated[ Optional[List[str]], typer.Option(help="A list of tags to apply to the note") ] = None, + note_type: Annotated[ + str, + typer.Option( + "--type", + help=( + "Note type stored in frontmatter (e.g. 'guide', 'report'). " + "A 'type:' in the note's own content frontmatter takes precedence." + ), + ), + ] = "note", project: Annotated[ Optional[str], typer.Option( @@ -100,6 +110,7 @@ def write_note( Examples: bm tool write-note --title "My Note" --folder "notes" --content "Note content" + bm tool write-note --title "My Guide" --folder "notes" --content "..." --type guide echo "content" | bm tool write-note --title "My Note" --folder "notes" bm tool write-note --title "My Note" --folder "notes" --local """ @@ -132,6 +143,7 @@ def write_note( project=project, project_id=project_id, tags=tags, + note_type=note_type, output_format="json", ) ) diff --git a/test-int/cli/test_cli_tool_write_note_type_integration.py b/test-int/cli/test_cli_tool_write_note_type_integration.py new file mode 100644 index 000000000..f3716b754 --- /dev/null +++ b/test-int/cli/test_cli_tool_write_note_type_integration.py @@ -0,0 +1,94 @@ +"""Integration coverage for `bm tool write-note --type` (Issue #875).""" + +import json + +from typer.testing import CliRunner + +from basic_memory.cli.main import app as cli_app + +runner = CliRunner() + + +def test_write_note_type_flag_round_trip(app, app_config, test_project, config_manager): + """`--type` sets the persisted note type and is searchable via `--type`.""" + write_result = runner.invoke( + cli_app, + [ + "tool", + "write-note", + "--title", + "CLI Typed Note", + "--folder", + "typed", + "--content", + "# CLI Typed Note\n\nCliTypeToken body.", + "--type", + "guide", + ], + ) + assert write_result.exit_code == 0, write_result.output + write_data = json.loads(write_result.stdout) + permalink = write_data["permalink"] + + # Read back the frontmatter to confirm the persisted type. + read_result = runner.invoke( + cli_app, + ["tool", "read-note", permalink, "--include-frontmatter"], + ) + assert read_result.exit_code == 0, read_result.output + read_data = json.loads(read_result.stdout) + assert read_data["frontmatter"]["type"] == "guide" + + # The search note-type filter must return the typed note. + search_result = runner.invoke( + cli_app, + [ + "tool", + "search-notes", + "CliTypeToken", + "--type", + "guide", + "--local", + "--page-size", + "20", + ], + ) + assert search_result.exit_code == 0, search_result.output + search_data = json.loads(search_result.stdout) + permalinks = {item["permalink"] for item in search_data["results"]} + assert permalink in permalinks + + +def test_write_note_content_frontmatter_type_wins_over_flag( + app, app_config, test_project, config_manager +): + """A `type:` in content frontmatter takes precedence over `--type` (documented behavior).""" + content = "---\ntype: session\n---\n# Frontmatter Wins\n\nFrontmatterWinsToken body." + + write_result = runner.invoke( + cli_app, + [ + "tool", + "write-note", + "--title", + "Frontmatter Wins", + "--folder", + "typed", + "--content", + content, + "--type", + "guide", + ], + ) + assert write_result.exit_code == 0, write_result.output + write_data = json.loads(write_result.stdout) + permalink = write_data["permalink"] + + read_result = runner.invoke( + cli_app, + ["tool", "read-note", permalink, "--include-frontmatter"], + ) + assert read_result.exit_code == 0, read_result.output + read_data = json.loads(read_result.stdout) + # Content frontmatter "session" wins over the --type "guide" flag. + assert read_data["frontmatter"]["type"] == "session" diff --git a/test-int/mcp/test_write_note_type_integration.py b/test-int/mcp/test_write_note_type_integration.py new file mode 100644 index 000000000..92fa26c60 --- /dev/null +++ b/test-int/mcp/test_write_note_type_integration.py @@ -0,0 +1,154 @@ +"""Integration tests locking in write_note note_type behavior (Issue #875). + +These exercise the real MCP harness (no mocks) to confirm that: +- content frontmatter ``type:`` is persisted as the note type and is searchable + via the ``note_types`` filter; +- overwriting a note with a different content ``type:`` flips the persisted type. + +The CLI ``--type`` passthrough is covered separately in +``test-int/cli/test_cli_tool_write_note_type_integration.py``. +""" + +import json +from textwrap import dedent +from typing import Any + +import pytest +from fastmcp import Client + + +def _json_content(tool_result) -> dict[str, Any]: + """Parse a FastMCP tool result content block into a JSON object.""" + assert len(tool_result.content) == 1 + assert tool_result.content[0].type == "text" + payload = json.loads(tool_result.content[0].text) # pyright: ignore [reportAttributeAccessIssue] + assert isinstance(payload, dict) + return payload + + +@pytest.mark.asyncio +async def test_write_note_content_type_is_persisted_and_searchable(mcp_server, app, test_project): + """Content frontmatter ``type:`` persists and is found by the note_types filter.""" + note = dedent(""" + --- + title: Session Log + type: session + --- + + # Session Log + + SessionTypeToken content body. + """).strip() + + async with Client(mcp_server) as client: + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Session Log", + "directory": "logs", + "content": note, + "output_format": "json", + }, + ) + + # The persisted frontmatter should report the content-declared type. + read_result = await client.call_tool( + "read_note", + { + "project": test_project.name, + "identifier": "logs/session-log", + "include_frontmatter": True, + "output_format": "json", + }, + ) + read_payload = _json_content(read_result) + assert read_payload["frontmatter"]["type"] == "session" + + # And the note_types filter must return it (and only it for this token). + search_result = await client.call_tool( + "search_notes", + { + "project": test_project.name, + "query": "SessionTypeToken", + "search_type": "text", + "note_types": ["session"], + "output_format": "json", + }, + ) + search_payload = _json_content(search_result) + permalinks = {item["permalink"] for item in search_payload["results"]} + assert any(p.endswith("logs/session-log") for p in permalinks) + + +@pytest.mark.asyncio +async def test_write_note_overwrite_flips_persisted_type(mcp_server, app, test_project): + """Overwriting with a different content ``type:`` flips the persisted note type.""" + session_note = dedent(""" + --- + title: Type Flip + type: session + --- + + # Type Flip + + Original session body. + """).strip() + + schema_note = dedent(""" + --- + title: Type Flip + type: schema + --- + + # Type Flip + + Replacement schema body. + """).strip() + + async with Client(mcp_server) as client: + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Type Flip", + "directory": "flip", + "content": session_note, + "output_format": "json", + }, + ) + + before = await client.call_tool( + "read_note", + { + "project": test_project.name, + "identifier": "flip/type-flip", + "include_frontmatter": True, + "output_format": "json", + }, + ) + assert _json_content(before)["frontmatter"]["type"] == "session" + + # Overwrite with a different content type. + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Type Flip", + "directory": "flip", + "content": schema_note, + "overwrite": True, + "output_format": "json", + }, + ) + + after = await client.call_tool( + "read_note", + { + "project": test_project.name, + "identifier": "flip/type-flip", + "include_frontmatter": True, + "output_format": "json", + }, + ) + assert _json_content(after)["frontmatter"]["type"] == "schema" diff --git a/tests/cli/test_cli_tool_json_output.py b/tests/cli/test_cli_tool_json_output.py index f32c7dfcd..786c5bdab 100644 --- a/tests/cli/test_cli_tool_json_output.py +++ b/tests/cli/test_cli_tool_json_output.py @@ -189,6 +189,58 @@ def test_write_note_with_tags(mock_mcp_write): assert mock_mcp_write.call_args.kwargs["tags"] == ["python", "async"] +@patch( + "basic_memory.cli.commands.tool.mcp_write_note", + new_callable=AsyncMock, + return_value=WRITE_NOTE_RESULT, +) +def test_write_note_type_passthrough(mock_mcp_write): + """--type forwards to the MCP tool's note_type parameter.""" + result = runner.invoke( + cli_app, + [ + "tool", + "write-note", + "--title", + "Test Note", + "--folder", + "notes", + "--content", + "hello", + "--type", + "guide", + ], + ) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert mock_mcp_write.call_args.kwargs["note_type"] == "guide" + + +@patch( + "basic_memory.cli.commands.tool.mcp_write_note", + new_callable=AsyncMock, + return_value=WRITE_NOTE_RESULT, +) +def test_write_note_type_defaults_to_note(mock_mcp_write): + """write-note defaults note_type to 'note' when --type is omitted.""" + result = runner.invoke( + cli_app, + [ + "tool", + "write-note", + "--title", + "Test Note", + "--folder", + "notes", + "--content", + "hello", + ], + ) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert mock_mcp_write.call_args.kwargs["note_type"] == "note" + + # --- read-note ---