Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ One project failing does not block others. Check the `errors` array:
- **Branch scope**: when active branch is set, MCP tools and config commands automatically scope to that branch.
`branch_id` is a **CLI flag** (`--branch`), NOT a tool input parameter -- do not pass it inside `--input`.
Config read commands (`config list`, `config detail`, `config search`) also support `--branch`.
- **Storage read commands are the exception**: `storage buckets`, `storage bucket-detail`,
`storage tables`, `storage table-detail`, and `storage files` **ignore the implicit active
dev branch** and query production by default. The Storage API branch-scoped endpoint only
returns resources locally modified in the dev branch (empty for a fresh branch), so
auto-scoping would surprise users with "No tables found". Explicit `--branch ID` still
works. Storage **write** commands (create-*, upload-*, delete-*, file-*) stay branch-aware.
- **Schema discovery**: use `kbagent --json tool list` to inspect each tool's `inputSchema` and find
accepted parameters. For example, `get_configs` takes `configs` (a list of `{component_id, configuration_id}`
objects), not a flat `config_id` string.
Expand Down
26 changes: 26 additions & 0 deletions src/keboola_agent_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ def resolve_branch(
formatter: OutputFormatter,
project: str | None,
branch: int | None,
*,
ignore_active_branch: bool = False,
) -> tuple[str | None, int | None]:
"""Resolve the effective branch and project.

Expand All @@ -192,6 +194,14 @@ def resolve_branch(
formatter: Output formatter for info messages.
project: Explicit --project alias or None.
branch: Explicit --branch integer or None.
ignore_active_branch: When True, the implicit active_branch_id from
config is ignored and the production endpoint (branch_id=None) is
used unless --branch was passed explicitly. An info message is
printed so the user can see the active dev branch was skipped.
Intended for storage READ commands -- the Storage API
branch-scoped endpoint returns only locally-modified resources,
which for a freshly created dev branch is an empty set. Explicit
--branch still overrides.

Returns:
Tuple of (effective_project, effective_branch_id).
Expand All @@ -202,6 +212,14 @@ def resolve_branch(
if project is not None:
proj_config = config_store.get_project(project)
if proj_config and proj_config.active_branch_id is not None:
if ignore_active_branch:
if not formatter.json_mode:
formatter.err_console.print(
f"[bold blue]Info:[/bold blue] Using production branch for read "
f"(active dev branch '{proj_config.active_branch_id}' ignored; "
f"pass --branch {proj_config.active_branch_id} to override)"
)
return project, None
if not formatter.json_mode:
formatter.err_console.print(
f"[bold blue]Info:[/bold blue] Using active branch "
Expand All @@ -217,6 +235,14 @@ def resolve_branch(
]
if len(active_projects) == 1:
alias, proj = active_projects[0]
if ignore_active_branch:
if not formatter.json_mode:
formatter.err_console.print(
f"[bold blue]Info:[/bold blue] Using production branch for read "
f"(active dev branch '{proj.active_branch_id}' on project '{alias}' "
f"ignored; pass --branch {proj.active_branch_id} to override)"
)
return alias, None
if not formatter.json_mode:
formatter.err_console.print(
f"[bold blue]Info:[/bold blue] Using active branch "
Expand Down
24 changes: 19 additions & 5 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,17 +155,30 @@

### Storage

Note on branches: storage READ commands (buckets, bucket-detail, tables,
table-detail, files, file-detail) use the production endpoint by default,
even when a dev branch is active via `branch use`. The Storage API
branch-scoped endpoint returns only resources that were locally modified
in the dev branch, so a freshly-created branch lists nothing. Pass
`--branch ID` explicitly to query dev-branch-local tables/buckets.
Storage WRITE commands (create-*, upload-*, delete-*, file-upload, etc.)
remain branch-aware because modifying a dev branch is the expected intent.

kbagent storage buckets [--project NAME] [--branch ID]
List buckets with sharing/linked info. Shows source project for linked buckets. Branch-aware.
List buckets with sharing/linked info. Shows source project for linked buckets.
Uses production by default; pass --branch to query a dev branch explicitly.

kbagent storage bucket-detail --project NAME --bucket-id BUCKET_ID [--branch ID]
Bucket detail with Snowflake direct access paths. Resolves linked bucket source DB. Branch-aware.
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. Branch-aware.
List storage tables, optionally filtered by bucket.
Uses production by default; pass --branch to query a dev branch explicitly.

kbagent storage table-detail --project NAME --table-id TABLE_ID [--branch ID]
Show detailed table info: columns (with types if available), primary key, row count, size, last import date. Branch-aware.
Show detailed table info: columns (with types if available), primary key, row count, size, last import date.
Uses production by default; pass --branch to query a dev branch explicitly.

kbagent storage create-bucket --project NAME --stage STAGE --name BUCKET_NAME [--description D] [--backend B] [--branch ID]
Create a new storage bucket. Stage must be "in" or "out". Branch-aware.
Expand Down Expand Up @@ -196,7 +209,8 @@
### Storage Files

kbagent storage files --project NAME [--tag TAG ...] [--limit N] [--offset N] [--query Q] [--branch ID]
List Storage Files. --tag filters by tags (AND logic, repeat for multiple). --query for full-text search on name. Branch-aware.
List Storage Files. --tag filters by tags (AND logic, repeat for multiple). --query for full-text search on name.
Uses production by default; pass --branch to query a dev branch explicitly.

kbagent storage file-upload --project NAME --file PATH [--name NAME] [--tag TAG ...] [--permanent] [--branch ID]
Upload any file to Storage Files. --tag assigns tags (repeatable). --permanent prevents auto-deletion after 15 days.
Expand Down
45 changes: 38 additions & 7 deletions src/keboola_agent_cli/commands/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ def storage_buckets(
Shows which buckets are linked from other projects, including the
source project ID and name. This information is not available via
MCP tools.

Branch handling: this read command uses the production endpoint by
default, even when a dev branch is active via `branch use`. The
Storage API branch-scoped endpoint only returns locally-modified
buckets, so a fresh dev branch lists nothing. Pass --branch to query
a dev branch explicitly.
"""
if should_hint(ctx):
emit_hint(ctx, "storage.buckets", project=project, branch=branch)
Expand All @@ -69,10 +75,16 @@ def storage_buckets(
)
raise typer.Exit(code=2)

