mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(cli): add delete-note tool command
Surfaces the delete-note command on the CLI tool app, delegating to the mcp_delete_note tool. Supports deleting notes and directories via --is-directory. Signed-off-by: Adit Karode <adit.karode@gmail.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
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
|
||||
from basic_memory.mcp.tools import edit_note as mcp_edit_note
|
||||
from basic_memory.mcp.tools import list_memory_projects as mcp_list_projects
|
||||
from basic_memory.mcp.tools import list_workspaces as mcp_list_workspaces
|
||||
@@ -178,6 +179,64 @@ def read_note(
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command("delete-note")
|
||||
def delete_note(
|
||||
identifier: str,
|
||||
is_directory: bool = typer.Option(
|
||||
False, "--is-directory", help="Delete a directory instead of a single note"
|
||||
),
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
project_id: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--project-id",
|
||||
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
|
||||
),
|
||||
] = None,
|
||||
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"),
|
||||
) -> None:
|
||||
"""Delete a note or directory from the knowledge base.
|
||||
|
||||
Examples:
|
||||
|
||||
bm tool delete-note notes/old-draft
|
||||
bm tool delete-note docs/archive --is-directory
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_delete_note(
|
||||
identifier=identifier,
|
||||
is_directory=is_directory,
|
||||
project=project,
|
||||
project_id=project_id,
|
||||
output_format="json",
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
typer.echo(f"Error: {result['error']}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
_print_json(result)
|
||||
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 delete_note: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
raise
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def edit_note(
|
||||
identifier: str,
|
||||
|
||||
@@ -39,6 +39,21 @@ EDIT_NOTE_RESULT = {
|
||||
"operation": "append",
|
||||
}
|
||||
|
||||
DELETE_NOTE_RESULT = {
|
||||
"deleted": True,
|
||||
"is_directory": False,
|
||||
"title": "Test Note",
|
||||
"permalink": "notes/test-note",
|
||||
"file_path": "notes/Test Note.md",
|
||||
}
|
||||
|
||||
DELETE_DIRECTORY_RESULT = {
|
||||
"deleted": True,
|
||||
"is_directory": True,
|
||||
"directory": "notes/archive",
|
||||
"deleted_count": 3,
|
||||
}
|
||||
|
||||
BUILD_CONTEXT_RESULT = {
|
||||
"results": [],
|
||||
"metadata": {"uri": "test/topic", "depth": 1},
|
||||
@@ -215,6 +230,111 @@ def test_read_note_include_frontmatter(mock_mcp_read):
|
||||
assert mock_mcp_read.call_args.kwargs["include_frontmatter"] is True
|
||||
|
||||
|
||||
# --- delete-note ---
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=DELETE_NOTE_RESULT,
|
||||
)
|
||||
def test_delete_note_json_output(mock_mcp_delete: AsyncMock) -> None:
|
||||
"""delete-note outputs valid JSON from MCP tool."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "test-note"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert data["deleted"] is True
|
||||
assert data["permalink"] == "notes/test-note"
|
||||
mock_mcp_delete.assert_called_once()
|
||||
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
|
||||
assert mock_mcp_delete.call_args.kwargs["is_directory"] is False
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=DELETE_DIRECTORY_RESULT,
|
||||
)
|
||||
def test_delete_note_directory_flag(mock_mcp_delete: AsyncMock) -> None:
|
||||
"""delete-note --is-directory passes directory mode to MCP."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "notes/archive", "--is-directory"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert data["is_directory"] is True
|
||||
assert mock_mcp_delete.call_args.kwargs["is_directory"] is True
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"deleted": False,
|
||||
"is_directory": False,
|
||||
"identifier": "missing-note",
|
||||
"error": None,
|
||||
},
|
||||
)
|
||||
def test_delete_note_not_found_outputs_json(mock_mcp_delete: AsyncMock) -> None:
|
||||
"""delete-note treats not-found JSON without an error as a successful command."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "missing-note"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert data["deleted"] is False
|
||||
assert data["identifier"] == "missing-note"
|
||||
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"deleted": False,
|
||||
"is_directory": False,
|
||||
"identifier": "test-note",
|
||||
"error": "Delete failed",
|
||||
},
|
||||
)
|
||||
def test_delete_note_error_response(mock_mcp_delete: AsyncMock) -> None:
|
||||
"""delete-note exits with code 1 when MCP tool returns an error field."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "test-note"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error: Delete failed" in result.output
|
||||
assert mock_mcp_delete.call_args.kwargs["output_format"] == "json"
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_delete_note",
|
||||
new_callable=AsyncMock,
|
||||
return_value=DELETE_NOTE_RESULT,
|
||||
)
|
||||
def test_delete_note_project_id_passthrough(mock_mcp_delete: AsyncMock) -> None:
|
||||
"""--project-id forwards to the MCP tool's project_id parameter."""
|
||||
uuid = "11111111-1111-1111-1111-111111111111"
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "delete-note", "test-note", "--project-id", uuid],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
assert mock_mcp_delete.call_args.kwargs["project_id"] == uuid
|
||||
|
||||
|
||||
# --- edit-note ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user