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
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.27.0",
"version": "0.28.0",
"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.27.0",
"version": "0.28.0",
"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
28 changes: 23 additions & 5 deletions plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ a critical failure.
needed for the current task (e.g. `flow update` needs 0.22.0+,
`schedule find` needs 0.23.0+, `config set-default-bucket` needs
0.26.0+, `data-app create / deploy / start / stop / delete / password`
need 0.27.0+, `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>. Ask user to run
kbagent update, then re-invoke me."` Do not attempt the task with
workarounds that use MCP strip-bug-prone tools.
need 0.27.0+, `config update` script[] auto-normalize against #245
trap needs 0.28.0+, `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>.
Ask user to run kbagent update, then re-invoke me."` Do not attempt
the task with workarounds that use MCP strip-bug-prone tools.

7. **ALWAYS USE `--json`**. Every `kbagent` invocation MUST have
`--json` as the first flag after `kbagent`. This makes output
Expand All @@ -87,6 +88,7 @@ a critical failure.
| Update flow (rename, description, phases) | `kbagent flow update` (partial, no `--file`) | `--file` after fetching current phases, merging locally, passing full YAML | `tool call update_flow` (strips `behavior.onError` pre-MCP v1.60); partial `--file` that drops fields |
| Schedule flow | `kbagent flow schedule --cron ... [--timezone]` | `tool call create_flow_schedule` | raw REST to `/storage/configurations/keboola.scheduler` |
| Create Snowflake transformation | `kbagent config new --component-id keboola.snowflake-transformation` + `config update --set ...` | `tool call create_sql_transformation` (lower schema, avoids the component refusal) | `tool call create_config` (refuses keboola.snowflake-transformation) |
| Update SQL transformation body (script[]) | `kbagent config update --project P --component-id keboola.snowflake-transformation --config-id K --configuration @body.json` (0.28.0+ auto-normalizes string `script` to array; SQL gets statement-level split, Python/R gets `[script]` wrap; envelope's `normalizations: [...]` records every change) | `kbagent --hint client config update ...` if you need to bypass the auto-normalize for some reason | `tool call update_sql_transformation` -- still vulnerable to the #245 string-vs-array runtime crash because it pushes raw to Storage API; raw `PUT /v2/storage/components/.../configs/...` -- same trap |
| Run a job (and wait) | `kbagent job run --project P --component-id C --config-id K --wait` | `tool call run_component` | `job run` without `--wait` when user expects the result |
| Browse configs (exploration) | `kbagent config list` / `kbagent config search --query Q` | `tool call list_configs` | full-project pull via MCP just to grep locally |
| Fetch a specific config | `kbagent config detail --project P --component-id C --config-id K --json` | `tool call get_config` | re-using an earlier JSON dump |
Expand Down Expand Up @@ -127,6 +129,22 @@ success, not a failure.
for the local scaffold, then `kbagent config update` for the body.
Or MCP `create_sql_transformation` which uses a lower-level schema.

- **`script[]` string-vs-array runtime crash** (0.28.0+ auto-fix; #245):
the Storage API silently accepts `parameters.blocks[].codes[].script`
as a string, but the runtime validator rejects it (`Expected array,
got string`) -- the broken push lands silently and the job crashes
hours later (often via the scheduler). `kbagent config update`
auto-normalizes string -> array before pushing: SQL transformations
get statement-level split via the existing `split_statements()` state
machine; Python / R / `kds-team.app-custom-python` get a single-element
`[script]` wrap. Inspect the result envelope's `normalizations: [...]`
to see what was changed (empty list means already-valid input).
**Caveat**: the trap STILL FIRES if you bypass kbagent. `tool call
update_sql_transformation` / `create_sql_transformation` and raw
`PUT /v2/storage/components/.../configs/...` calls do NOT inherit the
normalization (as of MCP v1.59.x). For SQL transformation body
updates, prefer `kbagent config update` over MCP/REST.

- **Primary keys on new output tables**: Keboola creates columns as
nullable by default on first insert. A PK on a nullable column
crashes the first run. Pattern: strip PKs before first run, run,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ All commands support `--json` for structured output. Multi-project flags (`--pro
- `config list [--project NAME] [--component-type TYPE] [--component-id ID] [--branch ID] [--include-rows]` -- list configs across projects (branch-aware). With `--include-rows` each row extends to include the full `configuration` and `rows` body (noticeably larger payload -- use only when the bodies are needed; the summary default covers name/description/component/last_modified/folder)
- `config detail --project NAME [--project ...] --component-id ID [--config-id ID] [--branch ID] [--with-state]` -- **two modes.** **Single** (with `--config-id`): full config dict, shape unchanged from previous releases (callers depending on `.id`, `.configuration`, `.rows` etc. are unaffected). **Bulk** (omit `--config-id`): returns `{"configs": [...], "errors": [...]}` with every configuration of `--component-id` across one or many projects -- each row tagged with `project_alias`/`branch_id`. One HTTP request per project via `list_components_with_configs` (not one per config; a project with 100 Snowflake writers returns in a single round-trip). `--project` is repeatable in bulk mode; `--config-id` with multiple `--project` is rejected (exit 2) because a single config lives in one project. `--with-state` attaches the runtime `state` dict: single-mode triggers an extra `get_config_state` call, bulk-mode adds `include=state` to the listing call (no N+1)
- `config search --query PATTERN [--project NAME] [-i] [-r] [--branch ID]` -- search config bodies for string/regex (branch-aware)
- `config update --project NAME --component-id ID --config-id ID [--name N] [--description D] [--configuration JSON|@file|-] [--configuration-file PATH] [--set PATH=VALUE ...] [--merge] [--dry-run] [--branch ID]` -- update metadata and/or configuration content. `--set` targets a nested key (e.g. `parameters.db.host=new-host`). `--merge` deep-merges into existing config (preserves sibling keys). `--dry-run` previews changes without applying. Paths are relative to the configuration root (unlike MCP's `update_config` which uses paths relative to `parameters`)
- `config update --project NAME --component-id ID --config-id ID [--name N] [--description D] [--configuration JSON|@file|-] [--configuration-file PATH] [--set PATH=VALUE ...] [--merge] [--dry-run] [--branch ID]` -- update metadata and/or configuration content. `--set` targets a nested key (e.g. `parameters.db.host=new-host`). `--merge` deep-merges into existing config (preserves sibling keys). `--dry-run` previews changes without applying. Paths are relative to the configuration root (unlike MCP's `update_config` which uses paths relative to `parameters`). **Auto-normalize (0.28.0+; #245)**: `parameters.blocks[].codes[].script` strings are silently rewritten to arrays before pushing to Storage API -- SQL transformations get statement-level split (respects `'...'` / `"..."` / `$$..$$` / `--` / `#` / `//` / `/* ... */`); Python / R / `kds-team.app-custom-python` get `[script]` wrap. The result envelope's `normalizations: [{path, action: "sql_split"|"wrap_array", before_type, after_type, after_length}]` records every change (empty when nothing was malformed). Closes the runtime "Expected array, got string" trap that the lax Storage API silently lets through. Bypassing kbagent (raw REST, MCP `update_sql_transformation`) does NOT inherit this -- prefer `kbagent config update` for SQL transformation body changes.
- `config set-default-bucket --project NAME --component-id ID --config-id ID (--bucket BUCKET_ID | --clear) [--dry-run] [--branch ID]` -- set or clear `configuration.storage.output.default_bucket` on a configuration. Discoverable shortcut for the raw-mode workaround at https://keboola.atlassian.net/wiki/spaces/SUP/pages/3770155030/. Read-modify-write that preserves sibling keys; returns `{"changed": false}` when the value already matches the requested state. Honored by output tables that don't pin their own `destination`.
- `config rename --project NAME --component-id ID --config-id ID --name "New Name" [--branch ID] [--directory DIR]` -- rename a configuration (API update + local sync directory rename with git mv support)
- `config delete --project NAME --component-id ID --config-id ID [--branch ID]` -- delete a configuration
Expand Down
61 changes: 61 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,67 @@ CREATE TABLE foo AS

See `scaffold-workflow.md` for the complete file structure reference.

## `config update` auto-normalizes `script[]` from string to array (since v0.28.0)

The Storage API silently accepts a string for `parameters.blocks[].codes[].script`,
but the Keboola runtime validator rejects it with:

```
Invalid type for path "root.parameters.blocks.0.codes.X.script".
Expected "array", but got "string"
```

The trap: the failed PUT returns 200, the version increments, the UI looks
fine -- the crash happens only when the job runs (often hours later, e.g. by
the scheduler), with no attribution back to the offending write. Reported in
issue #245 after a programmatic refactor of 3 production Snowflake
transformations.

`kbagent config update` (and any wrapper that takes a full configuration --
`--configuration`, `--configuration-file`, `--set parameters.blocks.0.codes.0.script=...`,
and dry-run preview) now closes the gap on the write side **before** the
Storage API touch:

- **SQL transformations** (Snowflake / Synapse / Oracle / Redshift /
BigQuery / DuckDB, plus fragment fallback for `*-exasol-transformation`,
`*-teradata-transformation`, etc.): the string is split on statement
boundaries via the existing `split_statements()` state machine that
already powers `kbagent sync push`. The splitter respects `'...'` /
`"..."` / `$$...$$` / `--` / `#` / `//` / `/* ... */`, so semicolons
inside string literals and block comments do NOT cause splits.
- **Python / R / `kds-team.app-custom-python`** and any other component
sharing the `parameters.blocks[].codes[].script` shape: the string is
wrapped as a single-element array `[script]`. Statement-level split
does not apply -- the runtime treats the script as one code chunk.
- **Already-array `script` values pass through unchanged.**

Observability: every normalization is surfaced.
- JSON mode: the result envelope gains
`"normalizations": [{"path": "parameters.blocks[0].codes[0].script",
"action": "sql_split", "before_type": "str", "after_type": "list",
"after_length": 3}]`. Empty list when nothing was normalized.
- Human mode: a yellow `Auto-normalized N script field(s) to array
(string -> list). See --json for details.` warning followed by a
per-element trace.
- `--dry-run`: the `new_configuration` field already reflects the
post-normalize shape, so the preview matches what would actually land.

**The trap still exists when bypassing kbagent.** Direct
`PUT /v2/storage/components/{component}/configs/{config}` calls (curl,
custom Python, the MCP `update_sql_transformation` / `create_sql_transformation`
tools as of MCP v1.59.x) do NOT inherit this normalization. If an LLM agent
is composing the configuration JSON itself, prefer
`kbagent config update --configuration ...` over raw REST or MCP tool calls
for SQL transformations -- that way the normalization fires regardless of
upstream client behaviour.

Bonus fix in 0.28.0: `kbagent sync push` previously did NOT split semicolons
in BigQuery / DuckDB transformations because those component IDs were
missing from `SQL_TRANSFORMATION_COMPONENTS`. Push collapsed multiple
statements into one `script` element, mirroring closed issue #119 on a
different backend. The 0.28.0 registry now covers BQ / DuckDB explicitly,
plus fragment-based fallback for future / self-hosted SQL backends.

## Snowflake: MULTI_STATEMENT_COUNT

Keboola sends each code block to Snowflake as a single query batch via the ODBC
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,32 @@ failures:
| `Actual statement count N did not match desired count 1` | Missing `ALTER SESSION SET MULTI_STATEMENT_COUNT = 0` |
| `Database 'SAPI_226' does not exist` | Unquoted database name (Step 4d) |
| `invalid identifier '"column"'` | Workspace table conflict (Step 5) |
| `Expected "array", but got "string"` for `script` (runtime, not Storage write) | Pushed `parameters.blocks[].codes[].script` as a string. Storage API silently accepted it; the runtime validator rejects it. **0.28.0+ fix**: `kbagent config update` auto-normalizes string -> array before push (SQL split / Python wrap). MCP `update_sql_transformation` and raw REST do NOT auto-normalize -- prefer `kbagent config update` for SQL transformation body edits. See `gotchas.md` `script[]` entry. |

## Auto-normalization of `script[]` (since 0.28.0, #245)

When pushing a SQL transformation body via `kbagent config update --configuration`,
`--configuration-file`, or `--set parameters.blocks.0.codes.0.script=...`, the
CLI normalizes any string `script` value to an array using the same
state-machine splitter that powers `kbagent sync push`:

- SQL transformations (Snowflake / BigQuery / Synapse / Oracle / Redshift /
DuckDB; fragment fallback for Exasol / Teradata / `*-transformation-v2`
variants): each statement becomes one element. The splitter respects
`'...'`, `"..."`, `$$..$$`, `--`, `#`, `//`, and `/* ... */`, so
semicolons inside literals or comments do not split.
- Python / R transformations and `kds-team.app-custom-python` apps: the
string is wrapped as a single-element array `[script]`. Each runtime
treats the array as one code chunk.

The result envelope exposes a `normalizations: [...]` field listing every
element that was rewritten (empty list when input was already valid). Human
mode prints a yellow `Auto-normalized N script field(s)` warning followed by
a per-element trace; `--dry-run`'s `new_configuration` already reflects the
post-normalize shape. **The trap still fires when bypassing kbagent** -- if
an LLM agent pushes via `tool call update_sql_transformation` /
`create_sql_transformation` or raw `PUT /v2/storage/components/.../configs/...`,
the string lands as-is and the job crashes at runtime.

## Anti-patterns to avoid

Expand All @@ -134,3 +160,7 @@ failures:
Local files like `/tmp/current_config.json` go stale after every version bump.
- **Applying the same replacement twice**: Guard against re-replacing already-migrated
paths. Check if the string already contains `sapi_` before replacing.
- **Pushing `script` as a single multi-statement string**: The Storage API
accepts it (200 OK) but the runtime validator crashes (`Expected array,
got string`). Use `kbagent config update` (0.28.0+ auto-normalizes) or
build the array yourself if going via MCP / raw REST.
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.27.0"
version = "0.28.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
Loading