Skip to content

Commit 3bbb44a

Browse files
phernandezclaude
andcommitted
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>
1 parent f9b2a07 commit 3bbb44a

6 files changed

Lines changed: 542 additions & 50 deletions

File tree

docs/releases/v0.19.0.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,19 @@ Docker-internal paths that don't exist locally.
179179
All `bm tool` subcommands support `--format json` for machine-readable output, enabling
180180
integration with scripts and plugins.
181181

182+
### `--json` for Top-Level CLI Commands
183+
184+
Five additional CLI commands now support `--json` for machine-readable output:
185+
186+
- `bm status --json` — sync report with new/modified/deleted/moved files and skipped files
187+
- `bm project list --json` — structured project list with name, paths, routing mode, and defaults
188+
- `bm schema validate --json` — validation report with per-note pass/fail, warnings, and errors
189+
- `bm schema infer --json` — field frequency analysis and suggested schema definition
190+
- `bm schema diff --json` — drift report with new fields, dropped fields, and cardinality changes
191+
192+
This complements the existing `bm project info --json` and `bm tool --format json` support,
193+
making all major CLI commands scriptable for CI pipelines and automation.
194+
182195
### Cloud Promo and Analytics
183196

184197
- Cloud promo panel shown on first run or version bump with OSS discount code

src/basic_memory/cli/commands/project.py

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def list_projects(
6161
local: bool = typer.Option(False, "--local", help="Force local routing for this command"),
6262
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
6363
workspace: str = typer.Option(None, "--workspace", help="Cloud workspace name or tenant_id"),
64+
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
6465
) -> None:
6566
"""List Basic Memory projects from local and (when available) cloud."""
6667
try:
@@ -96,13 +97,9 @@ async def _list_projects(ws: str | None = None):
9697

9798
if _has_cloud_credentials(config):
9899
try:
99-
with console.status(
100-
"[bold blue]Fetching cloud projects...", spinner="dots"
101-
):
100+
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
102101
with force_routing(cloud=True):
103-
cloud_result = run_with_cleanup(
104-
_list_projects(effective_workspace)
105-
)
102+
cloud_result = run_with_cleanup(_list_projects(effective_workspace))
106103
except Exception as exc: # pragma: no cover
107104
cloud_error = exc
108105

@@ -113,9 +110,7 @@ async def _list_projects(ws: str | None = None):
113110
try:
114111
from basic_memory.mcp.project_context import get_available_workspaces
115112