# Resolve active branch for single-project queries
# Resolve active branch for single-project queries.
# Storage read commands ignore the implicit active dev branch: the
# Storage API branch-scoped endpoint returns only locally-modified
# buckets, which for a freshly created dev branch is an empty set.
# Explicit --branch still wins.
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)
_, effective_branch = resolve_branch(
config_store, formatter, project[0], None, ignore_active_branch=True
)

try:
result = service.list_buckets(aliases=project, branch_id=effective_branch)
Expand Down Expand Up @@ -151,7 +163,10 @@ def storage_bucket_detail(
formatter = get_formatter(ctx)
service = get_service(ctx, "storage_service")
config_store: ConfigStore = ctx.obj["config_store"]
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
# Read command: ignore implicit active dev branch (empty listing trap).
_, effective_branch = resolve_branch(
config_store, formatter, project, branch, ignore_active_branch=True
)

try:
result = service.get_bucket_detail(
Expand Down Expand Up @@ -227,14 +242,24 @@ 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 a project.

Branch handling: this read command uses the production endpoint by
default, even when a dev branch is active via `branch use`. The
Storage API branch-scoped endpoint only returns tables that were
locally modified in the dev branch, so a fresh dev branch lists
nothing. Pass --branch to query a dev branch explicitly.
"""
if should_hint(ctx):
emit_hint(ctx, "storage.tables", project=project, bucket_id=bucket_id, branch=branch)

formatter = get_formatter(ctx)
service = get_service(ctx, "storage_service")
config_store: ConfigStore = ctx.obj["config_store"]
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
# Read command: ignore implicit active dev branch (empty listing trap).
_, effective_branch = resolve_branch(
config_store, formatter, project, branch, ignore_active_branch=True
)

try:
result = service.list_tables(
Expand Down Expand Up @@ -306,7 +331,10 @@ def storage_table_detail(
formatter = get_formatter(ctx)
service = get_service(ctx, "storage_service")
config_store: ConfigStore = ctx.obj["config_store"]
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
# Read command: ignore implicit active dev branch (empty listing trap).
_, effective_branch = resolve_branch(
config_store, formatter, project, branch, ignore_active_branch=True
)

try:
result = service.get_table_detail(
Expand Down Expand Up @@ -1152,7 +1180,10 @@ def storage_file_list(
formatter = get_formatter(ctx)
service = get_service(ctx, "storage_service")
config_store: ConfigStore = ctx.obj["config_store"]
_, effective_branch = resolve_branch(config_store, formatter, project, branch)
# Read command: ignore implicit active dev branch (empty listing trap).
_, effective_branch = resolve_branch(
config_store, formatter, project, branch, ignore_active_branch=True
)

try:
result = service.list_files(
Expand Down
145 changes: 145 additions & 0 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,148 @@ def test_resolve_branch_no_branch_returns_none(self, tmp_config_dir) -> None:
project, branch_id = resolve_branch(store, formatter, "prod", None)
assert project == "prod"
assert branch_id is None

def test_ignore_active_branch_returns_none_when_active_set(self, tmp_config_dir) -> None:
"""With ignore_active_branch=True, implicit active_branch_id is skipped.

Used by storage read commands so users with an active dev branch
still see production tables/buckets by default.
"""
from unittest.mock import MagicMock

from keboola_agent_cli.commands._helpers import resolve_branch
from keboola_agent_cli.config_store import ConfigStore
from keboola_agent_cli.models import ProjectConfig

store = ConfigStore(config_dir=tmp_config_dir)
store.add_project(
"prod",
ProjectConfig(
stack_url="https://connection.keboola.com",
token="tok-123",
active_branch_id=15931,
),
)

formatter = MagicMock(json_mode=False)
formatter.err_console = MagicMock()

project, branch_id = resolve_branch(
store, formatter, "prod", None, ignore_active_branch=True
)
assert project == "prod"
assert branch_id is None
# User must be told production is being used despite active dev branch.
formatter.err_console.print.assert_called_once()
msg = formatter.err_console.print.call_args.args[0]
assert "production" in msg.lower()
assert "15931" in msg

def test_ignore_active_branch_does_not_override_explicit_branch(self, tmp_config_dir) -> None:
"""Explicit --branch wins even when ignore_active_branch=True."""
from unittest.mock import MagicMock

from keboola_agent_cli.commands._helpers import resolve_branch
from keboola_agent_cli.config_store import ConfigStore
from keboola_agent_cli.models import ProjectConfig

store = ConfigStore(config_dir=tmp_config_dir)
store.add_project(
"prod",
ProjectConfig(
stack_url="https://connection.keboola.com",
token="tok-123",
active_branch_id=15931,
),
)

formatter = MagicMock(json_mode=False)
formatter.err_console = MagicMock()

project, branch_id = resolve_branch(store, formatter, "prod", 99, ignore_active_branch=True)
assert project == "prod"
assert branch_id == 99

def test_ignore_active_branch_no_config_returns_none(self, tmp_config_dir) -> None:
"""With ignore_active_branch=True and no active branch, still returns None."""
from unittest.mock import MagicMock

from keboola_agent_cli.commands._helpers import resolve_branch
from keboola_agent_cli.config_store import ConfigStore
from keboola_agent_cli.models import ProjectConfig

store = ConfigStore(config_dir=tmp_config_dir)
store.add_project(
"prod",
ProjectConfig(
stack_url="https://connection.keboola.com",
token="tok-123",
),
)

formatter = MagicMock(json_mode=False)
formatter.err_console = MagicMock()

project, branch_id = resolve_branch(
store, formatter, "prod", None, ignore_active_branch=True
)
assert project == "prod"
assert branch_id is None
# No info message needed -- there was no active branch to ignore.
formatter.err_console.print.assert_not_called()

def test_ignore_active_branch_json_mode_silent(self, tmp_config_dir) -> None:
"""In --json mode, ignore_active_branch still works but prints nothing."""
from unittest.mock import MagicMock

from keboola_agent_cli.commands._helpers import resolve_branch
from keboola_agent_cli.config_store import ConfigStore
from keboola_agent_cli.models import ProjectConfig

store = ConfigStore(config_dir=tmp_config_dir)
store.add_project(
"prod",
ProjectConfig(
stack_url="https://connection.keboola.com",
token="tok-123",
active_branch_id=15931,
),
)

formatter = MagicMock(json_mode=True)
formatter.err_console = MagicMock()

project, branch_id = resolve_branch(
store, formatter, "prod", None, ignore_active_branch=True
)
assert project == "prod"
assert branch_id is None
formatter.err_console.print.assert_not_called()

def test_ignore_active_branch_single_project_inferred(self, tmp_config_dir) -> None:
"""Without --project, if a single project has an active branch and
ignore_active_branch=True, the project is still returned but branch_id is None.
"""
from unittest.mock import MagicMock

from keboola_agent_cli.commands._helpers import resolve_branch
from keboola_agent_cli.config_store import ConfigStore
from keboola_agent_cli.models import ProjectConfig

store = ConfigStore(config_dir=tmp_config_dir)
store.add_project(
"prod",
ProjectConfig(
stack_url="https://connection.keboola.com",
token="tok-123",
active_branch_id=15931,
),
)

formatter = MagicMock(json_mode=False)
formatter.err_console = MagicMock()

project, branch_id = resolve_branch(store, formatter, None, None, ignore_active_branch=True)
assert project == "prod"
assert branch_id is None
formatter.err_console.print.assert_called_once()
Loading