Skip to content

Commit a47c9c0

Browse files
phernandezclaudebm-clawd
authored
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>
1 parent c46d7a6 commit a47c9c0

3 files changed

Lines changed: 641 additions & 18 deletions

File tree

src/basic_memory/cli/commands/tool.py

Lines changed: 182 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
from basic_memory.cli.commands.command_utils import run_with_cleanup
1313
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
1414
from basic_memory.config import ConfigManager
15+
from basic_memory.mcp.async_client import get_client
16+
from basic_memory.mcp.clients import KnowledgeClient, ResourceClient
17+
from basic_memory.mcp.project_context import get_active_project
18+
from basic_memory.mcp.tools.utils import call_get
19+
from basic_memory.schemas.base import Entity, TimeFrame
20+
from basic_memory.schemas.memory import GraphContext, MemoryUrl, memory_url_path
21+
from basic_memory.schemas.search import SearchItemType
1522

1623
# Import prompts
1724
from basic_memory.mcp.prompts.continue_conversation import (
@@ -25,14 +32,128 @@
2532
from basic_memory.mcp.tools import recent_activity as mcp_recent_activity
2633
from basic_memory.mcp.tools import search_notes as mcp_search
2734
from basic_memory.mcp.tools import write_note as mcp_write_note
28-
from basic_memory.schemas.base import TimeFrame
29-
from basic_memory.schemas.memory import MemoryUrl
30-
from basic_memory.schemas.search import SearchItemType
3135

3236
tool_app = typer.Typer()
3337
app.add_typer(tool_app, name="tool", help="Access to MCP tools via CLI")
3438

3539

40+
# --- JSON output helpers ---
41+
# These async functions bypass the MCP tool (which returns formatted strings)
42+
# and use API clients directly to return structured data for --format json.
43+
44+
45+
async def _write_note_json(
46+
title: str, content: str, folder: str, project_name: Optional[str], tags: Optional[List[str]]
47+
) -> dict:
48+
"""Write a note and return structured JSON metadata."""
49+
# Use the MCP tool to create/update the entity (handles create-or-update logic)
50+
await mcp_write_note.fn(title, content, folder, project_name, tags)
51+
52+
# Resolve the entity to get metadata back
53+
async with get_client() as client:
54+
active_project = await get_active_project(client, project_name)
55+
knowledge_client = KnowledgeClient(client, active_project.external_id)
56+
57+
entity = Entity(title=title, directory=folder)
58+
if not entity.permalink:
59+
raise ValueError(f"Could not generate permalink for title={title}, folder={folder}")
60+
entity_id = await knowledge_client.resolve_entity(entity.permalink)
61+
entity = await knowledge_client.get_entity(entity_id)
62+
63+
return {
64+
"title": entity.title,
65+
"permalink": entity.permalink,
66+
"content": content,
67+
"file_path": entity.file_path,
68+
}
69+
70+
71+
async def _read_note_json(
72+
identifier: str, project_name: Optional[str], page: int, page_size: int
73+
) -> dict:
74+
"""Read a note and return structured JSON with content and metadata."""
75+
async with get_client() as client:
76+
active_project = await get_active_project(client, project_name)
77+
knowledge_client = KnowledgeClient(client, active_project.external_id)
78+
resource_client = ResourceClient(client, active_project.external_id)
79+
80+
# Try direct resolution first (works for permalinks and memory URLs)
81+
entity_path = memory_url_path(identifier)
82+
entity_id = None
83+
try:
84+
entity_id = await knowledge_client.resolve_entity(entity_path)
85+
except Exception:
86+
logger.info(f"Direct lookup failed for '{entity_path}', trying title search")
87+
88+
# Fallback: title search (handles plain titles like "My Note")
89+
if entity_id is None:
90+
from basic_memory.mcp.tools.search import search_notes as mcp_search_tool
91+
92+
title_results = await mcp_search_tool.fn(
93+
query=identifier, search_type="title", project=project_name
94+
)
95+
if title_results and hasattr(title_results, "results") and title_results.results:
96+
result = title_results.results[0]
97+
if result.permalink:
98+
entity_id = await knowledge_client.resolve_entity(result.permalink)
99+
100+
if entity_id is None:
101+
raise ValueError(f"Could not find note matching: {identifier}")
102+
103+
entity = await knowledge_client.get_entity(entity_id)
104+
response = await resource_client.read(entity_id, page=page, page_size=page_size)
105+
106+
return {
107+
"title": entity.title,
108+
"permalink": entity.permalink,
109+
"content": response.text,
110+
"file_path": entity.file_path,
111+
}
112+
113+
114+
async def _recent_activity_json(
115+
type: Optional[List[SearchItemType]],
116+
depth: Optional[int],
117+
timeframe: Optional[TimeFrame],
118+
project_name: Optional[str] = None,
119+
page: int = 1,
120+
page_size: int = 50,
121+
) -> list:
122+
"""Get recent activity and return structured JSON list."""
123+
async with get_client() as client:
124+
# Build query params matching the MCP tool's logic
125+
params: dict = {"page": page, "page_size": page_size, "max_related": 10}
126+
if depth:
127+
params["depth"] = depth
128+
if timeframe:
129+
params["timeframe"] = timeframe
130+
if type:
131+
params["type"] = [t.value for t in type]
132+
133+
active_project = await get_active_project(client, project_name)
134+
response = await call_get(
135+
client,
136+
f"/v2/projects/{active_project.external_id}/memory/recent",
137+
params=params,
138+
)
139+
activity_data = GraphContext.model_validate(response.json())
140+
141+
# Extract entity results
142+
results = []
143+
for result in activity_data.results:
144+
pr = result.primary_result
145+
if pr.type == "entity":
146+
results.append(
147+
{
148+
"title": pr.title,
149+
"permalink": pr.permalink,
150+
"file_path": pr.file_path,
151+
"created_at": str(pr.created_at) if pr.created_at else None,
152+
}
153+
)
154+
return results
155+
156+
36157
@tool_app.command()
37158
def write_note(
38159
title: Annotated[str, typer.Option(help="The title of the note")],
@@ -52,6 +173,7 @@ def write_note(
52173
tags: Annotated[
53174
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
54175
] = None,
176+
format: str = typer.Option("text", "--format", help="Output format: text or json"),
55177
local: bool = typer.Option(
56178
False, "--local", help="Force local API routing (ignore cloud mode)"
57179
),
@@ -123,9 +245,20 @@ def write_note(
123245
# use the project name, or the default from the config
124246
project_name = project_name or config_manager.default_project
125247

248+
# content is validated non-None above (stdin or --content)
249+
assert content is not None
250+
126251
with force_routing(local=local, cloud=cloud):
127-
note = run_with_cleanup(mcp_write_note.fn(title, content, folder, project_name, tags))
128-
rprint(note)
252+
if format == "json":
253+
result = run_with_cleanup(
254+
_write_note_json(title, content, folder, project_name, tags)
255+
)
256+
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
257+
else:
258+
note = run_with_cleanup(
259+
mcp_write_note.fn(title, content, folder, project_name, tags)
260+
)
261+
rprint(note)
129262
except ValueError as e:
130263
typer.echo(f"Error: {e}", err=True)
131264
raise typer.Exit(1)
@@ -147,6 +280,7 @@ def read_note(
147280
] = None,
148281
page: int = 1,
149282
page_size: int = 10,
283+
format: str = typer.Option("text", "--format", help="Output format: text or json"),
150284
local: bool = typer.Option(
151285
False, "--local", help="Force local API routing (ignore cloud mode)"
152286
),
@@ -173,8 +307,14 @@ def read_note(
173307
project_name = project_name or config_manager.default_project
174308

175309
with force_routing(local=local, cloud=cloud):
176-
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
177-
rprint(note)
310+
if format == "json":
311+
result = run_with_cleanup(
312+
_read_note_json(identifier, project_name, page, page_size)
313+
)
314+
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
315+
else:
316+
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
317+
rprint(note)
178318
except ValueError as e:
179319
typer.echo(f"Error: {e}", err=True)
180320
raise typer.Exit(1)
@@ -197,6 +337,7 @@ def build_context(
197337
page: int = 1,
198338
page_size: int = 10,
199339
max_related: int = 10,
340+
format: str = typer.Option("json", "--format", help="Output format: text or json"),
200341
local: bool = typer.Option(
201342
False, "--local", help="Force local API routing (ignore cloud mode)"
202343
),
@@ -234,9 +375,6 @@ def build_context(
234375
max_related=max_related,
235376
)
236377
)
237-
# Use json module for more controlled serialization
238-
import json
239-
240378
context_dict = context.model_dump(exclude_none=True)
241379
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
242380
except ValueError as e:
@@ -252,8 +390,17 @@ def build_context(
252390
@tool_app.command()
253391
def recent_activity(
254392
type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
393+
project: Annotated[
394+
Optional[str],
395+
typer.Option(help="The project to use. If not provided, the default project will be used."),
396+
] = None,
255397
depth: Optional[int] = 1,
256398
timeframe: Optional[TimeFrame] = "7d",
399+
page: int = typer.Option(1, "--page", help="Page number for pagination (JSON format)"),
400+
page_size: int = typer.Option(
401+
50, "--page-size", help="Number of results per page (JSON format)"
402+
),
403+
format: str = typer.Option("text", "--format", help="Output format: text or json"),
257404
local: bool = typer.Option(
258405
False, "--local", help="Force local API routing (ignore cloud mode)"
259406
),
@@ -267,16 +414,33 @@ def recent_activity(
267414
try:
268415
validate_routing_flags(local, cloud)
269416

417+
# Resolve project from config for JSON mode
418+
config_manager = ConfigManager()
419+
project_name = None
420+
if project is not None:
421+
project_name, _ = config_manager.get_project(project)
422+
if not project_name:
423+
typer.echo(f"No project found named: {project}", err=True)
424+
raise typer.Exit(1)
425+
project_name = project_name or config_manager.default_project
426+
270427
with force_routing(local=local, cloud=cloud):
271-
result = run_with_cleanup(
272-
mcp_recent_activity.fn(
273-
type=type, # pyright: ignore [reportArgumentType]
274-
depth=depth,
275-
timeframe=timeframe,
428+
if format == "json":
429+
result = run_with_cleanup(
430+
_recent_activity_json(type, depth, timeframe, project_name, page, page_size)
276431
)
277-
)
278-
# The tool now returns a formatted string directly
279-
print(result)
432+
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
433+
else:
434+
result = run_with_cleanup(
435+
mcp_recent_activity.fn(
436+
type=type, # pyright: ignore [reportArgumentType]
437+
depth=depth,
438+
timeframe=timeframe,
439+
project=project_name,
440+
)
441+
)
442+
# The tool returns a formatted string directly
443+
print(result)
280444
except ValueError as e:
281445
typer.echo(f"Error: {e}", err=True)
282446
raise typer.Exit(1)

0 commit comments

Comments
 (0)