diff --git a/CLAUDE.md b/CLAUDE.md index 9d3103b0..5db8defb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,7 +261,7 @@ kbagent job terminate --project NAME (--job-id ID [--job-id ID ...] | --status a kbagent storage buckets [--project NAME] [--branch ID] kbagent storage bucket-detail --project NAME --bucket-id ID [--branch ID] -kbagent storage tables --project NAME [--bucket-id ID] [--branch ID] +kbagent storage tables [--project NAME ...] [--bucket-id ID] [--branch ID] kbagent storage table-detail --project NAME --table-id ID [--branch ID] kbagent storage create-bucket --project NAME --stage STAGE --name NAME [--description D] [--backend B] [--branch ID] kbagent storage create-table --project NAME --bucket-id ID --name NAME --column COL:TYPE [...] [--primary-key COL] [--branch ID] diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 3050395d..06540e47 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -102,7 +102,7 @@ When working inside a git repository or project directory, run `kbagent init` (o | Terminate one or more Queue API jobs (use to stop runaway or stuck jobs) | `kbagent job terminate --project PROJECT` | | List storage buckets with sharing/linked bucket information | `kbagent storage buckets` | | Show detailed bucket info including Snowflake direct access paths | `kbagent storage bucket-detail --project PROJECT --bucket-id BUCKET-ID` | -| List storage tables from a project | `kbagent storage tables --project PROJECT` | +| List storage tables from one or more projects | `kbagent storage tables` | | Show detailed table info including columns and types | `kbagent storage table-detail --project PROJECT --table-id TABLE-ID` | | Create a new storage bucket | `kbagent storage create-bucket --project PROJECT --stage STAGE --name NAME` | | Create a new storage table with typed columns | `kbagent storage create-table --project PROJECT --bucket-id BUCKET-ID --name NAME --column COLUMN` | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 06ed9679..7217f944 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -48,7 +48,7 @@ All commands support `--json` for structured output. Multi-project flags (`--pro ## Storage - `storage buckets [--project NAME] [--branch ID]` -- list buckets with sharing/linked info (branch-aware) - `storage bucket-detail --project NAME --bucket-id ID [--branch ID]` -- bucket detail with Snowflake paths (branch-aware) -- `storage tables --project NAME [--bucket-id ID] [--branch ID]` -- list tables, optionally by bucket (branch-aware) +- `storage tables [--project NAME ...] [--bucket-id ID] [--branch ID]` -- list tables across all connected projects in parallel (multi-project by default, same as `storage buckets`); repeat `--project` to target a subset; `--bucket-id` is applied independently per project (missing buckets become per-project errors); `--branch` requires exactly one `--project` - `storage table-detail --project NAME --table-id ID [--branch ID]` -- table detail with columns, types, primary key, row count (branch-aware) - `storage create-bucket --project NAME --stage STAGE --name NAME [--description D] [--backend B] [--branch ID]` -- create bucket (branch-aware) - `storage create-table --project NAME --bucket-id ID --name NAME --column COL:TYPE [...] [--primary-key COL] [--branch ID]` -- create typed table (branch-aware) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index f388e9e5..1b1eea7a 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -172,8 +172,14 @@ Bucket detail with Snowflake direct access paths. Resolves linked bucket source DB. Uses production by default; pass --branch to query a dev branch explicitly. - kbagent storage tables --project NAME [--bucket-id BUCKET_ID] [--branch ID] - List storage tables, optionally filtered by bucket. + kbagent storage tables [--project NAME ...] [--bucket-id BUCKET_ID] [--branch ID] + List storage tables from one or more projects (in parallel). Omit --project + to query all connected projects. Repeat --project for a specific subset. + Multi-project by default, matching `storage buckets`, `config list`, `job list`. + Each row is tagged with project_alias; per-project errors accumulate in the + response envelope. --branch is only valid with a single --project. + --bucket-id is applied independently per project; missing buckets are + reported as per-project errors, not fatal. Uses production by default; pass --branch to query a dev branch explicitly. kbagent storage table-detail --project NAME --table-id TABLE_ID [--branch ID] diff --git a/src/keboola_agent_cli/commands/storage.py b/src/keboola_agent_cli/commands/storage.py index adcdc171..4baca3f2 100644 --- a/src/keboola_agent_cli/commands/storage.py +++ b/src/keboola_agent_cli/commands/storage.py @@ -226,15 +226,16 @@ def storage_bucket_detail( @storage_app.command("tables", rich_help_panel=_TABLES) def storage_tables( ctx: typer.Context, - project: str = typer.Option( - ..., + project: list[str] | None = typer.Option( + None, "--project", - help="Project alias", + help="Project alias (can be repeated for multiple projects). " + "Omit to query all connected projects in parallel.", ), bucket_id: str | None = typer.Option( None, "--bucket-id", - help="Filter tables by bucket ID", + help="Filter tables by bucket ID (applied independently per project)", ), branch: int | None = typer.Option( None, @@ -242,7 +243,12 @@ def storage_tables( help="Dev branch ID (defaults to active branch if set via 'branch use')", ), ) -> None: - """List storage tables from a project. + """List storage tables from one or more projects. + + Queries all connected projects in parallel by default, matching the + behaviour of ``storage buckets``, ``config list``, ``job list``, and other + read commands. Each row in the output is tagged with ``project_alias`` + so results from multiple projects can be distinguished. Branch handling: this read command uses the production endpoint by default, even when a dev branch is active via `branch use`. The @@ -256,14 +262,30 @@ def storage_tables( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - # Read command: ignore implicit active dev branch (empty listing trap). - _, effective_branch = resolve_branch( - config_store, formatter, project, branch, ignore_active_branch=True - ) + + # --branch requires exactly one --project (branch ID is per-project). + # Mirrors the validation used by `storage buckets` and `config list`. + if branch is not None and (not project or len(project) != 1): + formatter.error( + message="--branch requires exactly one --project (branch ID is per-project)", + error_code="INVALID_ARGUMENT", + ) + raise typer.Exit(code=2) + + # Resolve active branch only for single-project queries; multi-project + # listing intentionally skips active-branch resolution because branches + # are per-project state. Read commands use ignore_active_branch=True: + # Storage API branch endpoint only returns locally modified tables, so + # auto-scoping to the active branch traps users into an empty listing. + effective_branch: int | None = branch + if branch is None and project and len(project) == 1: + _, effective_branch = resolve_branch( + config_store, formatter, project[0], None, ignore_active_branch=True + ) try: result = service.list_tables( - alias=project, + aliases=project, bucket_id=bucket_id, branch_id=effective_branch, ) @@ -282,27 +304,38 @@ def storage_tables( tables = result["tables"] if not tables: formatter.console.print("[dim]No tables found.[/dim]") + emit_project_warnings(formatter, result) return - table = Table(title=f"Tables - {result['project_alias']}") - table.add_column("Table ID", style="bold cyan") - table.add_column("Rows", justify="right") - table.add_column("Size", justify="right", style="dim") - table.add_column("Last Import", style="dim") - + # Group by project so multi-project output stays readable. + by_project: dict[str, list[dict]] = {} for t in tables: - size_mb = t["data_size_bytes"] / (1024 * 1024) if t["data_size_bytes"] else 0 - last_import = t.get("last_import_date", "") - if last_import and "T" in last_import: - last_import = last_import.split("T")[0] - table.add_row( - t["id"], - str(t["rows_count"]), - f"{size_mb:.1f} MB", - last_import, - ) + alias = t["project_alias"] + by_project.setdefault(alias, []).append(t) - formatter.console.print(table) + for alias, proj_tables in by_project.items(): + table = Table(title=f"Tables - {alias}") + table.add_column("Table ID", style="bold cyan") + table.add_column("Rows", justify="right") + table.add_column("Size", justify="right", style="dim") + table.add_column("Last Import", style="dim") + + for t in proj_tables: + size_mb = t["data_size_bytes"] / (1024 * 1024) if t["data_size_bytes"] else 0 + last_import = t.get("last_import_date", "") + if last_import and "T" in last_import: + last_import = last_import.split("T")[0] + table.add_row( + t["id"], + str(t["rows_count"]), + f"{size_mb:.1f} MB", + last_import, + ) + + formatter.console.print(table) + formatter.console.print() + + emit_project_warnings(formatter, result) @storage_app.command("table-detail", rich_help_panel=_TABLES) diff --git a/src/keboola_agent_cli/hints/definitions/storage.py b/src/keboola_agent_cli/hints/definitions/storage.py index 0e4400cb..96460f92 100644 --- a/src/keboola_agent_cli/hints/definitions/storage.py +++ b/src/keboola_agent_cli/hints/definitions/storage.py @@ -139,7 +139,7 @@ HintRegistry.register( CommandHint( cli_command="storage.tables", - description="List tables in a project", + description="List tables in one or more projects", steps=[ HintStep( comment="List tables", @@ -158,7 +158,7 @@ service_module="storage_service", method="list_tables", args={ - "alias": "{project}", + "aliases": "{project}", "bucket_id": "{bucket_id}", "branch_id": "{branch}", }, diff --git a/src/keboola_agent_cli/services/storage_service.py b/src/keboola_agent_cli/services/storage_service.py index 67a506f3..266015b0 100644 --- a/src/keboola_agent_cli/services/storage_service.py +++ b/src/keboola_agent_cli/services/storage_service.py @@ -291,47 +291,59 @@ def get_table_detail( def list_tables( self, - alias: str, + aliases: list[str] | None = None, bucket_id: str | None = None, branch_id: int | None = None, ) -> dict[str, Any]: - """List tables from a project, optionally filtered by bucket. + """List tables from one or more projects (in parallel). + + When ``bucket_id`` is specified with multiple projects, the filter is + applied independently in each project -- a missing bucket in a given + project is recorded as a per-project error without aborting the others. Args: - alias: Project alias. - bucket_id: Optional bucket ID filter. - branch_id: If set, target a specific dev branch. + aliases: Project aliases to query. If None, queries all. + bucket_id: Optional bucket ID filter applied per project. + branch_id: If set, target a specific dev branch + (only valid with a single project). Returns: - Dict with 'tables' list. + Dict with 'tables' list (each row tagged with ``project_alias``) + and 'errors' list (per-project failures). """ - projects = self.resolve_projects([alias]) - project = projects[alias] + projects = self.resolve_projects(aliases) - client = self._client_factory(project.stack_url, project.token) - try: - raw_tables = client.list_tables(bucket_id=bucket_id, branch_id=branch_id) - finally: - client.close() + def _worker(alias: str, project: ProjectConfig) -> tuple[str, list[dict[str, Any]], bool]: + return self._fetch_tables( + alias, + project, + bucket_id=bucket_id, + branch_id=branch_id, + ) - tables = [ - { - "project_alias": alias, - "id": t.get("id", ""), - "name": t.get("name", ""), - "display_name": t.get("displayName", t.get("name", "")), - "bucket_id": t.get("bucket", {}).get("id", "") - if isinstance(t.get("bucket"), dict) - else "", - "rows_count": t.get("rowsCount", 0), - "data_size_bytes": t.get("dataSizeBytes", 0), - "is_alias": t.get("isAlias", False), - "last_import_date": t.get("lastImportDate", ""), - } - for t in raw_tables - ] + successes, errors = self._run_parallel(projects, _worker) + + tables: list[dict[str, Any]] = [] + for result in successes: + alias = result[0] + for t in result[1]: + tables.append( + { + "project_alias": alias, + "id": t.get("id", ""), + "name": t.get("name", ""), + "display_name": t.get("displayName", t.get("name", "")), + "bucket_id": t.get("bucket", {}).get("id", "") + if isinstance(t.get("bucket"), dict) + else "", + "rows_count": t.get("rowsCount", 0), + "data_size_bytes": t.get("dataSizeBytes", 0), + "is_alias": t.get("isAlias", False), + "last_import_date": t.get("lastImportDate", ""), + } + ) - return {"tables": tables, "project_alias": alias} + return {"tables": tables, "errors": errors} # ------------------------------------------------------------------ # Write operations @@ -1473,3 +1485,33 @@ def _fetch_buckets( ) finally: client.close() + + def _fetch_tables( + self, + alias: str, + project: ProjectConfig, + bucket_id: str | None = None, + branch_id: int | None = None, + ) -> tuple[str, list[dict[str, Any]], bool]: + """Fetch tables for a single project (worker for _run_parallel). + + Per-project failures (e.g. bucket not found in this project, invalid + token) are returned as error tuples so other projects still complete. + """ + from ..errors import KeboolaApiError + + client = self._client_factory(project.stack_url, project.token) + try: + tables = client.list_tables(bucket_id=bucket_id, branch_id=branch_id) + return (alias, tables, True) + except KeboolaApiError as exc: + return ( + alias, + { + "project_alias": alias, + "error_code": exc.error_code, + "message": exc.message, + }, + ) + finally: + client.close() diff --git a/tests/test_storage_delete.py b/tests/test_storage_delete.py index 5b4c2bf0..a1d06ef7 100644 --- a/tests/test_storage_delete.py +++ b/tests/test_storage_delete.py @@ -884,7 +884,7 @@ def test_cli_branch_flag(self, tmp_path: Path) -> None: ): MockStore.return_value = store svc = MockSvc.return_value - svc.list_tables.return_value = {"tables": [], "project_alias": "test"} + svc.list_tables.return_value = {"tables": [], "errors": []} result = runner.invoke( app, [ @@ -900,6 +900,8 @@ def test_cli_branch_flag(self, tmp_path: Path) -> None: assert result.exit_code == 0 call_kwargs = svc.list_tables.call_args.kwargs assert call_kwargs["branch_id"] == 30 + # Multi-project CLI passes --project as a list + assert call_kwargs["aliases"] == ["test"] class TestDeleteColumnBranch: diff --git a/tests/test_storage_tables.py b/tests/test_storage_tables.py new file mode 100644 index 00000000..11c687bb --- /dev/null +++ b/tests/test_storage_tables.py @@ -0,0 +1,399 @@ +"""Tests for storage tables multi-project listing (issue #198). + +Covers: +- StorageService.list_tables() multi-project parallel execution +- CLI storage tables without --project (all projects) +- CLI storage tables with multiple --project flags +- Error accumulation across projects +- --branch/--project validation (branch requires single project) +- --bucket-id filter applied independently per project +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError, KeboolaApiError +from keboola_agent_cli.models import AppConfig, ProjectConfig +from keboola_agent_cli.services.storage_service import StorageService + +runner = CliRunner() + +TEST_TOKEN = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + + +def _make_multi_store(tmp_path: Path) -> ConfigStore: + """Config store with two projects ('p1' and 'p2').""" + config_dir = tmp_path / "config" + config_dir.mkdir(exist_ok=True) + store = ConfigStore(config_dir=config_dir) + config = AppConfig( + projects={ + "p1": ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + ), + "p2": ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + ), + }, + ) + store.save(config) + return store + + +def _make_single_store(tmp_path: Path) -> ConfigStore: + """Config store with a single project ('test').""" + config_dir = tmp_path / "config" + config_dir.mkdir(exist_ok=True) + store = ConfigStore(config_dir=config_dir) + config = AppConfig( + projects={ + "test": ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + ) + }, + ) + store.save(config) + return store + + +def _mk_table(table_id: str) -> dict: + """Helper to build a minimal raw API table dict.""" + return { + "id": table_id, + "name": table_id.split(".")[-1], + "displayName": table_id.split(".")[-1], + "bucket": {"id": ".".join(table_id.split(".")[:2])}, + "rowsCount": 100, + "dataSizeBytes": 1024, + "isAlias": False, + "lastImportDate": "2026-04-22T00:00:00+0000", + } + + +# ------------------------------------------------------------------ +# Service-layer tests +# ------------------------------------------------------------------ + + +class TestListTablesMultiProject: + """StorageService.list_tables() with multiple project aliases.""" + + def test_two_projects_aggregate_tables(self, tmp_path: Path) -> None: + """aliases=['p1','p2'] returns tables from both with project_alias set.""" + store = _make_multi_store(tmp_path) + + p1_client = MagicMock() + p1_client.list_tables.return_value = [_mk_table("in.c-a.t1")] + p2_client = MagicMock() + p2_client.list_tables.return_value = [_mk_table("in.c-b.t2")] + + # Route client_factory by token to return the right mock; both projects + # share the same token in the fixture, so use stack_url for routing + # would not work either. Instead, dispatch by call order using a + # rotating list -- _run_parallel spawns threads so ordering may vary, + # so we use a dict keyed by call count and assert set-wise below. + clients: dict[int, MagicMock] = {0: p1_client, 1: p2_client} + call_count = {"n": 0} + + def factory(url: str, token: str) -> MagicMock: + idx = call_count["n"] + call_count["n"] += 1 + return clients[idx] + + service = StorageService(config_store=store, client_factory=factory) + + result = service.list_tables(aliases=["p1", "p2"]) + + assert len(result["tables"]) == 2 + assert result["errors"] == [] + aliases_seen = {t["project_alias"] for t in result["tables"]} + assert aliases_seen == {"p1", "p2"} + ids_seen = {t["id"] for t in result["tables"]} + assert ids_seen == {"in.c-a.t1", "in.c-b.t2"} + + def test_aliases_none_queries_all_projects(self, tmp_path: Path) -> None: + """aliases=None resolves to all projects.""" + store = _make_multi_store(tmp_path) + + mock_client = MagicMock() + mock_client.list_tables.return_value = [_mk_table("in.c-b.t")] + + service = StorageService( + config_store=store, + client_factory=lambda _u, _t: mock_client, + ) + + result = service.list_tables(aliases=None) + + # Both p1 and p2 hit the client once each (same mock) + assert mock_client.list_tables.call_count == 2 + assert len(result["tables"]) == 2 + assert {t["project_alias"] for t in result["tables"]} == {"p1", "p2"} + + def test_error_accumulation_partial_success(self, tmp_path: Path) -> None: + """One project 404s; other succeeds. Errors list captures the failure.""" + store = _make_multi_store(tmp_path) + + good_client = MagicMock() + good_client.list_tables.return_value = [_mk_table("in.c-x.ok")] + + bad_client = MagicMock() + bad_client.list_tables.side_effect = KeboolaApiError( + message="Bucket not found", + error_code="NOT_FOUND", + status_code=404, + ) + + clients = [good_client, bad_client] + call_count = {"n": 0} + + def factory(url: str, token: str) -> MagicMock: + idx = call_count["n"] + call_count["n"] += 1 + return clients[idx] + + service = StorageService(config_store=store, client_factory=factory) + + result = service.list_tables(aliases=["p1", "p2"], bucket_id="in.c-missing") + + # Exactly one project returned tables, one produced an error + assert len(result["tables"]) == 1 + assert len(result["errors"]) == 1 + assert result["errors"][0]["error_code"] == "NOT_FOUND" + assert result["errors"][0]["project_alias"] in {"p1", "p2"} + + def test_bucket_id_filter_applied_per_project(self, tmp_path: Path) -> None: + """bucket_id is forwarded to each per-project client call.""" + store = _make_multi_store(tmp_path) + + mock_client = MagicMock() + mock_client.list_tables.return_value = [] + service = StorageService( + config_store=store, + client_factory=lambda _u, _t: mock_client, + ) + + service.list_tables(aliases=["p1", "p2"], bucket_id="in.c-shared") + + assert mock_client.list_tables.call_count == 2 + for call in mock_client.list_tables.call_args_list: + assert call.kwargs == {"bucket_id": "in.c-shared", "branch_id": None} + + def test_unknown_alias_raises_config_error(self, tmp_path: Path) -> None: + """Passing an unknown alias raises ConfigError (from resolve_projects).""" + store = _make_multi_store(tmp_path) + service = StorageService( + config_store=store, + client_factory=lambda _u, _t: MagicMock(), + ) + + with pytest.raises(ConfigError): + service.list_tables(aliases=["does-not-exist"]) + + +# ------------------------------------------------------------------ +# CLI-layer tests +# ------------------------------------------------------------------ + + +class TestStorageTablesCli: + """CLI tests for `kbagent storage tables`.""" + + def test_no_project_queries_all(self, tmp_path: Path) -> None: + """Omitting --project passes aliases=None to the service.""" + store = _make_multi_store(tmp_path) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + svc = MockSvc.return_value + svc.list_tables.return_value = {"tables": [], "errors": []} + result = runner.invoke(app, ["--json", "storage", "tables"]) + + assert result.exit_code == 0, result.output + call_kwargs = svc.list_tables.call_args.kwargs + # Typer delivers a list[str] | None -- when nothing is passed it's None + assert call_kwargs["aliases"] is None + + payload = json.loads(result.output) + assert payload["data"] == {"tables": [], "errors": []} + + def test_multi_project_flags(self, tmp_path: Path) -> None: + """Two --project flags are delivered as a list to the service.""" + store = _make_multi_store(tmp_path) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + svc = MockSvc.return_value + svc.list_tables.return_value = { + "tables": [ + { + "project_alias": "p1", + "id": "in.c-a.t1", + "name": "t1", + "display_name": "t1", + "bucket_id": "in.c-a", + "rows_count": 10, + "data_size_bytes": 100, + "is_alias": False, + "last_import_date": "", + }, + { + "project_alias": "p2", + "id": "in.c-b.t2", + "name": "t2", + "display_name": "t2", + "bucket_id": "in.c-b", + "rows_count": 20, + "data_size_bytes": 200, + "is_alias": False, + "last_import_date": "", + }, + ], + "errors": [], + } + result = runner.invoke( + app, + [ + "--json", + "storage", + "tables", + "--project", + "p1", + "--project", + "p2", + ], + ) + + assert result.exit_code == 0, result.output + call_kwargs = svc.list_tables.call_args.kwargs + assert call_kwargs["aliases"] == ["p1", "p2"] + payload = json.loads(result.output) + ids = {t["id"] for t in payload["data"]["tables"]} + assert ids == {"in.c-a.t1", "in.c-b.t2"} + + def test_single_project_with_bucket_id(self, tmp_path: Path) -> None: + """--project + --bucket-id narrows to a single project and forwards filter.""" + store = _make_single_store(tmp_path) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + svc = MockSvc.return_value + svc.list_tables.return_value = {"tables": [], "errors": []} + result = runner.invoke( + app, + [ + "--json", + "storage", + "tables", + "--project", + "test", + "--bucket-id", + "in.c-data", + ], + ) + + assert result.exit_code == 0 + call_kwargs = svc.list_tables.call_args.kwargs + assert call_kwargs["aliases"] == ["test"] + assert call_kwargs["bucket_id"] == "in.c-data" + + def test_multi_project_with_branch_rejected(self, tmp_path: Path) -> None: + """--branch with two --project flags fails with exit code 2.""" + store = _make_multi_store(tmp_path) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + MockSvc.return_value.list_tables.return_value = {"tables": [], "errors": []} + result = runner.invoke( + app, + [ + "--json", + "storage", + "tables", + "--project", + "p1", + "--project", + "p2", + "--branch", + "99", + ], + ) + + # Per CONTRIBUTING.md exit code 2 = usage/argument validation + assert result.exit_code == 2, result.output + assert "--branch requires exactly one --project" in result.output + + def test_branch_without_project_rejected(self, tmp_path: Path) -> None: + """--branch with no --project is also a usage error.""" + store = _make_multi_store(tmp_path) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + MockSvc.return_value.list_tables.return_value = {"tables": [], "errors": []} + result = runner.invoke( + app, + ["--json", "storage", "tables", "--branch", "99"], + ) + + assert result.exit_code == 2, result.output + + def test_errors_surface_in_json_output(self, tmp_path: Path) -> None: + """Per-project errors are preserved verbatim in JSON mode.""" + store = _make_multi_store(tmp_path) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + svc = MockSvc.return_value + svc.list_tables.return_value = { + "tables": [], + "errors": [ + { + "project_alias": "p1", + "error_code": "NOT_FOUND", + "message": "Bucket not found", + } + ], + } + result = runner.invoke(app, ["--json", "storage", "tables"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["data"]["errors"][0]["project_alias"] == "p1" + assert payload["data"]["errors"][0]["error_code"] == "NOT_FOUND" + + def test_unknown_project_exits_with_config_error(self, tmp_path: Path) -> None: + """Service ConfigError is mapped to exit code 5.""" + store = _make_multi_store(tmp_path) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.StorageService") as MockSvc, + ): + MockStore.return_value = store + MockSvc.return_value.list_tables.side_effect = ConfigError("Project 'ghost' not found.") + result = runner.invoke( + app, + ["--json", "storage", "tables", "--project", "ghost"], + ) + + assert result.exit_code == 5