diff --git a/docs/releases/v0.19.0.md b/docs/releases/v0.19.0.md index 772ac74f..f372ceca 100644 --- a/docs/releases/v0.19.0.md +++ b/docs/releases/v0.19.0.md @@ -179,6 +179,19 @@ Docker-internal paths that don't exist locally. All `bm tool` subcommands support `--format json` for machine-readable output, enabling integration with scripts and plugins. +### `--json` for Top-Level CLI Commands + +Five additional CLI commands now support `--json` for machine-readable output: + +- `bm status --json` — sync report with new/modified/deleted/moved files and skipped files +- `bm project list --json` — structured project list with name, paths, routing mode, and defaults +- `bm schema validate --json` — validation report with per-note pass/fail, warnings, and errors +- `bm schema infer --json` — field frequency analysis and suggested schema definition +- `bm schema diff --json` — drift report with new fields, dropped fields, and cardinality changes + +This complements the existing `bm project info --json` and `bm tool --format json` support, +making all major CLI commands scriptable for CI pipelines and automation. + ### Cloud Promo and Analytics - Cloud promo panel shown on first run or version bump with OSS discount code diff --git a/src/basic_memory/cli/commands/project.py b/src/basic_memory/cli/commands/project.py index 8657f1de..93a697a8 100644 --- a/src/basic_memory/cli/commands/project.py +++ b/src/basic_memory/cli/commands/project.py @@ -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: diff --git a/src/basic_memory/cli/commands/schema.py b/src/basic_memory/cli/commands/schema.py index 876c6885..8bb1d595 100644 --- a/src/basic_memory/cli/commands/schema.py +++ b/src/basic_memory/cli/commands/schema.py @@ -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) diff --git a/src/basic_memory/cli/commands/status.py b/src/basic_memory/cli/commands/status.py index d9e7e1e9..bea66fd2 100644 --- a/src/basic_memory/cli/commands/status.py +++ b/src/basic_memory/cli/commands/status.py @@ -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 diff --git a/tests/cli/test_json_output.py b/tests/cli/test_json_output.py new file mode 100644 index 00000000..0928c27a --- /dev/null +++ b/tests/cli/test_json_output.py @@ -0,0 +1,418 @@ +"""Tests for --json output across CLI commands. + +Each test verifies: +- Exit code 0 (or 1 for strict mode) +- Output is valid json.loads()-able +- Expected keys present in the parsed data +""" + +import json +from contextlib import asynccontextmanager +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from basic_memory.cli.main import app as cli_app +from basic_memory.mcp.clients.project import ProjectClient +from basic_memory.schemas.project_info import ProjectList +from basic_memory.schemas.sync_report import SyncReportResponse + +# Importing registers subcommands on the shared app instance. +import basic_memory.cli.commands.project as project_cmd # noqa: F401 + +runner = CliRunner() + + +def _parse_json_output(output: str) -> dict: + """Extract and parse the JSON object from CLI output. + + The CliRunner may capture log lines before the JSON payload. + We find the first '{' and parse from there. + """ + start = output.index("{") + return json.loads(output[start:]) + + +# --------------------------------------------------------------------------- +# Shared mock helpers +# --------------------------------------------------------------------------- + + +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 + + +SYNC_REPORT_WITH_CHANGES = SyncReportResponse( + new={"notes/new-file.md"}, + modified={"notes/existing.md"}, + deleted={"notes/old.md"}, + moves={"notes/moved-from.md": "notes/moved-to.md"}, + checksums={"notes/new-file.md": "abc12345", "notes/existing.md": "def67890"}, + skipped_files=[], + total=4, +) + +SYNC_REPORT_EMPTY = SyncReportResponse( + new=set(), + modified=set(), + deleted=set(), + moves={}, + checksums={}, + skipped_files=[], + total=0, +) + +SYNC_REPORT_WITH_SKIPPED = SyncReportResponse( + new=set(), + modified=set(), + deleted=set(), + moves={}, + checksums={}, + skipped_files=[ + { + "path": "bad/file.md", + "reason": "parse error", + "failure_count": 3, + "first_failed": datetime(2025, 6, 15, 12, 0, 0), + } + ], + total=0, +) + +VALIDATE_REPORT = { + "note_type": "person", + "total_notes": 2, + "total_entities": 2, + "valid_count": 1, + "warning_count": 1, + "error_count": 1, + "results": [ + { + "note_identifier": "people/alice", + "schema_entity": "person", + "passed": True, + "warnings": [], + "errors": [], + }, + { + "note_identifier": "people/bob", + "schema_entity": "person", + "passed": False, + "warnings": ["Missing optional field: role"], + "errors": ["Missing required field: name"], + }, + ], +} + +INFER_REPORT = { + "note_type": "person", + "notes_analyzed": 5, + "field_frequencies": [ + {"name": "name", "source": "observation", "count": 5, "total": 5, "percentage": 1.0}, + {"name": "role", "source": "observation", "count": 3, "total": 5, "percentage": 0.6}, + ], + "suggested_schema": {"name": "string, full name", "role?": "string, job title"}, + "suggested_required": ["name"], + "suggested_optional": ["role"], + "excluded": [], +} + +DIFF_REPORT_WITH_DRIFT = { + "note_type": "person", + "schema_found": True, + "new_fields": [ + {"name": "email", "source": "observation", "count": 3, "total": 5, "percentage": 0.6} + ], + "dropped_fields": [ + {"name": "phone", "source": "observation", "count": 0, "total": 5, "percentage": 0.0} + ], + "cardinality_changes": ["role: single -> array"], +} + + +# --------------------------------------------------------------------------- +# Status --json +# --------------------------------------------------------------------------- + +_MOCK_PROJECT_ITEM = MagicMock() +_MOCK_PROJECT_ITEM.name = "test-project" +_MOCK_PROJECT_ITEM.external_id = "11111111-1111-1111-1111-111111111111" + + +@patch("basic_memory.cli.commands.status.ConfigManager") +@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock) +@patch("basic_memory.cli.commands.status.get_client") +def test_status_json_outputs_sync_report(mock_get_client, mock_get_active, mock_config_cls): + """bm status --json outputs a valid JSON sync report with changes.""" + mock_config_cls.return_value = _mock_config_manager() + mock_get_active.return_value = _MOCK_PROJECT_ITEM + + mock_project_client = AsyncMock() + mock_project_client.get_status.return_value = SYNC_REPORT_WITH_CHANGES + + @asynccontextmanager + async def fake_get_client(project_name=None): + yield MagicMock() + + mock_get_client.side_effect = fake_get_client + + with patch.object(ProjectClient, "get_status", mock_project_client.get_status): + result = runner.invoke(cli_app, ["status", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = _parse_json_output(result.output) + assert data["total"] == 4 + assert "new" in data + assert "modified" in data + assert "deleted" in data + assert "moves" in data + + +@patch("basic_memory.cli.commands.status.ConfigManager") +@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock) +@patch("basic_memory.cli.commands.status.get_client") +def test_status_json_no_changes(mock_get_client, mock_get_active, mock_config_cls): + """bm status --json with empty report outputs total: 0.""" + mock_config_cls.return_value = _mock_config_manager() + mock_get_active.return_value = _MOCK_PROJECT_ITEM + + mock_project_client = AsyncMock() + mock_project_client.get_status.return_value = SYNC_REPORT_EMPTY + + @asynccontextmanager + async def fake_get_client(project_name=None): + yield MagicMock() + + mock_get_client.side_effect = fake_get_client + + with patch.object(ProjectClient, "get_status", mock_project_client.get_status): + result = runner.invoke(cli_app, ["status", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = _parse_json_output(result.output) + assert data["total"] == 0 + assert data["new"] == [] + assert data["modified"] == [] + + +@patch("basic_memory.cli.commands.status.ConfigManager") +@patch("basic_memory.cli.commands.status.get_active_project", new_callable=AsyncMock) +@patch("basic_memory.cli.commands.status.get_client") +def test_status_json_with_skipped_files(mock_get_client, mock_get_active, mock_config_cls): + """bm status --json serializes skipped_files with datetime fields.""" + mock_config_cls.return_value = _mock_config_manager() + mock_get_active.return_value = _MOCK_PROJECT_ITEM + + mock_project_client = AsyncMock() + mock_project_client.get_status.return_value = SYNC_REPORT_WITH_SKIPPED + + @asynccontextmanager + async def fake_get_client(project_name=None): + yield MagicMock() + + mock_get_client.side_effect = fake_get_client + + with patch.object(ProjectClient, "get_status", mock_project_client.get_status): + result = runner.invoke(cli_app, ["status", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = _parse_json_output(result.output) + assert len(data["skipped_files"]) == 1 + assert data["skipped_files"][0]["path"] == "bad/file.md" + # datetime should be serialized as ISO string via mode="json" + assert "2025-06-15" in data["skipped_files"][0]["first_failed"] + + +# --------------------------------------------------------------------------- +# Schema validate --json +# --------------------------------------------------------------------------- + + +@patch("basic_memory.cli.commands.schema.ConfigManager") +@patch( + "basic_memory.cli.commands.schema.mcp_schema_validate", + new_callable=AsyncMock, + return_value=VALIDATE_REPORT, +) +def test_schema_validate_json(mock_mcp, mock_config_cls): + """bm schema validate person --json outputs the validation report as JSON.""" + mock_config_cls.return_value = _mock_config_manager() + + result = runner.invoke(cli_app, ["schema", "validate", "person", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = _parse_json_output(result.output) + assert data["note_type"] == "person" + assert data["total_notes"] == 2 + assert len(data["results"]) == 2 + + +@patch("basic_memory.cli.commands.schema.ConfigManager") +@patch( + "basic_memory.cli.commands.schema.mcp_schema_validate", + new_callable=AsyncMock, + return_value={"error": "No schema found for type 'person'"}, +) +def test_schema_validate_json_error(mock_mcp, mock_config_cls): + """bm schema validate --json with error dict outputs the error as JSON.""" + mock_config_cls.return_value = _mock_config_manager() + + result = runner.invoke(cli_app, ["schema", "validate", "person", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = _parse_json_output(result.output) + assert "error" in data + + +@patch("basic_memory.cli.commands.schema.ConfigManager") +@patch( + "basic_memory.cli.commands.schema.mcp_schema_validate", + new_callable=AsyncMock, + return_value=VALIDATE_REPORT, +) +def test_schema_validate_json_strict_exit(mock_mcp, mock_config_cls): + """bm schema validate --json --strict exits 1 when errors present.""" + mock_config_cls.return_value = _mock_config_manager() + + result = runner.invoke(cli_app, ["schema", "validate", "person", "--json", "--strict"]) + + assert result.exit_code == 1 + # JSON should still be valid in stdout + data = _parse_json_output(result.output) + assert data["error_count"] == 1 + + +# --------------------------------------------------------------------------- +# Schema infer --json +# --------------------------------------------------------------------------- + + +@patch("basic_memory.cli.commands.schema.ConfigManager") +@patch( + "basic_memory.cli.commands.schema.mcp_schema_infer", + new_callable=AsyncMock, + return_value=INFER_REPORT, +) +def test_schema_infer_json(mock_mcp, mock_config_cls): + """bm schema infer person --json outputs the inference report as JSON.""" + mock_config_cls.return_value = _mock_config_manager() + + result = runner.invoke(cli_app, ["schema", "infer", "person", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = _parse_json_output(result.output) + assert data["note_type"] == "person" + assert data["notes_analyzed"] == 5 + assert "suggested_schema" in data + + +# --------------------------------------------------------------------------- +# Schema diff --json +# --------------------------------------------------------------------------- + + +@patch("basic_memory.cli.commands.schema.ConfigManager") +@patch( + "basic_memory.cli.commands.schema.mcp_schema_diff", + new_callable=AsyncMock, + return_value=DIFF_REPORT_WITH_DRIFT, +) +def test_schema_diff_json(mock_mcp, mock_config_cls): + """bm schema diff person --json outputs the drift report as JSON.""" + mock_config_cls.return_value = _mock_config_manager() + + result = runner.invoke(cli_app, ["schema", "diff", "person", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = _parse_json_output(result.output) + assert data["note_type"] == "person" + assert len(data["new_fields"]) == 1 + assert len(data["dropped_fields"]) == 1 + + +# --------------------------------------------------------------------------- +# Project list --json +# --------------------------------------------------------------------------- + + +@pytest.fixture +def write_config(tmp_path, monkeypatch): + """Write config.json under a temporary HOME and return the file path.""" + + def _write(config_data: dict): + from basic_memory import config as config_module + + config_module._CONFIG_CACHE = None + + config_dir = tmp_path / ".basic-memory" + config_dir.mkdir(parents=True, exist_ok=True) + config_file = config_dir / "config.json" + config_file.write_text(json.dumps(config_data, indent=2)) + monkeypatch.setenv("HOME", str(tmp_path)) + return config_file + + return _write + + +@pytest.fixture +def mock_client(monkeypatch): + """Mock get_client with a no-op async context manager.""" + + @asynccontextmanager + async def fake_get_client(workspace=None): + yield object() + + monkeypatch.setattr(project_cmd, "get_client", fake_get_client) + + +def test_project_list_json_outputs_projects(write_config, mock_client, tmp_path, monkeypatch): + """project list --json --local outputs structured JSON with project data.""" + alpha_local = (tmp_path / "alpha-local").as_posix() + + write_config( + { + "env": "dev", + "projects": { + "alpha": {"path": alpha_local, "mode": "local"}, + }, + "default_project": "alpha", + } + ) + + local_payload = { + "projects": [ + { + "id": 1, + "external_id": "11111111-1111-1111-1111-111111111111", + "name": "alpha", + "path": alpha_local, + "is_default": True, + } + ], + "default_project": "alpha", + } + + async def fake_list_projects(self): + return ProjectList.model_validate(local_payload) + + monkeypatch.setattr(ProjectClient, "list_projects", fake_list_projects) + + result = runner.invoke(cli_app, ["project", "list", "--json", "--local"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + data = _parse_json_output(result.output) + assert "projects" in data + assert len(data["projects"]) == 1 + proj = data["projects"][0] + assert proj["name"] == "alpha" + assert proj["is_default"] is True + assert "local_path" in proj + assert "cli_route" in proj + assert "mcp_stdio" in proj diff --git a/tests/repository/test_sqlite_vector_search_repository.py b/tests/repository/test_sqlite_vector_search_repository.py index 37c7c20d..c6c9c71b 100644 --- a/tests/repository/test_sqlite_vector_search_repository.py +++ b/tests/repository/test_sqlite_vector_search_repository.py @@ -1,8 +1,7 @@ """SQLite sqlite-vec search repository tests.""" -import json from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest from sqlalchemy import text @@ -284,7 +283,6 @@ async def test_run_vector_query_caps_k_at_sqlite_vec_limit(search_repository): # Track the parameters passed to session.execute captured_params: list[dict] = [] - original_execute = None async def capturing_execute(stmt, params=None): if params and "vector_k" in params: @@ -296,7 +294,6 @@ async def test_run_vector_query_caps_k_at_sqlite_vec_limit(search_repository): async with db.scoped_session(search_repository.session_maker) as session: await search_repository._prepare_vector_session(session) - original_execute = session.execute session.execute = capturing_execute query_embedding = [0.1] * search_repository._vector_dimensions