mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: add --format json to CLI tool commands (#552)
Signed-off-by: phernandez <paul@basicmachines.co> Signed-off-by: bm-clawd <clawd@basicmemory.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: bm-clawd <clawd@basicmemory.com>
This commit is contained in:
@@ -12,6 +12,13 @@ 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.config import ConfigManager
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.clients import KnowledgeClient, ResourceClient
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.base import Entity, TimeFrame
|
||||
from basic_memory.schemas.memory import GraphContext, MemoryUrl, memory_url_path
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
# Import prompts
|
||||
from basic_memory.mcp.prompts.continue_conversation import (
|
||||
@@ -25,14 +32,128 @@ 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
|
||||
from basic_memory.mcp.tools import write_note as mcp_write_note
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import MemoryUrl
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
tool_app = typer.Typer()
|
||||
app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
|
||||
|
||||
|
||||
# --- JSON output helpers ---
|
||||
# These async functions bypass the MCP tool (which returns formatted strings)
|
||||
# and use API clients directly to return structured data for --format json.
|
||||
|
||||
|
||||
async def _write_note_json(
|
||||
title: str, content: str, folder: str, project_name: Optional[str], tags: Optional[List[str]]
|
||||
) -> dict:
|
||||
"""Write a note and return structured JSON metadata."""
|
||||
# Use the MCP tool to create/update the entity (handles create-or-update logic)
|
||||
await mcp_write_note.fn(title, content, folder, project_name, tags)
|
||||
|
||||
# Resolve the entity to get metadata back
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project_name)
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
entity = Entity(title=title, directory=folder)
|
||||
if not entity.permalink:
|
||||
raise ValueError(f"Could not generate permalink for title={title}, folder={folder}")
|
||||
entity_id = await knowledge_client.resolve_entity(entity.permalink)
|
||||
entity = await knowledge_client.get_entity(entity_id)
|
||||
|
||||
return {
|
||||
"title": entity.title,
|
||||
"permalink": entity.permalink,
|
||||
"content": content,
|
||||
"file_path": entity.file_path,
|
||||
}
|
||||
|
||||
|
||||
async def _read_note_json(
|
||||
identifier: str, project_name: Optional[str], page: int, page_size: int
|
||||
) -> dict:
|
||||
"""Read a note and return structured JSON with content and metadata."""
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project_name)
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
resource_client = ResourceClient(client, active_project.external_id)
|
||||
|
||||
# Try direct resolution first (works for permalinks and memory URLs)
|
||||
entity_path = memory_url_path(identifier)
|
||||
entity_id = None
|
||||
try:
|
||||
entity_id = await knowledge_client.resolve_entity(entity_path)
|
||||
except Exception:
|
||||
logger.info(f"Direct lookup failed for '{entity_path}', trying title search")
|
||||
|
||||
# Fallback: title search (handles plain titles like "My Note")
|
||||
if entity_id is None:
|
||||
from basic_memory.mcp.tools.search import search_notes as mcp_search_tool
|
||||
|
||||
title_results = await mcp_search_tool.fn(
|
||||
query=identifier, search_type="title", project=project_name
|
||||
)
|
||||
if title_results and hasattr(title_results, "results") and title_results.results:
|
||||
result = title_results.results[0]
|
||||
if result.permalink:
|
||||
entity_id = await knowledge_client.resolve_entity(result.permalink)
|
||||
|
||||
if entity_id is None:
|
||||
raise ValueError(f"Could not find note matching: {identifier}")
|
||||
|
||||
entity = await knowledge_client.get_entity(entity_id)
|
||||
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
||||
|
||||
return {
|
||||
"title": entity.title,
|
||||
"permalink": entity.permalink,
|
||||
"content": response.text,
|
||||
"file_path": entity.file_path,
|
||||
}
|
||||
|
||||
|
||||
async def _recent_activity_json(
|
||||
type: Optional[List[SearchItemType]],
|
||||
depth: Optional[int],
|
||||
timeframe: Optional[TimeFrame],
|
||||
project_name: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> list:
|
||||
"""Get recent activity and return structured JSON list."""
|
||||
async with get_client() as client:
|
||||
# Build query params matching the MCP tool's logic
|
||||
params: dict = {"page": page, "page_size": page_size, "max_related": 10}
|
||||
if depth:
|
||||
params["depth"] = depth
|
||||
if timeframe:
|
||||
params["timeframe"] = timeframe
|
||||
if type:
|
||||
params["type"] = [t.value for t in type]
|
||||
|
||||
active_project = await get_active_project(client, project_name)
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/memory/recent",
|
||||
params=params,
|
||||
)
|
||||
activity_data = GraphContext.model_validate(response.json())
|
||||
|
||||
# Extract entity results
|
||||
results = []
|
||||
for result in activity_data.results:
|
||||
pr = result.primary_result
|
||||
if pr.type == "entity":
|
||||
results.append(
|
||||
{
|
||||
"title": pr.title,
|
||||
"permalink": pr.permalink,
|
||||
"file_path": pr.file_path,
|
||||
"created_at": str(pr.created_at) if pr.created_at else None,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@tool_app.command()
|
||||
def write_note(
|
||||
title: Annotated[str, typer.Option(help="The title of the note")],
|
||||
@@ -52,6 +173,7 @@ def write_note(
|
||||
tags: Annotated[
|
||||
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
|
||||
] = None,
|
||||
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)"
|
||||
),
|
||||
@@ -123,9 +245,20 @@ def write_note(
|
||||
# use the project name, or the default from the config
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
# content is validated non-None above (stdin or --content)
|
||||
assert content is not None
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
note = run_with_cleanup(mcp_write_note.fn(title, content, folder, project_name, tags))
|
||||
rprint(note)
|
||||
if format == "json":
|
||||
result = run_with_cleanup(
|
||||
_write_note_json(title, content, folder, project_name, tags)
|
||||
)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
else:
|
||||
note = run_with_cleanup(
|
||||
mcp_write_note.fn(title, content, folder, project_name, tags)
|
||||
)
|
||||
rprint(note)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
@@ -147,6 +280,7 @@ def read_note(
|
||||
] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
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)"
|
||||
),
|
||||
@@ -173,8 +307,14 @@ def read_note(
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
|
||||
rprint(note)
|
||||
if format == "json":
|
||||
result = run_with_cleanup(
|
||||
_read_note_json(identifier, project_name, page, page_size)
|
||||
)
|
||||
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))
|
||||
rprint(note)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
@@ -197,6 +337,7 @@ def build_context(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
format: str = typer.Option("json", "--format", help="Output format: text or json"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
@@ -234,9 +375,6 @@ def build_context(
|
||||
max_related=max_related,
|
||||
)
|
||||
)
|
||||
# Use json module for more controlled serialization
|
||||
import json
|
||||
|
||||
context_dict = context.model_dump(exclude_none=True)
|
||||
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
|
||||
except ValueError as e:
|
||||
@@ -252,8 +390,17 @@ def build_context(
|
||||
@tool_app.command()
|
||||
def recent_activity(
|
||||
type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
|
||||
project: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(help="The project to use. If not provided, the default project will be used."),
|
||||
] = None,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
page: int = typer.Option(1, "--page", help="Page number for pagination (JSON format)"),
|
||||
page_size: int = typer.Option(
|
||||
50, "--page-size", help="Number of results per page (JSON format)"
|
||||
),
|
||||
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)"
|
||||
),
|
||||
@@ -267,16 +414,33 @@ def recent_activity(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
# Resolve project from config for JSON mode
|
||||
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)
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_recent_activity.fn(
|
||||
type=type, # pyright: ignore [reportArgumentType]
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
if format == "json":
|
||||
result = run_with_cleanup(
|
||||
_recent_activity_json(type, depth, timeframe, project_name, page, page_size)
|
||||
)
|
||||
)
|
||||
# The tool now returns a formatted string directly
|
||||
print(result)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
|
||||
else:
|
||||
result = run_with_cleanup(
|
||||
mcp_recent_activity.fn(
|
||||
type=type, # pyright: ignore [reportArgumentType]
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
project=project_name,
|
||||
)
|
||||
)
|
||||
# The tool returns a formatted string directly
|
||||
print(result)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Integration tests for CLI tool --format json output."""
|
||||
|
||||
import json
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.main import app as cli_app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_write_note_json_format(app, app_config, test_project, config_manager):
|
||||
"""Test write-note --format json returns valid JSON with expected keys."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
"Integration Test Note",
|
||||
"--folder",
|
||||
"test-notes",
|
||||
"--content",
|
||||
"# Test\n\nThis is test content.",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
print(f"STDERR: {result.stderr if hasattr(result, 'stderr') else 'N/A'}")
|
||||
print(f"Exception: {result.exception}")
|
||||
assert result.exit_code == 0
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
assert data["title"] == "Integration Test Note"
|
||||
assert "permalink" in data
|
||||
assert data["content"] == "# Test\n\nThis is test content."
|
||||
assert "file_path" in data
|
||||
|
||||
|
||||
def test_read_note_json_format(app, app_config, test_project, config_manager):
|
||||
"""Test read-note --format json returns valid JSON with expected keys."""
|
||||
# First, write a note
|
||||
write_result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
"Read Test Note",
|
||||
"--folder",
|
||||
"test-notes",
|
||||
"--content",
|
||||
"# Read Test\n\nContent to read back.",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
assert write_result.exit_code == 0
|
||||
write_data = json.loads(write_result.stdout)
|
||||
permalink = write_data["permalink"]
|
||||
|
||||
# Now read it back
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "read-note", permalink, "--format", "json"],
|
||||
)
|
||||
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
print(f"Exception: {result.exception}")
|
||||
assert result.exit_code == 0
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
assert data["title"] == "Read Test Note"
|
||||
assert data["permalink"] == permalink
|
||||
assert "content" in data
|
||||
assert "file_path" in data
|
||||
|
||||
|
||||
def test_recent_activity_json_format(app, app_config, test_project, config_manager, monkeypatch):
|
||||
"""Test recent-activity --format json returns valid JSON list."""
|
||||
# _recent_activity_json uses resolve_project_parameter which requires either
|
||||
# default_project_mode=True or BASIC_MEMORY_MCP_PROJECT to resolve a project
|
||||
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name)
|
||||
|
||||
# Write a note to ensure there's recent activity
|
||||
write_result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
"Activity Test Note",
|
||||
"--folder",
|
||||
"test-notes",
|
||||
"--content",
|
||||
"# Activity\n\nTest content for activity.",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
assert write_result.exit_code == 0
|
||||
|
||||
# Get recent activity
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "recent-activity", "--format", "json"],
|
||||
)
|
||||
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
print(f"Exception: {result.exception}")
|
||||
assert result.exit_code == 0
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, list)
|
||||
# Should have at least one entity from the note we just wrote
|
||||
assert len(data) > 0
|
||||
item = data[0]
|
||||
assert "title" in item
|
||||
assert "permalink" in item
|
||||
assert "file_path" in item
|
||||
assert "created_at" in item
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Tests for --format json output in CLI tool commands.
|
||||
|
||||
Verifies that write-note, read-note, and recent-activity commands
|
||||
produce valid JSON output when invoked with --format json, and that
|
||||
the default text format still works via the MCP tool path.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.main import app as cli_app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# --- Shared mock data ---
|
||||
|
||||
WRITE_NOTE_RESULT = {
|
||||
"title": "Test Note",
|
||||
"permalink": "notes/test-note",
|
||||
"content": "hello world",
|
||||
"file_path": "notes/Test Note.md",
|
||||
}
|
||||
|
||||
READ_NOTE_RESULT = {
|
||||
"title": "Test Note",
|
||||
"permalink": "notes/test-note",
|
||||
"content": "# Test Note\n\nhello world",
|
||||
"file_path": "notes/Test Note.md",
|
||||
}
|
||||
|
||||
RECENT_ACTIVITY_RESULT = [
|
||||
{
|
||||
"title": "Note A",
|
||||
"permalink": "notes/note-a",
|
||||
"file_path": "notes/Note A.md",
|
||||
"created_at": "2025-01-01 00:00:00",
|
||||
},
|
||||
{
|
||||
"title": "Note B",
|
||||
"permalink": "notes/note-b",
|
||||
"file_path": "notes/Note B.md",
|
||||
"created_at": "2025-01-02 00:00:00",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _mock_config_manager():
|
||||
"""Create a mock ConfigManager that avoids reading real config."""
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.config = MagicMock()
|
||||
mock_cm.default_project = "test-project"
|
||||
mock_cm.get_project.return_value = ("test-project", "/tmp/test")
|
||||
return mock_cm
|
||||
|
||||
|
||||
# --- write-note --format json ---
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool._write_note_json",
|
||||
new_callable=AsyncMock,
|
||||
return_value=WRITE_NOTE_RESULT,
|
||||
)
|
||||
def test_write_note_json_output(mock_write_json, mock_config_cls):
|
||||
"""write-note --format json outputs valid JSON with expected keys."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
"Test Note",
|
||||
"--folder",
|
||||
"notes",
|
||||
"--content",
|
||||
"hello world",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
|
||||
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"] == "hello world"
|
||||
assert data["file_path"] == "notes/Test Note.md"
|
||||
mock_write_json.assert_called_once()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_write_note",
|
||||
)
|
||||
def test_write_note_text_output(mock_mcp_write, mock_config_cls):
|
||||
"""write-note with default text format uses the MCP tool path."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
# MCP tool .fn returns a formatted string
|
||||
mock_mcp_write.fn = AsyncMock(return_value="Created note: Test Note")
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
[
|
||||
"tool",
|
||||
"write-note",
|
||||
"--title",
|
||||
"Test Note",
|
||||
"--folder",
|
||||
"notes",
|
||||
"--content",
|
||||
"hello world",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
assert "Created note: Test Note" in result.output
|
||||
mock_mcp_write.fn.assert_called_once()
|
||||
|
||||
|
||||
# --- read-note --format json ---
|
||||
|
||||
|
||||
@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_output(mock_read_json, mock_config_cls):
|
||||
"""read-note --format json outputs valid JSON with expected keys."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "read-note", "test-note", "--format", "json"],
|
||||
)
|
||||
|
||||
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["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_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")
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "read-note", "test-note"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
assert "Test Note" in result.output
|
||||
mock_mcp_read.fn.assert_called_once()
|
||||
|
||||
|
||||
# --- recent-activity --format json ---
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool._recent_activity_json",
|
||||
new_callable=AsyncMock,
|
||||
return_value=RECENT_ACTIVITY_RESULT,
|
||||
)
|
||||
def test_recent_activity_json_output(mock_recent_json):
|
||||
"""recent-activity --format json outputs valid JSON list."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "recent-activity", "--format", "json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 2
|
||||
assert data[0]["title"] == "Note A"
|
||||
assert data[0]["permalink"] == "notes/note-a"
|
||||
assert data[0]["file_path"] == "notes/Note A.md"
|
||||
assert data[0]["created_at"] == "2025-01-01 00:00:00"
|
||||
assert data[1]["title"] == "Note B"
|
||||
mock_recent_json.assert_called_once()
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool.mcp_recent_activity",
|
||||
)
|
||||
def test_recent_activity_text_output(mock_mcp_recent):
|
||||
"""recent-activity with default text format uses the MCP tool path."""
|
||||
mock_mcp_recent.fn = AsyncMock(return_value="Recent activity:\n- Note A\n- Note B")
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "recent-activity"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
assert "Recent activity:" in result.output
|
||||
mock_mcp_recent.fn.assert_called_once()
|
||||
|
||||
|
||||
# --- read-note title fallback ---
|
||||
|
||||
|
||||
@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_with_plain_title(mock_read_json, mock_config_cls):
|
||||
"""read-note --format json works with plain titles (not just permalinks)."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "read-note", "My Note Title", "--format", "json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert data["title"] == "Test Note"
|
||||
# Verify the identifier was passed through
|
||||
call_args = mock_read_json.call_args
|
||||
assert call_args[0][0] == "My Note Title" or call_args[1].get("identifier") == "My Note Title"
|
||||
|
||||
|
||||
# --- recent-activity pagination ---
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool._recent_activity_json",
|
||||
new_callable=AsyncMock,
|
||||
return_value=RECENT_ACTIVITY_RESULT,
|
||||
)
|
||||
def test_recent_activity_json_pagination(mock_recent_json):
|
||||
"""recent-activity --format json passes --page and --page-size to helper."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "recent-activity", "--format", "json", "--page", "2", "--page-size", "10"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert isinstance(data, list)
|
||||
# Verify pagination params were passed through
|
||||
mock_recent_json.assert_called_once()
|
||||
call_kwargs = mock_recent_json.call_args
|
||||
# positional args: type, depth, timeframe, project_name, page, page_size
|
||||
assert call_kwargs[0][4] == 2 # page
|
||||
assert call_kwargs[0][5] == 10 # page_size
|
||||
|
||||
|
||||
# --- build-context --format json ---
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch("basic_memory.cli.commands.tool.mcp_build_context")
|
||||
def test_build_context_format_json(mock_build_ctx, mock_config_cls):
|
||||
"""build-context --format json outputs valid JSON."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.model_dump.return_value = {
|
||||
"primary_results": [],
|
||||
"related_results": [],
|
||||
}
|
||||
mock_build_ctx.fn = AsyncMock(return_value=mock_context)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "build-context", "memory://test/topic", "--format", "json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert "primary_results" in data
|
||||
mock_build_ctx.fn.assert_called_once()
|
||||
|
||||
|
||||
@patch("basic_memory.cli.commands.tool.ConfigManager")
|
||||
@patch("basic_memory.cli.commands.tool.mcp_build_context")
|
||||
def test_build_context_default_format_is_json(mock_build_ctx, mock_config_cls):
|
||||
"""build-context defaults to JSON output (backward compatible)."""
|
||||
mock_config_cls.return_value = _mock_config_manager()
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.model_dump.return_value = {"results": []}
|
||||
mock_build_ctx.fn = AsyncMock(return_value=mock_context)
|
||||
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "build-context", "memory://test/topic"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
|
||||
# --- Edge cases ---
|
||||
|
||||
|
||||
@patch(
|
||||
"basic_memory.cli.commands.tool._recent_activity_json",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
)
|
||||
def test_recent_activity_json_empty(mock_recent_json):
|
||||
"""recent-activity --format json handles empty results."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["tool", "recent-activity", "--format", "json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert data == []
|
||||
Reference in New Issue
Block a user