Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "kbagent",
"version": "0.31.0",
"version": "0.31.1",
"source": "./plugins/kbagent",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"category": "development"
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kbagent",
"version": "0.31.0",
"version": "0.31.1",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"author": {
"name": "Keboola",
Expand Down
2 changes: 2 additions & 0 deletions plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ a critical failure.
`config row-delete`, `config oauth-url` need 0.30.0+,
`project edit --new-alias` (cascading rename across config.json +
nested sync dir; warns on lineage cache rebuild) needs 0.31.0+,
`data-app *` JSON output uses key `app_id` (was bare `id`) on 0.31.1+
-- pipe with `jq -r '.apps[].app_id'`, not `'.id'`,
`storage retype` is a future composite), you
MUST refuse the task and return a handoff message to the parent:
`"Cannot proceed safely on kbagent <version>. Missing: <commands>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the
- `workspace from-transformation --project ALIAS --component-id ID --config-id ID [--row-id ID]` -- workspace from existing transform

## Data Apps (Streamlit / Flask / Node deployments)
Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, encrypted secrets, runtime size) with Data Science API (`/apps` -- deployment record, state, URL, configVersion). The CLI encapsulates the §9 redeploy contract so callers cannot pin to the empty-shell v2; see `data-app-workflow.md` for the gotcha inventory and recipes.
Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, encrypted secrets, runtime size) with Data Science API (`/apps` -- deployment record, state, URL, configVersion). The CLI encapsulates the §9 redeploy contract so callers cannot pin to the empty-shell v2; see `data-app-workflow.md` for the gotcha inventory and recipes. Since v0.31.1 the JSON output envelope's data-app id key is `app_id` (renamed from bare `id` for symmetry with the `--app-id` input flag); `config_id` is unchanged.
- `data-app list [--project NAME ...] [--branch ID]` -- list apps across projects (Data Science index merged with Storage names)
- `data-app detail --project NAME --app-id ID [--branch ID]` -- merged view (state, desired, url, configVersion, slug, git block with PAT redacted)
- `data-app create --project ALIAS --name NAME --slug SLUG --git-repo URL [--git-public/--no-git-public] [--git-username USER] [--git-pat-env VAR | --git-pat-file PATH | --git-pat-encrypted KBC::Project...] [--auth password|public] [--size tiny|small|medium|large] [--auto-suspend SECONDS] [--type python-js|python|streamlit|r|...] [--branch ID] [--no-deploy] [--wait] [--timeout SECONDS] [--keep-on-failure] [--dry-run]` -- POST shell + encrypt PAT + PUT Storage config (with auto-injected `parameters.id`) + PATCH deploy with the §9 trio. Cleanup-in-finally on failure unless `--keep-on-failure`. Default `--auth password` mints a 20-char hex simpleAuth password (retrievable via `data-app password`).
Expand Down
24 changes: 24 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Gotchas -- Response Parsing and Common Pitfalls

## `data-app` JSON output: key for the app's own id is `app_id` (since v0.31.1)

- Every `kbagent --json data-app <subcommand>` envelope emits the
data-app's own identifier under the key `app_id`. Prior to v0.31.1 the
same key was named bare `id`, which did not match the `--app-id` input
flag. Affects `data-app list / detail / create / deploy / start / stop /
delete / password / secrets-set / secrets-list / secrets-get /
secrets-remove`. The companion `config_id` key is unchanged.
- Pipe-friendly chain that v0.31.1 enables:
`kbagent --json data-app list | jq -r '.apps[].app_id' | xargs -I{} kbagent data-app deploy --project P --app-id {}`.
On pre-v0.31.1 you had to read `.apps[].id` (mismatched the input flag,
surprised AI agents that templated `.app_id`).
- **What is NOT renamed:** the Storage config back-pointer at
`parameters.id` inside the configuration body sent TO Storage (writeup
§5) -- that lives in the Storage config, not in kbagent's output
envelope. The auth-provider id (`auth_providers[].id == "simpleAuth"`)
is also unchanged.
- The Data Science API on every Keboola stack we've probed (europe-west3.gcp,
us-east4.gcp; 2026-05-12) serves camelCase keys on the wire (`id`,
`configId`, `desiredState`, `configVersion`, ...). kbagent reads those
camelCase keys directly and emits its own snake_case-ish output keys.
If a future API shape change introduces snake_case wire keys, this
helper will need a defensive alias pass -- not yet warranted.

## `project edit --new-alias` does NOT rewrite lineage caches (since v0.31.0)

- `kbagent project edit --project OLD --new-alias NEW` cascades the rename
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-agent-cli"
version = "0.31.0"
version = "0.31.1"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
5 changes: 5 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.31.1": [
"BREAKING (output-only): `kbagent --json data-app list / detail / create / deploy / start / stop / delete / password / secrets-set / secrets-list / secrets-get / secrets-remove` now emit the field name `app_id` instead of bare `id` for the data-app's own identifier. Renamed for input/output symmetry with the existing `--app-id` flag and the rest of the kbagent CLI -- `ConfigService` emits `config_id`, `StorageService` emits `bucket_id` / `table_id` on single-item operations, but `DataAppService` had been the lone hold-out emitting bare `id` since v0.27.0. The pipe-friendly chain `kbagent --json data-app list | jq -r '.apps[].app_id' | xargs -I{} kbagent data-app deploy --project P --app-id {}` now works as the input flag implies. `config_id` is unchanged. The Storage config back-pointer at `parameters.id` (writeup §5; lives INSIDE the configuration body sent TO Storage, not in kbagent output) is unchanged. Migration for any downstream consumer reading the JSON output: rename `.id` -> `.app_id` in jq filters / scripts. Live-validated 2026-05-12 against europe-west3.gcp projects 1143, 2738, 2959 (the project @ottomansky cited when raising the original UX complaint on v0.30.2; the more dramatic null-id symptom in that report could not be reproduced on v0.30.6 -- which is byte-identical to v0.30.2 for these files -- so this release is scoped to the rename only). Reported by @ottomansky.",
"Plugin docs: new `(since v0.31.1)` entry in `gotchas.md` documenting the `id` -> `app_id` output rename. `commands-reference.md` data-app section gets a one-line callout. `keboola-expert.md` VERSION GATE Rule 6 lists `data-app *` JSON output key as `app_id` on 0.31.1+. AGENT_CONTEXT and the auto-generated SKILL.md decision table are unaffected (no command-signature changes).",
"Tests: new `tests/test_data_app_service.py::TestDataAppListOutputKeys::test_list_emits_app_id_key` locks the rename + asserts the legacy `id` key is gone. New `tests/test_data_app_service.py::TestDataAppEnvelopesNoBareIdKey` (11 parametrised tests, one per envelope-emitting method: detail, create, deploy, start, stop, delete, password, secrets-set, secrets-list, secrets-get, secrets-remove) is a regression guard preventing any future edit from accidentally re-introducing the bare `id` key. Existing `tests/test_data_app_service.py`, `tests/test_data_app_cli.py`, `tests/test_data_app_secrets_cli.py` updated for the `app_id` rename. E2E coverage extended in `tests/test_e2e.py::TestE2EDataAppLifecycle::test_data_app_lifecycle_public` with a list-step round-trip assertion that the newly-created app appears with populated `app_id` and `config_id`.",
],
"0.31.0": [
"New: `kbagent project edit --new-alias NEW [--dry-run]` -- rename the alias of an existing project connection without going through `project remove` + `project add` (which forces token re-entry). Cascades the rename through everything that persists the alias on disk: the `config.json` `projects` dict key (`pop(old)` + insert under `new`) AND the `default_project` field if it matched the old alias. When a nested-layout sync workspace is present at `<cwd>/<old-alias>/.keboola/manifest.json`, the directory itself is also renamed to `<cwd>/<new-alias>/` -- mirrors the `kbagent config rename` precedent (`-2`-suffix collision handling, git-mv with shutil.move fallback). Skips the disk step when no sync workspace is present. Combined with `--url` and/or `--token` in a single invocation those mutations target the NEW alias post-rename, so `kbagent project edit --project foo --new-alias bar --token NEW` is one atomic operation with the expected ordering. Backed by the new `ConfigStore.rename_project(old, new)` method (atomic dict-key swap + `default_project` update saved as one transaction) and a fail-closed `ProjectService._rename_project_alias()` helper that validates collision before touching any state. Validation: empty `new_alias`, whitespace-only `new_alias`, and `new_alias` that already exists are all rejected with `ConfigError` exit code 5.",
"New: `--dry-run` previews the rename (collision detection, planned disk-rename method `git_mv` vs `shutil_move`, lineage-cache warning) without mutating any state. Validation errors (`..` path-traversal, collision, invalid format) raise the same `ConfigError` exit-5 codes as the live path -- callers can rely on `--dry-run` as a 1:1 pre-flight. Token re-verification is also skipped in dry-run mode (no API hit). Result dict carries `dry_run: True` and a `planned` sub-dict. Backed by `_plan_project_alias_rename()` and `_plan_nested_sync_dir()` helpers in `services/project_service.py` -- pure read-only mirrors of the live `_rename_project_alias` / `_rename_nested_sync_dir`. Addresses PR #266 review NIT (UX consideration: even non-classically-destructive ops benefit from a dry-run pre-flight).",
Expand Down
8 changes: 4 additions & 4 deletions src/keboola_agent_cli/commands/data_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def _print_data_app_table(formatter, result: dict) -> None:
return
for app in apps:
formatter.console.print(
f" [bold]{app['id']}[/bold] "
f" [bold]{app['app_id']}[/bold] "
f"[cyan]{app.get('name', '')}[/cyan] "
f"({app.get('type', '?')}) "
f"state=[yellow]{app.get('state', '?')}[/yellow] "
Expand Down Expand Up @@ -167,7 +167,7 @@ def data_app_detail(
formatter.output(
result,
lambda c, d: (
c.print(f"\n[bold]Data app:[/bold] {d.get('name', '')} ({d['id']})"),
c.print(f"\n[bold]Data app:[/bold] {d.get('name', '')} ({d['app_id']})"),
c.print(f" [bold]Project:[/bold] {d['project_alias']}"),
c.print(f" [bold]Slug:[/bold] {d.get('slug', '')}"),
c.print(f" [bold]Type:[/bold] {d.get('type', '')}"),
Expand Down Expand Up @@ -405,7 +405,7 @@ def data_app_create(
formatter.console.print(
f"[bold green]Success:[/bold green] {result.get('message', '')}"
)
formatter.console.print(f" [bold]App ID:[/bold] {result['id']}")
formatter.console.print(f" [bold]App ID:[/bold] {result['app_id']}")
formatter.console.print(f" [bold]Config ID:[/bold] {result['config_id']}")
if result.get("url"):
formatter.console.print(f" [bold]URL:[/bold] {result['url']}")
Expand Down Expand Up @@ -926,7 +926,7 @@ def data_app_secrets_list(
return
formatter.console.print(
f"\n[bold]{result['count']} secret(s)[/bold] on data app "
f"[cyan]{result['id']}[/cyan] in [magenta]{result['project_alias']}[/magenta]:"
f"[cyan]{result['app_id']}[/cyan] in [magenta]{result['project_alias']}[/magenta]:"
)
for entry in result["secrets"]:
marker = (
Expand Down
38 changes: 20 additions & 18 deletions src/keboola_agent_cli/services/data_app_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ def worker(
merged.append(
{
"project_alias": alias,
"id": str(app.get("id", "")),
"app_id": str(app.get("id", "")),
"config_id": config_id,
"name": config_names.get(config_id, app.get("name", "")),
"type": app.get("type", ""),
Expand Down Expand Up @@ -393,7 +393,7 @@ def worker(
all_apps: list[dict[str, Any]] = []
for _alias, apps, _ok in successes:
all_apps.extend(apps)
all_apps.sort(key=lambda a: (a["project_alias"], a.get("id", "")))
all_apps.sort(key=lambda a: (a["project_alias"], a.get("app_id", "")))
errors.sort(key=lambda e: e.get("project_alias", ""))
return {"apps": all_apps, "errors": errors}

Expand Down Expand Up @@ -441,7 +441,7 @@ def get_data_app(

return {
"project_alias": alias,
"id": str(app.get("id", "")),
"app_id": str(app.get("id", "")),
"config_id": config_id,
"config_version_storage": str(storage_config.get("version", "") or ""),
"config_version_deployed": str(app.get("configVersion", "") or ""),
Expand Down Expand Up @@ -642,9 +642,11 @@ def create_data_app(
timeout_seconds=timeout_seconds,
)

url_record = deployed_record or shell
state_record = poll_result or deployed_record or shell
return {
"project_alias": alias,
"id": app_id,
"app_id": app_id,
"config_id": config_id,
"name": name,
"slug": slug,
Expand All @@ -657,9 +659,9 @@ def create_data_app(
"config_version": storage_version,
"deployed": bool(deploy),
"wait": bool(wait),
"url": (deployed_record or shell).get("url", ""),
"state": (poll_result or deployed_record or shell).get("state", ""),
"desired_state": (poll_result or deployed_record or shell).get("desiredState", ""),
"url": url_record.get("url", ""),
"state": state_record.get("state", ""),
"desired_state": state_record.get("desiredState", ""),
"last_start_timestamp": (poll_result or deployed_record or {}).get(
"lastStartTimestamp"
),
Expand All @@ -668,7 +670,7 @@ def create_data_app(
auth=auth,
deployed=bool(deploy),
wait=bool(wait),
state=(poll_result or deployed_record or shell).get("state", ""),
state=state_record.get("state", ""),
),
}
except Exception:
Expand Down Expand Up @@ -846,7 +848,7 @@ def delete_data_app(self, alias: str, app_id: str) -> dict[str, Any]:
ds_client.close()
return {
"project_alias": alias,
"id": str(app_id),
"app_id": str(app_id),
"deleted": True,
"message": (
f"Data app {app_id} deleted from project '{alias}'. "
Expand Down Expand Up @@ -888,7 +890,7 @@ def get_data_app_password(
password = payload.get("password", "") if isinstance(payload, dict) else ""
return {
"project_alias": alias,
"id": str(app_id),
"app_id": str(app_id),
"password": password,
"message": (
f"Retrieved simpleAuth password for data app {app_id}. "
Expand Down Expand Up @@ -1045,7 +1047,7 @@ def set_data_app_secrets(
return {
"dry_run": True,
"project_alias": alias,
"id": str(app_id),
"app_id": str(app_id),
"config_id": config_id,
"secrets_set": sorted(_derive_runtime_env_var_name(k) for k in validated),
"secrets_unchanged": unchanged,
Expand Down Expand Up @@ -1128,7 +1130,7 @@ def set_data_app_secrets(
secrets_set = sorted(_derive_runtime_env_var_name(k) for k in validated)
return {
"project_alias": alias,
"id": str(app_id),
"app_id": str(app_id),
"config_id": config_id,
"secrets_set": secrets_set,
"secrets_unchanged": unchanged,
Expand Down Expand Up @@ -1191,7 +1193,7 @@ def list_data_app_secrets(

return {
"project_alias": alias,
"id": str(app_id),
"app_id": str(app_id),
"config_id": config_id,
"secrets": entries,
"count": len(entries),
Expand Down Expand Up @@ -1245,7 +1247,7 @@ def get_data_app_secret(
env_var = _derive_runtime_env_var_name(key)
return {
"project_alias": alias,
"id": str(app_id),
"app_id": str(app_id),
"config_id": config_id,
"key": key,
"env_var": env_var,
Expand Down Expand Up @@ -1305,7 +1307,7 @@ def remove_data_app_secrets(
# Idempotent: removing a non-existent key is success.
return {
"project_alias": alias,
"id": str(app_id),
"app_id": str(app_id),
"config_id": config_id,
"removed": [],
"not_found": not_found,
Expand All @@ -1324,7 +1326,7 @@ def remove_data_app_secrets(
return {
"dry_run": True,
"project_alias": alias,
"id": str(app_id),
"app_id": str(app_id),
"config_id": config_id,
"to_remove": removed,
"not_found": not_found,
Expand Down Expand Up @@ -1352,7 +1354,7 @@ def remove_data_app_secrets(
new_version = str(put_response.get("version", "") or "")
return {
"project_alias": alias,
"id": str(app_id),
"app_id": str(app_id),
"config_id": config_id,
"removed": removed,
"not_found": not_found,
Expand Down Expand Up @@ -1862,7 +1864,7 @@ def _format_lifecycle_result(
record = poll_result or deployed
return {
"project_alias": alias,
"id": str(app_id),
"app_id": str(app_id),
"action": action,
"state": record.get("state", ""),
"desired_state": record.get("desiredState", ""),
Expand Down
Loading
Loading