From 35b2883bdfa8b404b1446de0460376e2f781a077 Mon Sep 17 00:00:00 2001 From: Petr Date: Wed, 12 Aug 2026 08:33:41 +0200 Subject: [PATCH 1/3] docs(mcp): no migration command -- record the decision and the recipe (#390) Decision: `kbagent agent migrate-mcp-tasks` will not be built. Migrating a scheduled `--type mcp_tool` task before v0.85.0 is manual, or AI-assisted. The reasoning that killed automatic rewriting also argues against a plan-generator: the parity map knows which native command replaces each tool but not how to map arguments -- mcp_tool params are the MCP tool's own `input` dict (`componentId`) against CLI flags (`--component-id`), with different shapes for several tools. An agent reading `--help` next to the task's actual input does that mapping better than any generator we could ship, and has a human confirming. Detection already landed in #557, which is the part that could not be improvised: `doctor` names every affected task and its native_command, and `agent list` / serve `/agents` carry a deprecation key. So this is documentation, not tooling: - gotchas.md states plainly that no command exists and why, then gives the four-step recipe (doctor for the ids, agents.json for the input, --help for the flags, agent update + one agent run before trusting the cron). - keboola-expert.md gets a one-line trigger pointing at it, kept short on purpose: that file is at 61.6 KB of its 62 KB budget. Note the residual risk, since it is a judgement and not a measurement: we have no telemetry, so "probably nobody uses this" is a call about the user base, not an observation. If it is wrong the failure mode is a silently dead cron task -- but `kbagent doctor` now surfaces exactly that, which is why the decision is defensible without the command. --- .claude-plugin/marketplace.json | 2 +- plugins/kbagent/.claude-plugin/plugin.json | 2 +- plugins/kbagent/agents/keboola-expert.md | 3 +++ .../skills/kbagent/references/gotchas.md | 20 +++++++++++++++++-- pyproject.toml | 2 +- src/keboola_agent_cli/changelog.py | 15 ++++++++++++++ uv.lock | 2 +- 7 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7afe7b35..b7fbedc1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.82.0", + "version": "0.83.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" diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 2f400caa..bafaef42 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.82.0", + "version": "0.83.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", diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 7289be8a..c397ef5d 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -166,6 +166,9 @@ One-line triggers only. Full prose, exact error strings, issue numbers, and API quirks live in [`gotchas.md`](../skills/kbagent/references/gotchas.md) -- read it when a trigger fires. Each `(X.Y.Z+)` tag is the version floor. +**Migrating an `mcp_tool` agent task** +- No migration command exists; you do the argv mapping. Recipe in gotchas.md. (0.83.0+) + **Upgrading kbagent itself** - `install_channel` in `kbagent --json version` => native binary; `kbagent update` REFUSES by design. Quote `upgrade_command` (choco/winget/brew/apt/dnf); diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 2f215c84..31f8c09e 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -193,14 +193,30 @@ Versioning convention: warns. Migrate to `--type cli_command` with the native command BEFORE v0.85.0. These tasks are persisted in `/agents.json`, so unlike an interactive `tool call` they get no warning at removal time: a - scheduled task simply starts failing on its next cron tick. Use the - `cli_equivalent` from `kbagent tool list` to rewrite each one. + scheduled task simply starts failing on its next cron tick. +- **There is NO migration command, by decision.** The parity map knows which + native command replaces a tool, but not how to map arguments: `mcp_tool` + params are the MCP tool's own `input` dict (`componentId`) while the CLI + takes flags (`--component-id`), and for several tools the shape differs too. + Guessing the argv of a scheduled WRITE task that then runs unattended is the + worst place to be wrong, so kbagent detects these tasks and leaves the + rewrite to you -- or to an AI agent, which can read `--help` and the task's + own input and does not have to guess. - **Find the affected tasks (since v0.81.0)**: `kbagent doctor` has an `mcp_tool_tasks` check that warns with the task ids, the tool each one calls and its native replacement (`details.tasks[]` in `--json`, each with `native_command`). `kbagent agent list` marks those rows DEPRECATED and its `--json` adds an additive per-task `deprecation` key -- present ONLY on affected tasks, so every other consumer sees a byte-identical payload. +- **Recipe for migrating one task** (this is the whole procedure): + 1. `kbagent --json doctor` -> `checks[].details.tasks[]` for the affected + task ids and the `native_command` each one maps to. + 2. Read that task's `action.params.input` from `/agents.json` + (or `kbagent --json agent show `) -- those are the values to carry over. + 3. `kbagent --help` -> map each input key to its flag. This + is the step no tooling does for you; the naming differs per tool. + 4. `kbagent agent update --type cli_command --argv ...` with the result, + then run it once with `kbagent agent run ` before trusting the cron. ## MCP tool classification is FAIL-CLOSED; parity commands replace `tool call` (since v0.73.0) diff --git a/pyproject.toml b/pyproject.toml index 3ef971df..063cfc9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.82.0" +version = "0.83.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 785eb7fc..3e8a5307 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,21 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.83.0": [ + "Note (#390): there will be NO `kbagent agent migrate-mcp-tasks` command. Migrating " + "a scheduled `--type mcp_tool` task to `--type cli_command` before v0.85.0 is a " + "manual step -- or an AI-assisted one, which is the point: the parity map knows " + "which native command replaces each tool, but not how to map arguments (`mcp_tool` " + "params are the MCP tool's own `input` dict, e.g. `componentId`, while the CLI takes " + "`--component-id`, and for several tools the shape differs too). Guessing the argv " + "of a scheduled WRITE task that then runs unattended is the worst place to be wrong, " + "and an agent reading `--help` alongside the task's own input does it better than " + "any generator we could ship. kbagent therefore finds the tasks and leaves the " + "rewrite to you. The full four-step recipe is in the skill's `gotchas.md`; the short " + "version is `kbagent --json doctor` for the task ids and their `native_command`, " + "`kbagent --help` for the flag names, then `kbagent agent update --type " + "cli_command --argv ...` and one `kbagent agent run` before trusting the cron.", + ], "0.82.0": [ "Fix: kbagent is licensed under **Apache 2.0**, and the packaging metadata now says " "so. #544 added an Apache 2.0 `LICENSE` file, but `pyproject.toml`, the deb/rpm " diff --git a/uv.lock b/uv.lock index 0eb9138e..bd7a4c0e 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.82.0" +version = "0.83.0" source = { editable = "." } dependencies = [ { name = "croniter" }, From 8259aeec73943dbb37710960837da71baf584149 Mon Sep 17 00:00:00 2001 From: Petr Date: Wed, 12 Aug 2026 10:10:44 +0200 Subject: [PATCH 2/3] fix(docs): the migration recipe named flags agent update does not have Devin review of #581, and it landed on the point of the PR: step 4 told the reader to run `kbagent agent update --type cli_command --argv ...`. That command patches name/cron/enabled/trigger only and explicitly cannot change a task's action, so the documented path exited 2. In a PR whose whole premise is "a recipe instead of tooling", a recipe that fails at its last step is worse than none. Corrected to what works today: `agent create` the replacement, verify it with one `agent run`, then `agent delete` the old task. That path has a trap Devin's suggestion did not mention, so it is now spelled out: `Trigger.task_id` chains tasks BY ID, and create-new + delete-old changes the id. Any other task pointing at the old one via --trigger-task-id must be repointed FIRST or the chain breaks silently. tests/test_mcp_migration_recipe.py pins the recipe to the CLI's real flags, including an anchor asserting `agent update` still cannot change the action -- if that ever gains --type/--argv, the test fails and the docs get simpler. --- .../skills/kbagent/references/gotchas.md | 13 ++++- src/keboola_agent_cli/changelog.py | 7 ++- tests/test_mcp_migration_recipe.py | 54 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 tests/test_mcp_migration_recipe.py diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 31f8c09e..aecb33be 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -215,8 +215,17 @@ Versioning convention: (or `kbagent --json agent show `) -- those are the values to carry over. 3. `kbagent --help` -> map each input key to its flag. This is the step no tooling does for you; the naming differs per tool. - 4. `kbagent agent update --type cli_command --argv ...` with the result, - then run it once with `kbagent agent run ` before trusting the cron. + 4. `kbagent agent create --name ... --cron ... --type cli_command --argv ...` + with the result, verify it once with `kbagent agent run `, then + `kbagent agent delete `. +- **`agent update` CANNOT change a task's action** -- it patches name, cron, + enabled/manual and the trigger only, so `--type` / `--argv` are not options + there and exit 2. Migration therefore means create-new + delete-old, which + has one trap: **the task id changes**. `Trigger.task_id` chains tasks by id, + so if any OTHER task has `--trigger-task-id `, repoint it with + `kbagent agent update --trigger-task-id ` BEFORE deleting + the old one, or the chain breaks silently. Check with + `kbagent --json agent list` and grep the `trigger` blocks for the old id. ## MCP tool classification is FAIL-CLOSED; parity commands replace `tool call` (since v0.73.0) diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 3e8a5307..a233146d 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -36,8 +36,11 @@ "any generator we could ship. kbagent therefore finds the tasks and leaves the " "rewrite to you. The full four-step recipe is in the skill's `gotchas.md`; the short " "version is `kbagent --json doctor` for the task ids and their `native_command`, " - "`kbagent --help` for the flag names, then `kbagent agent update --type " - "cli_command --argv ...` and one `kbagent agent run` before trusting the cron.", + "`kbagent --help` for the flag names, then `kbagent agent create --type " + "cli_command --argv ...`, one `kbagent agent run` to verify, and `agent delete` on " + "the old task. Note `agent update` cannot change a task's action -- migration is " + "create-new + delete-old, so the task ID changes: if another task chains to it via " + "`--trigger-task-id`, repoint that first or the chain breaks silently.", ], "0.82.0": [ "Fix: kbagent is licensed under **Apache 2.0**, and the packaging metadata now says " diff --git a/tests/test_mcp_migration_recipe.py b/tests/test_mcp_migration_recipe.py new file mode 100644 index 00000000..73620c25 --- /dev/null +++ b/tests/test_mcp_migration_recipe.py @@ -0,0 +1,54 @@ +"""The documented migration recipe must name flags the CLI actually has. + +#581 shipped a four-step recipe whose last step told the reader to run +`kbagent agent update --type cli_command --argv ...`. That command patches +name/cron/enabled/trigger only -- it explicitly cannot change a task's action -- +so the documented path exited 2. A recipe that fails at its last step is worse +than no recipe, and nothing was checking that the docs matched the CLI. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app + +GOTCHAS = Path(__file__).parent.parent / "plugins/kbagent/skills/kbagent/references/gotchas.md" +runner = CliRunner() + + +def _options_of(*command: str) -> set[str]: + result = runner.invoke(app, [*command, "--help"]) + assert result.exit_code == 0, result.output + return set(re.findall(r"--[a-z][a-z0-9-]*", result.output)) + + +def test_agent_update_still_cannot_change_the_action() -> None: + """Anchor for the recipe below; if this ever changes, simplify the docs.""" + options = _options_of("agent", "update") + assert "--type" not in options + assert "--argv" not in options + + +def test_recipe_does_not_tell_agent_update_to_change_the_action() -> None: + text = GOTCHAS.read_text(encoding="utf-8") + assert "agent update --type cli_command" not in text + + +def test_recipe_uses_flags_agent_create_really_has() -> None: + options = _options_of("agent", "create") + for flag in ("--type", "--argv", "--name", "--cron"): + assert flag in options, f"the migration recipe names {flag}, which agent create lacks" + + +def test_recipe_warns_about_the_changing_task_id() -> None: + """Recreating breaks any task chained to the old id via Trigger.task_id.""" + text = GOTCHAS.read_text(encoding="utf-8") + section = text.split("Recipe for migrating one task", 1) + assert len(section) == 2, "migration recipe section is gone" + body = section[1][:1600] + assert "--trigger-task-id" in body + assert "the task id changes" in body.lower() From 68aa01928b0ff8f6a6df84999c67438db24a0237 Mon Sep 17 00:00:00 2001 From: Petr Date: Wed, 12 Aug 2026 10:43:03 +0200 Subject: [PATCH 3/3] test(docs): read the CLI contract, not the rendered help The guard I added against a broken recipe was itself broken: _options_of() scraped `--help` to decide which flags a command has. Rendered help is a presentation concern -- Rich wraps and truncates it by terminal width and TTY detection -- so an option can be present in the interface and absent from the text. It passed locally (including at COLUMNS=80) and failed on all three CI runners. Rather than chase what differs about the CI environment, the helper now reads `params` off the command object, where the contract actually lives. Width and TTY stop mattering. Walking the tree is duck-typed on a `commands` dict rather than isinstance(click.Group): Typer builds its own Command/Group subclasses, so the click check is False at runtime -- which broke the first attempt at this fix. --- tests/test_mcp_migration_recipe.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/test_mcp_migration_recipe.py b/tests/test_mcp_migration_recipe.py index 73620c25..c25786a8 100644 --- a/tests/test_mcp_migration_recipe.py +++ b/tests/test_mcp_migration_recipe.py @@ -9,21 +9,34 @@ from __future__ import annotations -import re from pathlib import Path +from typing import Any -from typer.testing import CliRunner +import typer.main from keboola_agent_cli.cli import app GOTCHAS = Path(__file__).parent.parent / "plugins/kbagent/skills/kbagent/references/gotchas.md" -runner = CliRunner() def _options_of(*command: str) -> set[str]: - result = runner.invoke(app, [*command, "--help"]) - assert result.exit_code == 0, result.output - return set(re.findall(r"--[a-z][a-z0-9-]*", result.output)) + """Every option name a command declares, read from the command object. + + Deliberately NOT scraped from ``--help``: rendered help is a presentation + concern -- Rich wraps and truncates it by terminal width and TTY detection, + so an option can be present in the interface and absent from the text. The + parameter list is the actual contract. An earlier version of this helper + scraped the help, passed locally, and failed on all three CI runners. + """ + node: Any = typer.main.get_command(app) + for name in command: + # Duck-typed rather than isinstance(click.Group): Typer builds its own + # Command/Group subclasses, so a click.Group check is False at runtime. + subcommands = getattr(node, "commands", None) + assert isinstance(subcommands, dict), f"{name!r} is not under a command group" + assert name in subcommands, f"no such command: {' '.join(command)}" + node = subcommands[name] + return {opt for param in node.params for opt in param.opts if opt.startswith("--")} def test_agent_update_still_cannot_change_the_action() -> None: