mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: add --json output to CLI commands for scripting and CI
Add machine-readable JSON output to five CLI commands: - `bm status --json` — sync report - `bm project list --json` — structured project list - `bm schema validate --json` — validation report - `bm schema infer --json` — inference report - `bm schema diff --json` — drift report Refactored `run_status()` to return data instead of printing directly, improving testability. Follows the established `bm project info --json` pattern using `print()` for clean JSON (no Rich markup). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -61,6 +61,7 @@ def list_projects(
|
||||
local: bool = typer.Option(False, "--local", help="Force local routing for this command"),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
workspace: str = typer.Option(None, "--workspace", help="Cloud workspace name or tenant_id"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
) -> None:
|
||||
"""List Basic Memory projects from local and (when available) cloud."""
|
||||
try:
|
||||
@@ -96,13 +97,9 @@ def list_projects(
|
||||
|
||||
if _has_cloud_credentials(config):
|
||||
try:
|
||||
with console.status(
|
||||
"[bold blue]Fetching cloud projects...", spinner="dots"
|
||||
):
|
||||
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
|
||||
with force_routing(cloud=True):
|
||||
cloud_result = run_with_cleanup(
|
||||
_list_projects(effective_workspace)
|
||||
)
|
||||
cloud_result = run_with_cleanup(_list_projects(effective_workspace))
|
||||
except Exception as exc: # pragma: no cover
|
||||
cloud_error = exc
|
||||
|
||||
@@ -113,9 +110,7 @@ def list_projects(
|
||||
try:
|
||||
from basic_memory.mcp.project_context import get_available_workspaces
|
||||
|
||||
with console.status(
|
||||
"[bold blue]Resolving workspace...", spinner="dots"
|
||||
):
|
||||
with console.status("[bold blue]Resolving workspace...", spinner="dots"):
|
||||
workspaces = run_with_cleanup(get_available_workspaces())
|
||||
matched = next(
|
||||
(ws for ws in workspaces if ws.tenant_id == effective_workspace),
|
||||
@@ -153,6 +148,8 @@ def list_projects(
|
||||
project_names_by_permalink[permalink] = project.name
|
||||
cloud_projects_by_permalink[permalink] = project
|
||||
|
||||
# --- Build unified project list ---
|
||||
project_rows: list[dict] = []
|
||||
for permalink in sorted(project_names_by_permalink):
|
||||
project_name = project_names_by_permalink[permalink]
|
||||
local_project = local_projects_by_permalink.get(permalink)
|
||||
@@ -182,9 +179,9 @@ def list_projects(
|
||||
else:
|
||||
cli_route = ProjectMode.LOCAL.value
|
||||
|
||||
is_default = "[X]" if config.default_project == project_name else ""
|
||||
is_default = config.default_project == project_name
|
||||
|
||||
has_sync = "[X]" if entry and entry.local_sync_path else ""
|
||||
has_sync = bool(entry and entry.local_sync_path)
|
||||
mcp_stdio_target = "local" if local_project is not None else "n/a"
|
||||
|
||||
# Show workspace name (type) for cloud-sourced projects
|
||||
@@ -192,18 +189,41 @@ def list_projects(
|
||||
if cloud_project is not None and cloud_ws_name:
|
||||
ws_label = f"{cloud_ws_name} ({cloud_ws_type})" if cloud_ws_type else cloud_ws_name
|
||||
|
||||
row = [
|
||||
project_name,
|
||||
local_path,
|
||||
cloud_path,
|
||||
ws_label,
|
||||
cli_route,
|
||||
mcp_stdio_target,
|
||||
has_sync,
|
||||
is_default,
|
||||
]
|
||||
row_data = {
|
||||
"name": project_name,
|
||||
"permalink": permalink,
|
||||
"local_path": local_path,
|
||||
"cloud_path": cloud_path,
|
||||
"cli_route": cli_route,
|
||||
"mcp_stdio": mcp_stdio_target,
|
||||
"sync": has_sync,
|
||||
"is_default": is_default,
|
||||
}
|
||||
if ws_label:
|
||||
row_data["workspace"] = cloud_ws_name or ""
|
||||
if cloud_ws_type:
|
||||
row_data["workspace_type"] = cloud_ws_type
|
||||
|
||||
table.add_row(*row)
|
||||
project_rows.append(row_data)
|
||||
|
||||
# --- JSON output ---
|
||||
if json_output:
|
||||
print(json.dumps({"projects": project_rows}, indent=2, default=str))
|
||||
return
|
||||
|
||||
# --- Rich table output ---
|
||||
for row_data in project_rows:
|
||||
table.add_row(
|
||||
row_data["name"],
|
||||
row_data["local_path"],
|
||||
row_data["cloud_path"],
|
||||
row_data.get("workspace", "")
|
||||
+ (f" ({row_data['workspace_type']})" if row_data.get("workspace_type") else ""),
|
||||
row_data["cli_route"],
|
||||
row_data["mcp_stdio"],
|
||||
"[X]" if row_data["sync"] else "",
|
||||
"[X]" if row_data["is_default"] else "",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if cloud_error is not None:
|
||||
|
||||
@@ -173,6 +173,7 @@ def validate(
|
||||
typer.Option(help="The project name."),
|
||||
] = None,
|
||||
strict: bool = typer.Option(False, "--strict", help="Exit with error on validation failures"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
@@ -183,6 +184,7 @@ def validate(
|
||||
TARGET can be a note path (e.g., people/ada-lovelace.md) or a note type
|
||||
(e.g., person). If omitted, validates all notes that have schemas.
|
||||
|
||||
Use --json for machine-readable output.
|
||||
Use --strict to exit with error code 1 if any validation errors are found.
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
@@ -211,12 +213,19 @@ def validate(
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
return
|
||||
|
||||
# output_format="json" guarantees a dict return
|
||||
assert isinstance(result, dict)
|
||||
_render_validate_table(result)
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
_render_validate_table(result)
|
||||
|
||||
if strict and result.get("error_count", 0) > 0:
|
||||
raise typer.Exit(1)
|
||||
@@ -245,6 +254,7 @@ def infer(
|
||||
0.25, "--threshold", help="Minimum frequency for optional fields (0-1)"
|
||||
),
|
||||
save: bool = typer.Option(False, "--save", help="Save inferred schema to schema/ directory"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
@@ -258,6 +268,7 @@ def infer(
|
||||
Fields present in 95%+ of notes become required. Fields above the
|
||||
threshold (default 25%) become optional. Fields below threshold are excluded.
|
||||
|
||||
Use --json for machine-readable output.
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
@@ -277,7 +288,10 @@ def infer(
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
return
|
||||
|
||||
# output_format="json" guarantees a dict return
|
||||
@@ -285,10 +299,16 @@ def infer(
|
||||
|
||||
# Handle zero notes
|
||||
if result.get("notes_analyzed", 0) == 0:
|
||||
console.print(f"[yellow]No notes found with type: {note_type}[/yellow]")
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
console.print(f"[yellow]No notes found with type: {note_type}[/yellow]")
|
||||
return
|
||||
|
||||
_render_infer_table(result)
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
_render_infer_table(result)
|
||||
|
||||
if save:
|
||||
console.print(
|
||||
@@ -316,6 +336,7 @@ def diff(
|
||||
Optional[str],
|
||||
typer.Option(help="The project name."),
|
||||
] = None,
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
@@ -327,6 +348,7 @@ def diff(
|
||||
are actually structured. Identifies new fields,
|
||||
dropped fields, and cardinality changes.
|
||||
|
||||
Use --json for machine-readable output.
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
@@ -345,12 +367,19 @@ def diff(
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
return
|
||||
|
||||
# output_format="json" guarantees a dict return
|
||||
assert isinstance(result, dict)
|
||||
_render_diff_output(result)
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
else:
|
||||
_render_diff_output(result)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Status command for basic-memory CLI."""
|
||||
|
||||
import json
|
||||
from typing import Set, Dict
|
||||
from typing import Annotated, Optional
|
||||
|
||||
@@ -141,21 +142,20 @@ def display_changes(
|
||||
console.print(Panel(tree, expand=False))
|
||||
|
||||
|
||||
async def run_status(project: Optional[str] = None, verbose: bool = False): # pragma: no cover
|
||||
"""Check sync status of files vs database."""
|
||||
async def run_status(
|
||||
project: Optional[str] = None,
|
||||
) -> tuple[str, SyncReportResponse]:
|
||||
"""Fetch sync status of files vs database.
|
||||
|
||||
Returns (project_name, sync_report) for the caller to render.
|
||||
"""
|
||||
# Resolve default project so get_client() can route per-project
|
||||
project = project or ConfigManager().default_project
|
||||
|
||||
try:
|
||||
async with get_client(project_name=project) as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
sync_report = await ProjectClient(client).get_status(project_item.external_id)
|
||||
|
||||
display_changes(project_item.name, "Status", sync_report, verbose)
|
||||
|
||||
except (ValueError, ToolError) as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
async with get_client(project_name=project) as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
sync_report = await ProjectClient(client).get_status(project_item.external_id)
|
||||
return project_item.name, sync_report
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -165,6 +165,7 @@ def status(
|
||||
typer.Option(help="The project name."),
|
||||
] = None,
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
@@ -172,6 +173,7 @@ def status(
|
||||
):
|
||||
"""Show sync status between files and database.
|
||||
|
||||
Use --json for machine-readable output.
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
@@ -187,11 +189,24 @@ def status(
|
||||
if not local and not cloud:
|
||||
local = True
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
project_name, sync_report = run_with_cleanup(run_status(project))
|
||||
|
||||
if json_output:
|
||||
print(json.dumps(sync_report.model_dump(mode="json"), indent=2, default=str))
|
||||
else:
|
||||
display_changes(project_name, "Status", sync_report, verbose)
|
||||
except (ValueError, ToolError) as e:
|
||||
if json_output:
|
||||
print(json.dumps({"error": str(e)}, indent=2))
|
||||
else:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking status: {e}")
|
||||
typer.echo(f"Error checking status: {e}", err=True)
|
||||
if json_output:
|
||||
print(json.dumps({"error": str(e)}, indent=2))
|
||||
else:
|
||||
typer.echo(f"Error checking status: {e}", err=True)
|
||||
raise typer.Exit(code=1) # pragma: no cover
|
||||
|
||||
Reference in New Issue
Block a user