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.60.4",
"version": "0.61.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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ kbagent init --from-global --read-only

Three protection layers (kbagent policy + filesystem chmod + Claude Code deny rules) prevent the agent from writing, deleting, or bypassing restrictions. See [Permissions Guide](docs/guide.md#permissions) for details.

## Use as a library

Besides the CLI and `kbagent serve`, kbagent exposes a small **stateless, importable client** for in-process use -- a Keboola Data App, a transformation, or any Python service can run Query Service SQL and read/write Storage Files without spawning the CLI, running the daemon, or maintaining a config-dir. Auth is the storage token you pass in (12-factor); nothing is written to disk.

```python
import os
from keboola_agent_cli import Client

with Client(url=os.environ["KBC_URL"], token=os.environ["KBC_TOKEN"]) as kbc:
rows = kbc.query(workspace_id, "SELECT id, name FROM customers") # list[dict]

meta = kbc.files.upload(b"hello", name="greeting.txt", tags=["demo"])
data = kbc.files.read_bytes(meta.id) # bytes
files = kbc.files.list(tags=["demo"]) # list[FileEntry]
```

`query()` reads results inline (fast, native JSON types) and returns rows keyed by column name; `files` returns a uniform `FileEntry` shape and reads bytes straight into memory. Everything exported from `keboola_agent_cli` is committed public API (semver). For lower-level endpoints, reach for `Client.raw` (the underlying `KeboolaClient`).

## 30-second demo

![30-second demo](docs/assets/demo-readme-main.gif)
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.60.4",
"version": "0.61.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
1 change: 1 addition & 0 deletions plugins/kbagent/agents/keboola-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ a critical failure.
| Re-seed a table without losing its schema / PK / dependents | `kbagent storage truncate-table --project P --table-id in.c-foo.data [--branch ID] [--dry-run] [--yes]` (0.32.0+) -- DELETE `/tables/{id}/rows?allowTruncate=1`; endpoint is uniformly async on every branch (returns a queued `tableRowsDelete` job; client polls via `_wait_for_storage_job`). Do NOT pass `async=true` -- the API rejects it. Batch via repeated `--table-id`. Returns `{truncated[], failed[], dry_run, project_alias}` with `truncated[]` entries carrying `{table_id, rows_before, rows_after, branch_id}`. Permission class: `destructive` | `tool call delete_table_rows` if the upstream MCP exposes it | drop + recreate the table (loses descriptions, PK, sharing edges, and breaks every downstream config reference); deleting rows via raw SQL in a workspace (bypasses the Storage API audit trail) |
| Debug a failed job | `kbagent job detail --project P --job-id J --json` + `kbagent job run ... --log-tail-lines 200` | `kbagent workspace from-transformation` for SQL repro | "I think the issue is..." without reading logs |
| Ad-hoc SQL / row-count / type audit | `kbagent workspace create` + `kbagent workspace load` + `kbagent workspace query --sql "..."` (0.59.0+: results come back inline+fast but **capped at `--limit`, default 500** -- check `statements[].truncated`/`total_rows`, use `COUNT(*)` for counts, `--full` for the complete set) | `kbagent workspace from-transformation` for existing transform debugging; `workspace list --qs-compatible` (0.42.0+, #304) for data-app reuse | trusting a default `SELECT *` as the full result (it is truncated at 500); querying Storage via raw Snowflake credentials outside the workspace abstraction |
| Run Keboola SQL or read/write Storage Files from INSIDE a Python process you control (Data App, transformation, hosted service) | `from keboola_agent_cli import Client` (0.61.0+) -- stateless `Client(url, token)`; `.query(workspace_id, sql) -> list[dict]`, `.files.upload(path_or_bytes)` / `.files.read_bytes(id) -> bytes` / `.files.list() -> [FileEntry]`; no CLI subprocess, no `serve`, no config-dir | the `kbagent` CLI or `kbagent serve` REST when you are NOT already inside Python | shelling out to the `kbagent` binary from a Python process you control (import the library instead); using it for AI-driven exploration (it is fixed typed ops, not MCP tools) |
| Inspect dev branch | `kbagent branch list --project P`, `kbagent branch use --project P --branch ID` | `tool call get_branch` | acting on `main` when a dev branch exists |
| Audit project capabilities / features | `kbagent project info --project P` (0.30.0+) -- returns project ID, name, backend, enabled features, quota limits, and metrics | `tool call verify_token` (returns less structured info; no feature list) | inspecting the UI project settings manually |
| Manage feature flags (stack catalogue / project / user) | `kbagent feature list\|project-show\|project-add\|project-remove\|user-show\|user-add\|user-remove --project P [--email E] [--feature NAME] [--dry-run] [--yes]` (0.48.0+) -- Manage API; needs a SUPER-ADMIN manage token (interactive prompt; `--allow-env-manage-token`+`KBC_MANAGE_API_TOKEN` for CI); `--project` resolves the stack URL (+project_id for `project-*`); add=admin, remove=destructive; add body is `{"feature":NAME}` | `kbagent project info` for a project's *enabled* features (read-only, no super-admin) | raw `/manage/...` calls; manage token via a CLI flag |
Expand Down
13 changes: 13 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -2663,3 +2663,16 @@ cross-project flow migration is a manual dance (`sync pull` source, edit,
A `sync pull` without the right flags leaves column metadata empty in the local
JSON. That does NOT mean Keboola has no metadata -- always re-fetch via
`kbagent storage table-detail` when deciding about types.

### `Client` library: `query()` needs a provisioned workspace; `branch_id=None` costs a branch-list call (since v0.61.0)

The in-process library facade (`from keboola_agent_cli import Client`, 0.61.0+)
is a thin wrapper, not a workspace manager. Two non-obvious behaviors:

- **`query(workspace_id, sql)` does NOT create a workspace.** The `workspace_id`
must already exist (make one via `kbagent workspace create` or the Storage
API first). An unknown id surfaces the Query Service error verbatim.
- **`Client(url, token)` with no `branch_id` resolves the default branch lazily
on the first `query()`** -- one extra `list_dev_branches` API call, cached
after. Pass `branch_id=` to skip it (and to target a dev branch). Storage
Files default to the production scope when `branch_id` is unset.
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.60.4"
version = "0.61.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
3 changes: 3 additions & 0 deletions src/keboola_agent_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
from importlib.metadata import PackageNotFoundError, version

from .constants import APP_NAME
from .lib import Client, FileEntry, Files

try:
__version__ = version(APP_NAME)
except PackageNotFoundError:
__version__ = "0.0.0-dev"

__all__ = ["Client", "FileEntry", "Files", "__version__"]
16 changes: 16 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.61.0": [
"New (#415): kbagent now ships a stateless, importable library facade -- "
"`from keboola_agent_cli import Client` -- so any in-process Python consumer (a Keboola "
"Data App, a transformation, a hosted service) can run Query Service SQL and read/write "
"Storage Files without a CLI subprocess, a `kbagent serve` daemon, or a config-dir. "
"`Client(url, token)` wraps the existing `KeboolaClient`; `client.query(workspace_id, sql)` "
"returns `list[dict]` rows over the fast inline `/results` path (native JSON types; "
"truncation is warned, never silently capped), and `client.files` offers "
"`upload(path_or_bytes)`, `read_bytes(file_id) -> bytes`, `list() -> list[FileEntry]` "
"(one uniform shape, read via `read_bytes` so callers never branch on a signed URL) and "
"`delete()`. The Query Service pagination helper moved from the workspace service into "
"`client.py` (re-exported, no behavior change) so the CLI and the library share one "
"implementation. Everything under `keboola_agent_cli.__all__` is committed public API. "
"Addresses the jasnost feedback points 1, 2, and 4 (point 3 -- structured query results -- "
"shipped in 0.59.0).",
],
"0.60.4": [
"Security: `kbagent serve --ui` no longer lets `GET /doctor`, `/version`, and `/changelog` "
"(and any other registered endpoint) bypass bearer auth. In single-process UI mode the auth "
Expand Down
75 changes: 75 additions & 0 deletions src/keboola_agent_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import re
import time
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import quote
Expand Down Expand Up @@ -47,6 +48,80 @@
logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class InlineQueryResult:
"""One statement's result fetched via the fast inline ``/results`` path."""

columns: list[dict[str, Any]] # [{"name", "type", "nullable"}]
rows: list[list[Any]] # row values, row-major; capped at the requested limit
total_rows: int | None # numberOfRows reported by the warehouse (full count)
truncated: bool # True when the warehouse has more rows than we fetched


def _collect_inline_results(
client: "KeboolaClient",
query_job_id: str,
statement_id: str,
limit: int,
) -> InlineQueryResult:
"""Page through ``GET .../results``, accumulating up to ``limit`` rows.

The endpoint enforces ``100 <= pageSize <= 100000``, so we always request a
fixed, valid ``QUERY_RESULTS_PAGE_SIZE`` page and cap the accumulated rows at
``limit`` locally -- deriving ``pageSize`` from a small ``limit`` (e.g. 5)
would trip the API's minimum with a 400. A ``limit`` larger than one page is
satisfied by walking ``offset``; we stop once the limit is reached (marking
the result truncated) or when the warehouse runs out of rows.

Lives in the client layer (not a service) because it is pure Query Service
pagination over :meth:`KeboolaClient.get_query_results` -- no config, no
business logic -- so both ``WorkspaceService`` and the public library facade
(:mod:`keboola_agent_cli.lib`) can share it.
"""
collected: list[list[Any]] = []
columns: list[dict[str, Any]] = []
total_rows: int | None = None
offset = 0
exhausted = False
while len(collected) < limit:
payload = client.get_query_results(
query_job_id, statement_id, offset=offset, page_size=QUERY_RESULTS_PAGE_SIZE
)
if not columns:
columns = payload.get("columns", []) or []
if total_rows is None:
total_rows = payload.get("numberOfRows")
page_rows = payload.get("data", []) or []
collected.extend(page_rows)
# Last page: the warehouse returned fewer rows than a full page.
if len(page_rows) < QUERY_RESULTS_PAGE_SIZE:
exhausted = True
break
offset += len(page_rows)
# Reached the reported total on a page boundary: stop without spending a
# round-trip on the empty next page (e.g. total == a multiple of the
# page size, limit larger than total).
if total_rows is not None and offset >= total_rows:
exhausted = True
break

rows = collected[:limit]
if total_rows is not None:
truncated = total_rows > len(rows)
else:
# The Query Service normally reports numberOfRows, but if it omits the
# count we fall back to *how* the loop ended: stopping at the limit cap
# without exhausting a full last page means there may be more rows. Bias
# toward over-warning when the true count is unknown.
truncated = not exhausted and len(collected) >= limit
return InlineQueryResult(
columns=columns,
rows=rows,
total_rows=total_rows,
truncated=truncated,
)


def _iter_poll_intervals(strategy: str) -> Iterator[float]:
"""Yield sleep intervals (seconds) for Queue job polling.

Expand Down
Loading
Loading