Skip to content
Draft
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.53.0",
"version": "0.54.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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,10 @@ plugins/kbagent/
# Headless / token-only (0.50.0+): export KBAGENT_PROJECT_FROM_ENV=1 + KBC_TOKEN + KBC_STORAGE_API_URL to synthesize an in-memory `__env__` project (no `project add`, no config.json on disk; token never persisted). Use `--project __env__`. Same env setup also powers `kbagent serve`.

kbagent project add --project NAME --url URL --token TOKEN
kbagent project login [--url URL] [--project ALIAS] [--port N] [--no-browser] [--timeout SECONDS]
# login (0.54.0+): browser OAuth + PKCE against connection.<stack>/oauth/authorize -- user logs in,
# picks the project, kbagent gets a refresh token + minted Storage token (silently auto-renewed at
# resolve time). INTERACTIVE only; needs the kbagent public OAuth client registered on the stack.
kbagent project list
kbagent project remove --project NAME
kbagent project edit --project NAME [--url URL] [--token TOKEN] [--new-alias NEW]
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.53.0",
"version": "0.54.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
33 changes: 16 additions & 17 deletions plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,33 +51,29 @@ a critical failure.
exists, use it. Only fall back to `kbagent tool call ...` (MCP) when
the native command does not cover the operation. When an MCP
`tool call` returns `isError: true`, DO NOT retry with reformatted
inputs. Immediately switch to `kbagent --hint client <cmd>` and
execute via direct `KeboolaClient`. (Note: `--hint` is deprecated since
0.45.0 in favor of the `kbagent serve` REST API; it still works but warns.)
inputs; use the `kbagent serve` REST API (or the deprecated
`kbagent --hint client <cmd>` snippet generator) instead.

5. **PREFER CLI OVER REST**. NEVER write `curl`, `httpx`, or `requests`
calls against `*.keboola.com` URLs. Not in shell. Not in Python
snippets. Not in plans. If the CLI lacks the command, use
`kbagent --hint client` to generate a `KeboolaClient`-based snippet.
(`--hint` is deprecated since 0.45.0; prefer `kbagent serve` REST API for
new integrations.)
snippets. Not in plans. If the CLI lacks the command, use the
`kbagent serve` REST API (`--hint client` still works but is
deprecated since 0.45.0).