116-
with console.status(
117-
"[bold blue]Resolving workspace...", spinner="dots"
118-
):
113+
with console.status("[bold blue]Resolving workspace...", spinner="dots"):
119114
workspaces = run_with_cleanup(get_available_workspaces())
120115
matched = next(
121116
(ws for ws in workspaces if ws.tenant_id == effective_workspace),
@@ -153,6 +148,8 @@ async def _list_projects(ws: str | None = None):
153148
project_names_by_permalink[permalink] = project.name
154149
cloud_projects_by_permalink[permalink] = project
155150

151+
# --- Build unified project list ---
152+
project_rows: list[dict] = []
156153
for permalink in sorted(project_names_by_permalink):
157154
project_name = project_names_by_permalink[permalink]
158155
local_project = local_projects_by_permalink.get(permalink)
@@ -182,28 +179,51 @@ async def _list_projects(ws: str | None = None):
182179
else:
183180
cli_route = ProjectMode.LOCAL.value
184181

185-
is_default = "[X]" if config.default_project == project_name else ""
182+
is_default = config.default_project == project_name
186183

187-
has_sync = "[X]" if entry and entry.local_sync_path else ""
184+
has_sync = bool(entry and entry.local_sync_path)
188185
mcp_stdio_target = "local" if local_project is not None else "n/a"
189186

190187
# Show workspace name (type) for cloud-sourced projects
191188
ws_label = ""
192189
if cloud_project is not None and cloud_ws_name:
193190
ws_label = f"{cloud_ws_name} ({cloud_ws_type})" if cloud_ws_type else cloud_ws_name
194191

195-
row = [
196-
project_name,
197-
local_path,
198-
cloud_path,
199-
ws_label,
200-
cli_route,
201-
mcp_stdio_target,
202-
has_sync,
203-
is_default,
204-
]
205-
206-
table.add_row(*row)
192+
row_data = {
193+
"name": project_name,
194+
"permalink": permalink,
195+
"local_path": local_path,
196+
"cloud_path": cloud_path,
197+
"cli_route": cli_route,
198+
"mcp_stdio": mcp_stdio_target,
199+
"sync": has_sync,
200+
"is_default": is_default,
201+
}
202+
if ws_label:
203+
row_data["workspace"] = cloud_ws_name or ""
204+
if cloud_ws_type:
205+
row_data["workspace_type"] = cloud_ws_type
206+
207+
project_rows.append(row_data)
208+
209+
# --- JSON output ---
210+
if json_output:
211+
print(json.dumps({"projects": project_rows}, indent=2, default=str))
212+
return
213+
214+
# --- Rich table output ---
215+
for row_data in project_rows:
216+
table.add_row(
217+
row_data["name"],
218+
row_data["local_path"],
219+
row_data["cloud_path"],
220+
row_data.get("workspace", "")
221+
+ (f" ({row_data['workspace_type']})" if row_data.get("workspace_type") else ""),
222+
row_data["cli_route"],
223+
row_data["mcp_stdio"],
224+
"[X]" if row_data["sync"] else "",
225+
"[X]" if row_data["is_default"] else "",
226+
)
207227

208228
console.print(table)
209229
if cloud_error is not None:

src/basic_memory/cli/commands/schema.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ def validate(
173173
typer.Option(help="The project name."),
174174
] = None,
175175
strict: bool = typer.Option(False, "--strict", help="Exit with error on validation failures"),
176+
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
176177
local: bool = typer.Option(
177178
False, "--local", help="Force local API routing (ignore cloud mode)"
178179
),
@@ -183,6 +184,7 @@ def validate(
183184
TARGET can be a note path (e.g., people/ada-lovelace.md) or a note type
184185
(e.g., person). If omitted, validates all notes that have schemas.
185186
187+
Use --json for machine-readable output.
186188
Use --strict to exit with error code 1 if any validation errors are found.
187189
Use --local to force local routing when cloud mode is enabled.
188190
Use --cloud to force cloud routing when cloud mode is disabled.
@@ -211,12 +213,19 @@ def validate(
211213

212214
# Handle error responses
213215
if isinstance(result, dict) and "error" in result:
214-
console.print(f"[yellow]{result['error']}[/yellow]")
216+
if json_output:
217+
print(json.dumps(result, indent=2, default=str))
218+
else:
219+
console.print(f"[yellow]{result['error']}[/yellow]")
215220
return
216221

217222
# output_format="json" guarantees a dict return
218223
assert isinstance(result, dict)
219-
_render_validate_table(result)
224+
225+
if json_output:
226+
print(json.dumps(result, indent=2, default=str))
227+
else:
228+
_render_validate_table(result)
220229

221230
if strict and result.get("error_count", 0) > 0:
222231
raise typer.Exit(1)
@@ -245,6 +254,7 @@ def infer(
245254
0.25, "--threshold", help="Minimum frequency for optional fields (0-1)"
246255
),
247256
save: bool = typer.Option(False, "--save", help="Save inferred schema to schema/ directory"),
257+
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
248258
local: bool = typer.Option(
249259
False, "--local", help="Force local API routing (ignore cloud mode)"
250260
),
@@ -258,6 +268,7 @@ def infer(
258268
Fields present in 95%+ of notes become required. Fields above the
259269
threshold (default 25%) become optional. Fields below threshold are excluded.
260270
271+
Use --json for machine-readable output.
261272
Use --local to force local routing when cloud mode is enabled.
262273
Use --cloud to force cloud routing when cloud mode is disabled.
263274
"""
@@ -277,18 +288,27 @@ def infer(
277288

278289
# Handle error responses
279290
if isinstance(result, dict) and "error" in result:
280-
console.print(f"[yellow]{result['error']}[/yellow]")
291+
if json_output:
292+
print(json.dumps(result, indent=2, default=str))
293+
else:
294+
console.print(f"[yellow]{result['error']}[/yellow]")
281295
return
282296

283297
# output_format="json" guarantees a dict return
284298
assert isinstance(result, dict)
285299

286300
# Handle zero notes
287301
if result.get("notes_analyzed", 0) == 0:
288-
console.print(f"[yellow]No notes found with type: {note_type}[/yellow]")
302+
if json_output:
303+
print(json.dumps(result, indent=2, default=str))
304+
else:
305+
console.print(f"[yellow]No notes found with type: {note_type}[/yellow]")
289306
return
290307

291-
_render_infer_table(result)
308+
if json_output:
309+
print(json.dumps(result, indent=2, default=str))
310+
else:
311+
_render_infer_table(result)
292312

293313
if save:
294314
console.print(
@@ -316,6 +336,7 @@ def diff(
316336
Optional[str],
317337
typer.Option(help="The project name."),
318338
] = None,
339+
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
319340
local: bool = typer.Option(
320341
False, "--local", help="Force local API routing (ignore cloud mode)"
321342
),
@@ -327,6 +348,7 @@ def diff(
327348
are actually structured. Identifies new fields,
328349
dropped fields, and cardinality changes.
329350
351+
Use --json for machine-readable output.
330352
Use --local to force local routing when cloud mode is enabled.
331353
Use --cloud to force cloud routing when cloud mode is disabled.
332354
"""
@@ -345,12 +367,19 @@ def diff(
345367

346368
# Handle error responses
347369
if isinstance(result, dict) and "error" in result:
348-
console.print(f"[yellow]{result['error']}[/yellow]")
370+
if json_output:
371+
print(json.dumps(result, indent=2, default=str))
372+
else:
373+
console.print(f"[yellow]{result['error']}[/yellow]")
349374
return
350375

351376
# output_format="json" guarantees a dict return
352377
assert isinstance(result, dict)
353-
_render_diff_output(result)
378+
379+
if json_output:
380+
print(json.dumps(result, indent=2, default=str))
381+
else:
382+
_render_diff_output(result)
354383
except ValueError as e:
355384
console.print(f"[red]Error: {e}[/red]")
356385
raise typer.Exit(1)

src/basic_memory/cli/commands/status.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Status command for basic-memory CLI."""
22

3+
import json
34
from typing import Set, Dict
45
from typing import Annotated, Optional
56

@@ -141,21 +142,20 @@ def display_changes(
141142
console.print(Panel(tree, expand=False))
142143

143144

144-
async def run_status(project: Optional[str] = None, verbose: bool = False): # pragma: no cover
145-
"""Check sync status of files vs database."""
145+
async def run_status(
146+
project: Optional[str] = None,
147+
) -> tuple[str, SyncReportResponse]:
148+
"""Fetch sync status of files vs database.
149+
150+
Returns (project_name, sync_report) for the caller to render.
151+
"""
146152
# Resolve default project so get_client() can route per-project
147153
project = project or ConfigManager().default_project
148154

149-
try:
150-
async with get_client(project_name=project) as client:
151-
project_item = await get_active_project(client, project, None)
152-
sync_report = await ProjectClient(client).get_status(project_item.external_id)
153-
154-
display_changes(project_item.name, "Status", sync_report, verbose)
155-
156-
except (ValueError, ToolError) as e:
157-
console.print(f"[red]Error: {e}[/red]")
158-
raise typer.Exit(1)
155+
async with get_client(project_name=project) as client:
156+
project_item = await get_active_project(client, project, None)
157+
sync_report = await ProjectClient(client).get_status(project_item.external_id)
158+
return project_item.name, sync_report
159159

160160

161161
@app.command()
@@ -165,13 +165,15 @@ def status(
165165
typer.Option(help="The project name."),
166166
] = None,
167167
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
168+
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
168169
local: bool = typer.Option(
169170
False, "--local", help="Force local API routing (ignore cloud mode)"
170171
),
171172
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
172173
):
173174
"""Show sync status between files and database.
174175
176+
Use --json for machine-readable output.
175177
Use --local to force local routing when cloud mode is enabled.
176178
Use --cloud to force cloud routing when cloud mode is disabled.
177179
"""
@@ -187,11 +189,24 @@ def status(
187189
if not local and not cloud:
188190
local = True
189191
with force_routing(local=local, cloud=cloud):
190-
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
191-
except ValueError as e:
192-
console.print(f"[red]Error: {e}[/red]")
192+
project_name, sync_report = run_with_cleanup(run_status(project))
193+
194+
if json_output:
195+
print(json.dumps(sync_report.model_dump(mode="json"), indent=2, default=str))
196+
else:
197+
display_changes(project_name, "Status", sync_report, verbose)
198+
except (ValueError, ToolError) as e:
199+
if json_output:
200+
print(json.dumps({"error": str(e)}, indent=2))
201+
else:
202+
console.print(f"[red]Error: {e}[/red]")
193203
raise typer.Exit(code=1)
204+
except typer.Exit:
205+
raise
194206
except Exception as e:
195207
logger.error(f"Error checking status: {e}")
196-
typer.echo(f"Error checking status: {e}", err=True)
208+
if json_output:
209+
print(json.dumps({"error": str(e)}, indent=2))
210+
else:
211+
typer.echo(f"Error checking status: {e}", err=True)
197212
raise typer.Exit(code=1) # pragma: no cover

0 commit comments

Comments
 (0)