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.30.6",
"version": "0.30.7",
"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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ plugins/kbagent/
kbagent project add --project NAME --url URL --token TOKEN
kbagent project list
kbagent project remove --project NAME
kbagent project edit --project NAME [--url URL] [--token TOKEN]
kbagent project edit --project NAME [--url URL] [--token TOKEN] [--new-alias NEW]
kbagent project status [--project NAME]
kbagent project refresh --project ALIAS [--dry-run] [--force] [--yes] [--token-description DESC] [--token-expires-in N]
kbagent project refresh --all [--dry-run] [--force] [--yes] [--token-description DESC] [--token-expires-in N]
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.30.6",
"version": "0.30.7",
"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
3 changes: 3 additions & 0 deletions plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ a critical failure.
`data-app secrets-* / validate-repo` need 0.29.0+,
`search`, `project info`, `config row-create`, `config row-update`,
`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.30.7+,
`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 Expand Up @@ -135,6 +137,7 @@ a critical failure.
| Confirm one secret is present | `kbagent data-app secrets-get --project P --app-id N --key '#KEY'` (0.29.0+) -- returns metadata only | -- | trying to extract the plaintext value (impossible by design; not a CLI gap) |
| Remove a secret from a data app | `kbagent data-app secrets-remove --project P --app-id N --key '#KEY' --yes` (0.29.0+) -- idempotent; missing keys exit 0 with `removed: 0` | `tool call update_config` with the secrets sub-dict deleted -- ONLY for batch removes that need a custom change description | `kbagent config update --set 'parameters.dataApp.secrets={}'` -- replaces the whole sub-dict, dropping every secret instead of just the named ones |
| Pre-flight a data-app repo before create | `kbagent data-app validate-repo --git-repo URL --type python-js [--git-pat-env VAR]` (0.29.0+) -- BLOCKING / WARN / OK with help-doc citations; ≤5 GitHub API calls regardless of repo size | git-clone the repo locally and inspect by hand | `data-app create --dry-run` (only shows the request bodies; does not validate repo structure) |
| Rename a project alias | `kbagent project edit --project OLD --new-alias NEW [--dry-run]` (0.30.7+) -- cascades through `config.json` (`projects` key + `default_project`) and the nested-sync directory `<cwd>/<old-alias>/`. Combined with `--url`/`--token` in one call, those mutations target the new alias post-rename. `--dry-run` previews collision detection, planned disk-rename method, and the lineage-cache warning without mutating state. **Lineage cache (if any) is NOT auto-updated**: rebuild via `kbagent lineage build` after the rename | `kbagent project remove` + `kbagent project add` (re-enters the token; loses any nested sync workspace) | hand-editing `~/.config/keboola-agent-cli/config.json` (no validation, easy to miss `default_project` cascade) |