6. **VERSION GATE**. On first invocation in a session, run
`kbagent --json context` and inspect the version. If missing commands
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+, `config update` script[] auto-normalize (#245) needs
0.28.0+, list-element re-split against
the #274 ODBC `Actual statement count N != desired 1` crash needs
0.28.0+, list-element re-split (ODBC statement-count crash, #274) needs
0.31.0+, `storage swap-tables` needs 0.28.0+, `storage clone-table` = 0.52.0+,
`project login` (browser OAuth + PKCE) = 0.54.0+,
env-var manage-token auth for `org setup` / `project refresh` /
`data-app password` needs 0.29.0+ with `--allow-env-manage-token`,
`project invite` / `project member-*` / `project invitation-*`
need 0.29.0+,
`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 invite` / `project member-*` / `project invitation-*` /
`data-app secrets-*` / `validate-repo` need 0.29.0+,
`search`, `project info`, `config row-*`, `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+,
`storage truncate-table` needs 0.32.0+,
Expand All @@ -87,10 +83,9 @@ a critical failure.
`semantic-layer` command group needs 0.41.0+:
- model lifecycle: `model list / create / delete`
- read: `show`, `validate [--deep]`, `export`, `diff`
- write: `add metric|dataset|relationship|constraint|glossary`,
`edit metric|dataset|constraint|relationship|glossary`,
- write/destructive: `add/edit/remove
metric|dataset|relationship|constraint|glossary`,
`import`, `promote`, `build`, `token --encrypt`
- destructive: `remove metric|dataset|constraint|relationship|glossary`
- alias: `kbagent sl ...` = `kbagent semantic-layer ...`
- `semantic-layer build` is heuristic-only on 0.41.0+ (one dataset + one COUNT(*) metric + one glossary entry per table; not a version gate),
`kbagent http get/post/patch/delete <PATH>` (self-call against the
Expand Down Expand Up @@ -312,6 +307,10 @@ success, not a failure.
stranded the edits). Safe to force-pull an unrelated config while you have
un-pushed edits elsewhere. To discard a local edit on purpose, delete the
file/dir then pull. See `gotchas.md`.
- **`project login` is INTERACTIVE-ONLY** (0.54.0+): browser OAuth + PKCE.
NEVER run it yourself; on `OAUTH_ERROR` (exit 3) / persistent 401 the
refresh token died -- tell the user to re-run `kbagent project login
--url <stack>`. Automation: `project add --token`. See `gotchas.md`.

- **`storage truncate-table` is row-only; schema and dependents are
preserved** (0.32.0+): the underlying call is
Expand Down
5 changes: 4 additions & 1 deletion plugins/kbagent/skills/kbagent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ description: >
developer portal, dev-portal, apps-api, register component, vendor app,
portal property, ui-options, encryption portal, defaultBucket portal,
app icon, configurationSchema portal, publish component, deprecate component,
kbagent dev-portal, portal identity, vendor login, service account portal.
kbagent dev-portal, portal identity, vendor login, service account portal,
project login, browser login, oauth login, OAuth project authorization,
PKCE, log into keboola, login keboola project, refresh token expired.
---

# kbagent -- Keboola Agent CLI
Expand Down Expand Up @@ -117,6 +119,7 @@ When working inside a git repository or project directory, run `kbagent init` (o
| Set the permission policy (firewall rules) | `kbagent permissions set --mode MODE` |
| Remove all permission restrictions | `kbagent permissions reset` |
| Check if a specific operation is allowed | `kbagent permissions check <OPERATION>` |
| Log into a Keboola project via the browser (OAuth + PKCE) | `kbagent project login` |
| Add a new Keboola project connection | `kbagent project add --project ALIAS` |
| List all connected Keboola projects | `kbagent project list` |
| Remove a Keboola project connection | `kbagent project remove --project ALIAS` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All commands support `--json` for structured output. Multi-project flags (`--pro

## Project Management
- `project add --project NAME --url URL --token TOKEN` -- connect a project (token verified via API)
- `project login [--url URL] [--project ALIAS] [--port N] [--no-browser] [--timeout SECONDS]` -- browser OAuth project authorization (since v0.54.0). Authorization Code + PKCE against `connection.<stack>/oauth/authorize`; the user logs in and picks the project on the consent screen, kbagent receives the code on a `127.0.0.1` loopback callback, stores the OAuth refresh token + a minted short-lived Storage token that silently auto-renews at resolve time. **INTERACTIVE only** -- a human must complete the browser flow; agents use `project add` with a provided token instead. Requires the kbagent public OAuth client registered on the stack (override id via `KBAGENT_OAUTH_CLIENT_ID`). Re-login into a registered project updates it in place. `--no-browser` prints the URL for manual opening (remote/SSH)
- `project list` -- list all connected projects (tokens masked)
- `project remove --project NAME` -- disconnect a project
- `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.31.0). 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
Expand Down
21 changes: 21 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,27 @@ Versioning convention:
behavior; the inline `(updated vX.Y.Z)` records when the refinement landed.
-->

## `project login` is interactive-only; its minted token self-renews but the session can still die (since v0.54.0)

- `kbagent project login` runs a browser OAuth flow (Authorization Code + PKCE) -- it
REQUIRES a human at a browser. An AI agent must never invoke it to "fix" auth;
the fallback for automation is `project add --token` / headless env mode.
- Projects added via login carry an `oauth` block in config.json and their
`token` is a SHORT-LIVED minted Storage token (~2h). It silently renews at
project-resolve time, so commands just work -- but if the stack reports
`OAUTH_ERROR` (exit 3) or a 401 persists across retries, the refresh token
itself has expired or been revoked (~1 month idle): tell the user to re-run
`kbagent project login --url <stack>`. Do not try to repair the oauth block
by hand-editing config.json.
- The flow only works on stacks where the kbagent public OAuth client is
registered. `OAUTH_ERROR` mentioning `invalid_client` on `/oauth/authorize`
means the stack does not have the registration yet -- fall back to
`project add`.
- Concurrent kbagent processes coordinate refresh-token rotation via an
flock on `<config_dir>/.oauth-refresh.lock`; if a shared config dir lives on
a filesystem without POSIX locks (some network mounts), parallel refreshes
can race and kill the session (symptom: spurious re-login prompts).

## `sync push` fresh-CREATE writeback now updates placeholders in place (since v0.47.0)

Before v0.47.0, `kbagent sync push` always **appended** new `ManifestConfiguration`
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.53.0"
version = "0.54.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
24 changes: 24 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.54.0": [
"New `kbagent project login` -- browser OAuth project authorization (Authorization Code + "
"PKCE, RFC 7636/8252) against the Connection OAuth server (the same League OAuth2 server "
"the remote MCP server authenticates to). The CLI opens the stack login page, the user "
"authenticates and selects a project on the consent screen, and kbagent receives the "
"authorization code on a 127.0.0.1 loopback callback -- no manual token copying. Public "
"client, NO client_secret. kbagent persists the OAuth refresh token (config.json, 0600) "
"plus a short-lived Storage API token minted from the OAuth access token via `POST "
"/v2/storage/tokens` with `Authorization: Bearer` (the production MCP-server pattern -- "
"Queue API and AI Service do not accept Bearer yet, so the minted token keeps every "
"existing command path unchanged). The minted token auto-renews silently at project "
"resolve time (`BaseService.resolve_projects()` chokepoint -- covers CLI, the MCP "
"subprocess env, and `kbagent serve`), with refresh-token rotation persisted under an "
"inter-process flock (the OAuth server revokes the old refresh token on rotation, so "
"concurrent kbagent processes must not race). Re-login into an already-registered "
"project updates the entry in place (preserves active_branch_id; never duplicates). "
"New ErrorCode `OAUTH_ERROR` (maps to exit 3, auth). PREREQUISITE: the stack's "
"Connection must have the kbagent public OAuth client registered "
"(`league:oauth2-server:create-client ... --public` with loopback redirect URIs "
"http://127.0.0.1:8765-8769/callback whitelisted, authorization_code + refresh_token "
"grants); until the cross-stack registration name is finalized, override the client id "
"via KBAGENT_OAUTH_CLIENT_ID. Tests include a full real-HTTP protocol round-trip "
"against an in-repo fake Connection OAuth server that enforces PKCE S256.",
],
"0.53.0": [
'Fix (`sync pull --force`, silent baseline corruption -> data loss): a config with un-pushed local edits is no longer silently de-synced when a force-pull runs while the remote is unchanged. Repro (reported on v0.51.1, project 5785): pull a config, edit its `_config.yml` (`sync diff` correctly shows `1 to update`), then run `sync pull --force` -- typically to resolve an *unrelated* config\'s conflict. Pre-0.53.0, `--force` skipped the "locally modified" guard in `SyncService.pull()`, so for a config whose remote had not changed the `remote_unchanged` short-circuit re-stamped the manifest `pull_hash` from the *edited on-disk file*. Afterwards `sync diff` and `sync push` both reported "in sync" and a real `push` shipped nothing, while the live remote still held the old config -- the local edits were stranded with no visible signal. Root cause was an interaction of two individually-reasonable decisions: `--force` bypassing the overwrite guard, and the `diff` `local_override_hashes` optimization that skips re-reading a file whose hash matches `pull_hash` (so the edited content was never even compared). The fix splits `--force` behaviour by 3-way diff state, per the maintainer decision: (b) local edited + remote UNCHANGED -> the file AND its 3-way base (`pull_hash` + `pull_config_hash`) are preserved, so the pending delta stays visible to `sync push` (no data loss, no silent revert); (a) local edited + remote ALSO changed since the last pull (a true merge conflict) -> the pull aborts before writing anything with the new `SyncConflictError` (exit 1, error code `SYNC_CONFLICT`), listing every conflicting config/row so the user resolves it (`sync diff`, then `push` or discard, then pull again). A no-conflict force-pull (remote changed, local untouched) still takes remote as before. Applies at config and row granularity. Note: `--force` no longer discards un-pushed non-conflicting edits -- that was the dangerous behaviour; to intentionally drop local edits, delete the file (or the config dir) and pull. New: `errors.SyncConflictError` + `ErrorCode.SYNC_CONFLICT`; `SyncService._detect_force_pull_conflicts` / `_is_conflict` (read-only pre-pass that runs before any write); `commands/sync.py` catches the error and prints a red per-config conflict block (human) or a `SYNC_CONFLICT` envelope with a `details.conflicts` array (`--json`). The pull `--force` help now documents the preserve/abort semantics. `--all-projects` surfaces a per-project conflict as a structured entry (`error_code: SYNC_CONFLICT` + the `conflicts` list, matching the single-project envelope) without aborting the batch. Tests: `tests/test_sync_force_pull_baseline.py` (config + row, preserve case b, abort case a, remote-only-changed takes remote, `--all-projects` structured conflict), `tests/test_sync_cli.py` (exit 1 + human/JSON conflict envelope), and `tests/test_e2e.py::TestE2ESyncWorkflow::test_sync_force_pull_conflict_aware` (real Storage: preserve when remote unchanged, then `SYNC_CONFLICT` after a remote mutation).',
],
Expand Down
3 changes: 3 additions & 0 deletions src/keboola_agent_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from .services.lineage_service import LineageService
from .services.mcp_service import McpService
from .services.member_service import MemberService
from .services.oauth_login_service import OAuthLoginService
from .services.org_service import OrgService
from .services.project_service import ProjectService
from .services.repo_validate_service import RepoValidateService
Expand Down Expand Up @@ -339,6 +340,7 @@ def main(
config_store = ConfigStore(config_dir=resolved_dir, source=source)

project_service = ProjectService(config_store=config_store)
oauth_login_service = OAuthLoginService(config_store=config_store)
component_service = ComponentService(config_store=config_store)
config_service = ConfigService(config_store=config_store)
job_service = JobService(config_store=config_store)
Expand Down Expand Up @@ -401,6 +403,7 @@ def main(
ctx.obj["allow_env_manage_token"] = allow_env_manage_token
ctx.obj["config_store"] = config_store
ctx.obj["project_service"] = project_service
ctx.obj["oauth_login_service"] = oauth_login_service
ctx.obj["component_service"] = component_service
ctx.obj["config_service"] = config_service
ctx.obj["job_service"] = job_service
Expand Down
4 changes: 2 additions & 2 deletions src/keboola_agent_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def get_service(ctx: typer.Context, key: str) -> Any:
def map_error_to_exit_code(exc: KeboolaApiError) -> int:
"""Map a KeboolaApiError to a CLI exit code.

- INVALID_TOKEN -> 3 (authentication error)
- INVALID_TOKEN / MISSING_MASTER_TOKEN / OAUTH_ERROR -> 3 (authentication error)
- TIMEOUT / CONNECTION_ERROR / RETRY_EXHAUSTED / QUEUE_JOB_TIMEOUT -> 4
(network/retryable; QUEUE_JOB_TIMEOUT means local gave up AND the
remote-kill attempt also failed, so the job may still be running)
Expand All @@ -95,7 +95,7 @@ def map_error_to_exit_code(exc: KeboolaApiError) -> int:
job; scripts can distinguish "we killed it" from "it failed on its own")
- Everything else -> 1 (general error)
"""
if exc.error_code in ("INVALID_TOKEN", "MISSING_MASTER_TOKEN"):
if exc.error_code in ("INVALID_TOKEN", "MISSING_MASTER_TOKEN", "OAUTH_ERROR"):
return 3
if exc.error_code in (
"TIMEOUT",
Expand Down
10 changes: 10 additions & 0 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@
kbagent project add --project NAME --url URL --token TOKEN
Add a new project connection. Token verified against API.

kbagent project login [--url URL] [--project ALIAS] [--port N] [--no-browser] [--timeout SECONDS]
Browser OAuth login (PKCE, since 0.54.0): opens the stack login page, the
user picks the project, kbagent receives credentials on a localhost
callback -- no manual token copying. Stores a refresh token and a
short-lived minted Storage token that auto-renews silently; when the
refresh token eventually expires (~1 month idle), re-run login.
INTERACTIVE: requires a human at a browser -- agents must never drive it;
fall back to `project add` with a provided token in automation. Requires
the stack to have the kbagent public OAuth client registered.

kbagent project list
List all connected projects (tokens always masked).

Expand Down
5 changes: 5 additions & 0 deletions src/keboola_agent_cli/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,14 @@
should_hint,
)
from ._metadata_input import resolve_text_input
from .project_login import project_login

project_app = typer.Typer(help="Manage connected Keboola projects")

# Registered here (defined in project_login.py) because this file is over its
# size budget; the command still lives under `kbagent project ...`.
project_app.command("login")(project_login)


@project_app.callback(invoke_without_command=True)
def _project_permission_check(ctx: typer.Context) -> None:
Expand Down
Loading
Loading