If the table does not cover the user's task, **ask clarifying
questions** instead of guessing. Returning a targeted question is a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ All commands support `--json` for structured output. Multi-project flags (`--pro
- `project add --project NAME --url URL --token TOKEN` -- connect a project (token verified via API)
- `project list` -- list all connected projects (tokens masked)
- `project remove --project NAME` -- disconnect a project
- `project edit --project NAME [--url URL] [--token TOKEN]` -- update connection details
- `project edit --project NAME [--url URL] [--token TOKEN] [--new-alias NEW] [--dry-run]` -- update connection details and/or rename the alias. `--new-alias` cascades through config.json (`projects` key + `default_project` if matched) and the nested sync directory `<cwd>/<old-alias>/` when present (-2 collision suffix, git-mv with shutil fallback). Lineage cache rebuild is manual (see gotchas, since v0.30.7). Combined with `--url` / `--token` in one call, those mutations target the new alias post-rename. `--dry-run` previews everything (collision check, planned disk-rename method, lineage-cache warning) without mutating state -- same exit codes as live for validation errors
- `project status [--project NAME]` -- test connectivity and response time
- `project description-get --project NAME` -- read the dashboard project description (KBC.projectDescription on the default branch). Returns `{"description": ""}` if not set, not an error
- `project description-set --project NAME [--text STR | --file PATH | --stdin]` -- set the dashboard project description (markdown). Pass exactly one of `--text`, `--file`, or `--stdin`. Writes to `KBC.projectDescription` on the default branch -- always the main branch, regardless of any active dev branch
Expand Down
25 changes: 25 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Gotchas -- Response Parsing and Common Pitfalls

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

- `kbagent project edit --project OLD --new-alias NEW` cascades the rename
through `config.json` (`projects` dict key + `default_project` field if it
matched OLD) and renames the nested-layout sync directory at
`<cwd>/<old-alias>/.keboola/manifest.json` to `<cwd>/<new-alias>/`.
Collision handling appends a `-2` numeric suffix (mirrors `config rename`).
- Lineage caches (`*.lineage.json` files produced by `kbagent lineage build
--output FILE`) embed the alias inside FQN strings (`<alias>:<table_id>`)
and are **NOT** auto-updated by the rename. The CLI emits a stderr warning
when it detects a cache file in the workspace.
- After a rename: rebuild any cached `.lineage.json` with
`kbagent lineage build --output PATH`. Otherwise downstream lineage queries
silently reference the old alias.
- Why we don't auto-rewrite: lineage caches can live anywhere on disk
(committed to git, in a sibling repo, used by external tooling). A partial
rewrite is worse than no rewrite -- callers must opt in by re-running
`lineage build`.
- Combined invocations are atomic in the obvious order: `--new-alias` is
applied first, then `--url` / `--token` mutations target the new alias key.
So `kbagent project edit --project foo --new-alias bar --token NEW` does
the rename, then writes the new token under `bar`. If `--new-alias` is
identical to the current alias, it's a no-op (matches "rename to same name"
idempotency).

## `keboola-mcp-server` is now auto-updated on kbagent startup (since v0.30.1)

- Pre-v0.30.1 trap: a user installs `keboola-mcp-server` once via
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.30.6"
version = "0.30.7"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
8 changes: 8 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.30.7": [
"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).",
"Note: lineage cache JSON files (output of `kbagent lineage build --output X.json`) embed the alias inside FQN strings (`<alias>:<table_id>`) and are NOT auto-updated by the rename. Rebuild with `kbagent lineage build` after the rename if you have a cached `.lineage.json`. Lineage caches may live anywhere on disk (committed to git, in a sibling repo, etc.) so a partial rename is worse than no rename. Surfaced as a stderr warning at rename time when a `.lineage.json` is detected in the workspace.",
"Security: hardening from review iteration 2. The `--new-alias` validator rejects path-traversal sequences (`..`), path separators (`/`, `\\`), NUL bytes, leading dot/dash, and anything outside `[A-Za-z0-9_.-]` -- regex `[A-Za-z0-9_][A-Za-z0-9_.-]*`. Stricter than `project add`'s no-op check; rationale is the rename's filesystem interaction (alias becomes a directory name). `search_root` is `Path.resolve()`-d once before the disk rename to collapse symlinks and close a malicious-cwd vector. Disk rename failures (`OSError`) trigger a config rollback so config and disk never end up out of sync. Lineage cache scan is depth-capped at 2 levels (top + `*/` + `*/*/`) to bound cost when `search_root` is `$HOME` or similar.",
"E2E: `tests/test_e2e.py::_test_project_edit_and_remove` extended with a `--dry-run` preview (planned-block assertion) followed by a live `--new-alias` round-trip (rename + reverse-rename to baseline) before the existing `--url` step. Pinned by Padak's PR #266 review BLOCKING -- every CLI command must have E2E coverage per CONTRIBUTING.md / convention #16. Round-trip leaves `self.alias` unchanged so subsequent steps continue to work.",
"Tests: 38 new tests across `tests/test_project_edit.py` (32 service-layer) and `tests/test_project_edit_cli.py` (6 CliRunner). Service tests pin: alias-key swap, collision rejection, `default_project` cascade when matched, `default_project` left alone when unrelated, sync-dir disk rename via `tmp_path`, no-sync-dir no-op path, sync-dir collision -2 suffix, combined edit-and-rename in one call, rename-to-same-alias is no-op, no-changes-only-same-alias is rejected, invalid alias format (empty, whitespace, `..`, `/`, leading `.`, leading `-`, NUL byte) rejected, parametrized 9-input path-traversal validator surface, legal-shape aliases (`99_playground_max`, `prod-eu`, `kbc.demo`, `_internal`) accepted, OS failure on disk rename rolls config back, rollback failure surfaces original error not secondary, symlink target collision triggers suffix bump, `--dry-run` happy-path returns planned-dict without mutation, `--dry-run` collision still raises, `--dry-run` format validation still raises, `--dry-run` predicts disk method without touching disk. CLI tests pin: human + JSON output shape, exit-5 on validation errors (collision, no-changes), `--dry-run` JSON `planned` block shape, `--dry-run` human output has `DRY RUN` label.",
],
"0.30.6": [
"UX (sec-20 follow-up): malformed `.keboola/branch-mapping.json` now surfaces as a clean JSON error envelope (exit 5, `CONFIG_ERROR`) instead of a raw Python traceback. v0.30.5 introduced the descriptive `Invalid branch ID in branch-mapping.json` message but `load_branch_mapping()` raised it as a bare `ValueError` -- which CLI commands did not catch, so an end user with a hand-edited mapping file saw a multi-frame traceback dumped to stderr instead of a one-line error. Fixed by raising `ConfigError` from `load_branch_mapping()` directly; existing CLI `except ConfigError` handlers now produce the standard error envelope. Found during v0.30.5 e2e smoke test against the kbagent-e2e project; not a security regression but a clear UX cleanup. The descriptive content of the error is unchanged; only the wrapper class differs.",
"Tests: `test_load_branch_mapping_invalid_id_includes_path` updated to assert `ConfigError` instead of `ValueError`. `cleanup_branch_id_from_mapping()` extended to catch `ConfigError` alongside the legacy `ValueError` so its best-effort behavior is preserved. `BranchMapping.from_dict()` continues to raise `ValueError` (it's the data-parser layer); only `load_branch_mapping()` (the filesystem-aware wrapper) was promoted to `ConfigError`.",
Expand Down
7 changes: 5 additions & 2 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,11 @@
kbagent project remove --project NAME
Remove a project connection.

kbagent project edit --project NAME [--url URL] [--token TOKEN]
Edit project connection. Re-verifies token if changed.
kbagent project edit --project NAME [--url URL] [--token TOKEN] [--new-alias NEW]
Edit project connection. Re-verifies token if changed. --new-alias renames
the alias and cascades the rename through config.json and the nested sync
directory at <cwd>/<old-alias>/. Lineage cache embeds the alias in FQNs
and is NOT auto-updated; rebuild via `kbagent lineage build` after rename.

kbagent project status [--project NAME]
Test connectivity. Shows OK/ERROR with response time.
Expand Down
68 changes: 62 additions & 6 deletions src/keboola_agent_cli/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,22 +227,78 @@ def project_edit(
None,
help="New Storage API token",
),
new_alias: str | None = typer.Option(
None,
"--new-alias",
help=(
"Rename the project alias. Updates the config.json projects key "
"AND the default_project field if it matched. Renames the nested "
"sync directory <cwd>/<old-alias>/ when present (with -2-suffix "
"collision handling). Lineage cache (if any) is NOT auto-updated; "
"rebuild with 'kbagent lineage build' after the rename."
),
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help=(
"Preview the edit without mutating state. Validates --new-alias, "
"detects collision against existing projects, predicts the disk-"
"rename method (git_mv vs shutil_move), and surfaces the lineage-"
"cache warning if any -- all read-only. Errors (collision, "
"invalid format) raise the same exit codes as the live path. No "
"API call is made for --token in dry-run mode."
),
),
) -> None:
"""Edit an existing Keboola project connection.

If --token is provided, the token is re-verified against the API.
Combined with --new-alias, the rename is applied first and any
--url / --token mutation lands on the new alias key. Pass --dry-run
to preview without mutating state.
"""
formatter = get_formatter(ctx)
service = get_service(ctx, "project_service")

try:
result = service.edit_project(alias=alias, stack_url=url, token=token)
formatter.output(
result,
lambda c, d: c.print(
f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] updated."
),
result = service.edit_project(
alias=alias,
stack_url=url,
token=token,
new_alias=new_alias,
dry_run=dry_run,
)

def _human(c: Console, d: dict) -> None:
if d.get("dry_run"):
planned = d.get("planned", {})
p_new = planned.get("new_alias")
if p_new:
c.print(
f"[bold yellow]DRY RUN:[/bold yellow] Project "
f"[bold]{d['alias']}[/bold] would be renamed to "
f"[bold]{p_new}[/bold]. No state mutated."
)
else:
c.print(
f"[bold yellow]DRY RUN:[/bold yellow] Project "
f"[bold]{d['alias']}[/bold] would be updated. "
"No state mutated."
)
return
if "old_alias" in d:
c.print(
f"[bold green]Success:[/bold green] Project "
f"[bold]{d['old_alias']}[/bold] renamed to "
f"[bold]{d['alias']}[/bold]."
)
else:
c.print(
f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] updated."
)

formatter.output(result, _human)
except KeboolaApiError as exc:
exit_code = map_error_to_exit_code(exc)
formatter.error(
Expand Down
30 changes: 30 additions & 0 deletions src/keboola_agent_cli/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,33 @@ def edit_project(self, alias: str, **kwargs: str | int | None) -> None:
setattr(project, key, value)
config.projects[alias] = project
self.save(config)

def rename_project(self, old_alias: str, new_alias: str) -> None:
"""Rename a project alias in the persisted config.

Pops ``old_alias`` from the projects dict and re-inserts the same
``ProjectConfig`` under ``new_alias``. If ``default_project`` was
set to ``old_alias``, it is updated to ``new_alias`` so the pin
survives the rename. Both mutations are applied to the same
in-memory ``AppConfig`` and saved as one transaction.

Args:
old_alias: The current alias to rename from.
new_alias: The target alias to rename to.

Raises:
ConfigError: If ``old_alias`` does not exist or ``new_alias``
is already in use by another project.
"""
config = self.load()
if old_alias not in config.projects:
raise ConfigError(f"Project '{old_alias}' not found.")
if new_alias in config.projects:
raise ConfigError(
f"Cannot rename '{old_alias}' to '{new_alias}': "
f"alias '{new_alias}' is already in use."
)
config.projects[new_alias] = config.projects.pop(old_alias)
if config.default_project == old_alias:
config.default_project = new_alias
self.save(config)
Loading
Loading