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.63.3",
"version": "0.63.4",
"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.63.3",
"version": "0.63.4",
"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
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-cli"
version = "0.63.3"
version = "0.63.4"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand All @@ -26,7 +26,8 @@ dependencies = [

[project.optional-dependencies]
server = [
"fastapi>=0.115",
# Cap <0.137: fastapi 0.137 drops auth on serve --ui protected endpoints (GHSA-ffpq-prmh-3gx2).
"fastapi>=0.115,<0.137",
"uvicorn[standard]>=0.30",
"sse-starlette>=2.1",
"python-multipart>=0.0.31",
Expand Down
2 changes: 1 addition & 1 deletion scripts/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ def run_benchmarks(project: str, runs: int = 1, run_multi: bool = False):
status = "ok" if all_ok else "FAIL"

# Calculate vs stdio
stdio_total = stdio_results[i]["total"]
stdio_total = float(stdio_results[i]["total"])
vs_stdio = ((avg_total - stdio_total) / stdio_total) * 100

http_results.append(
Expand Down
5 changes: 3 additions & 2 deletions scripts/check_command_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@

from keboola_agent_cli.cli import app
from keboola_agent_cli.commands.context import AGENT_CONTEXT
from keboola_agent_cli.commands.repl import _is_group
from keboola_agent_cli.permissions import OPERATION_REGISTRY

REPO_ROOT = Path(__file__).resolve().parent.parent
Expand Down Expand Up @@ -83,7 +84,7 @@ def _walk(
if cmd is None or getattr(cmd, "hidden", False):
continue
path = (*prefix, name)
if isinstance(cmd, click.Group):
if _is_group(cmd):
groups.append(path)
with click.Context(cmd, parent=ctx) as sub_ctx:
sub_leaves, sub_groups = _walk(cmd, sub_ctx, path)
Expand All @@ -97,7 +98,7 @@ def _walk(
def collect_commands() -> tuple[list[CommandPath], list[CommandPath]]:
"""Return (leaf_paths, group_paths) for the live CLI command tree."""
click_app = typer.main.get_command(app)
assert isinstance(click_app, click.Group)
assert _is_group(click_app)
with click.Context(click_app) as ctx:
return _walk(click_app, ctx)

Expand Down
15 changes: 9 additions & 6 deletions scripts/generate_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import typer.main

from keboola_agent_cli.cli import app
from keboola_agent_cli.commands.repl import _is_group

# ---------------------------------------------------------------------------
# Paths
Expand Down Expand Up @@ -67,19 +68,21 @@ def _format_param(param: click.Parameter) -> str:
Required params get a placeholder value (e.g. --alias NAME).
Optional params are omitted from the compact command string.
"""
if isinstance(param, click.Argument):
# Use param_type_name (not isinstance against click.Argument/Option): Typer >=0.25
# vendors its own Click, so a command's params are not standalone click.* instances.
if param.param_type_name == "argument":
human_name = (param.name or "").upper().replace("_", "-")
return f"<{human_name}>" if param.required else f"[{human_name}]"

if isinstance(param, click.Option):
if param.param_type_name == "option":
if not param.required:
return ""
# Use the longest option string (e.g. --alias over -a)
opt_str = max(param.opts, key=lambda o: len(o))
# Convert underscores to hyphens for display
opt_str = opt_str.replace("_", "-")
human_name = param.human_readable_name.upper().replace("_", "-")
if param.is_flag:
if getattr(param, "is_flag", False):
return opt_str
return f"{opt_str} {human_name}"

Expand All @@ -104,7 +107,7 @@ def _collect_commands(

full_name = f"{prefix} {name}".strip() if prefix else name

if isinstance(cmd, click.Group):
if _is_group(cmd):
# If the group has invoke_without_command and its own non-trivial
# params, it acts as a standalone command too (e.g. `explorer`
# has --project, --output-dir, etc.). Groups whose callback is
Expand All @@ -116,7 +119,7 @@ def _collect_commands(
p
for p in cmd.params
if p.name not in ("help", "ctx")
and not (isinstance(p, click.Option) and p.name == "help")
and not (p.param_type_name == "option" and p.name == "help")
]
if own_params:
help_text = cmd.help or ""
Expand Down Expand Up @@ -199,7 +202,7 @@ def main() -> None:
"""Entry point: introspect CLI, generate table, inject into SKILL.md."""
click_app = typer.main.get_command(app)

assert isinstance(click_app, click.Group)
assert _is_group(click_app)
with click.Context(click_app) as ctx:
commands = _collect_commands(click_app, ctx)

Expand Down
23 changes: 23 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.63.4": [
"Fix: the interactive REPL `help` command and tab-completion again list every "
"command instead of only `help`/`exit`. `_build_command_tree` guarded its walk "
"with `isinstance(x, click.Group)`; Typer >=0.25 vendors its own Click "
"(`typer._click`), so the `TyperGroup` from `typer.main.get_command` is not a "
"standalone `click.Group` subclass and the guard collapsed the tree to empty. "
"Replaced with a structural `_is_group()` TypeGuard; the previously swallowed "
"tree-build error is now written to stderr.",
"Fix: invalid `--mode`/`--poll-strategy` (`job run`), `--role`/`--default-role` "
"(`project invite`), `--role` (`project member-set-role`) and `--role-hint` "
"(`dev-portal identity add/edit`) values again fail with a clean exit-2 usage "
"error instead of an uncaught traceback. The options passed a standalone "
"`click.Choice` into Typer; under a Click-vendoring Typer (>=0.25) the "
"`BadParameter` it raises is a different class than the one Typer's parser "
"catches, so it escaped unhandled. Replaced the `click.Choice` options with "
"`StrEnum` types so Typer builds and validates the choice with its own Click. "
"Valid values and `--help` were unaffected.",
"Security: cap `fastapi<0.137` in the `[server]` extra. With fastapi 0.137 "
"`serve --ui` stops requiring a token on protected endpoints -- `/doctor`, "
"`/version`, `/changelog`, `/agents` become reachable unauthenticated, reopening "
"GHSA-ffpq-prmh-3gx2 (fixed in an earlier release). Held until the `serve --ui` "
"auth check is updated for the newer fastapi.",
],
"0.63.3": [
"Fix: `kbagent context` no longer renders API path templates as "
"`/apps/<built-in function id>/logs/tail`. `AGENT_CONTEXT` is an f-string (it "
Expand Down
20 changes: 11 additions & 9 deletions src/keboola_agent_cli/commands/dev_portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@

import getpass
import sys
from enum import StrEnum
from typing import TYPE_CHECKING, Any

import click
import typer

from ..errors import ConfigError, ErrorCode, KeboolaApiError
Expand All @@ -28,15 +28,19 @@
resolve_identity_alias,
)


# CLI-layer enforcement of the role_hint enum. The Pydantic validator on
# DeveloperPortalIdentity intentionally silent-downgrades unknown values to
# "vendor" for backwards compatibility with pre-0.51.1 config.json files
# that may carry arbitrary free-text strings. That tolerance is wrong at the
# CLI surface, where the user just typed a value RIGHT NOW -- a typo should
# fail loudly, not silently land as "vendor" and confuse the next operation.
# Wiring `click.Choice` here gives the Typer-level rejection (exit 2 + usage
# error) before any model construction.
_ROLE_HINT_CHOICES = ["vendor", "admin"]
# A StrEnum option type gives the Typer-level rejection (exit 2 + usage error)
# before any model construction.
class RoleHint(StrEnum):
vendor = "vendor"
admin = "admin"


dev_portal_app = typer.Typer(
help="Keboola Developer Portal — multi-identity, production-safe writes.",
Expand Down Expand Up @@ -98,10 +102,9 @@ def identity_add(
"--password-stdin",
help="Read password from stdin. On a TTY this is a hidden prompt (Enter to confirm); on a pipe it reads until EOF (e.g. `echo $PASS | … --password-stdin`).",
),
role_hint: str = typer.Option(
"vendor",
role_hint: RoleHint = typer.Option(
RoleHint.vendor,
"--role-hint",
click_type=click.Choice(_ROLE_HINT_CHOICES),
help="Identity role: 'vendor' (default) or 'admin'. Routes write commands to different apps-api endpoints -- admin uses PATCH /admin/apps/{app} which accepts complexity/categories/forwardToken/processTimeout/etc. that the vendor endpoint forbids.",
),
vendor: str | None = typer.Option(None, "--vendor"),
Expand Down Expand Up @@ -178,10 +181,9 @@ def identity_edit(
username: str | None = typer.Option(None, "--username"),
password: str | None = typer.Option(None, "--password"),
password_stdin: bool = typer.Option(False, "--password-stdin"),
role_hint: str | None = typer.Option(
role_hint: RoleHint | None = typer.Option(
None,
"--role-hint",
click_type=click.Choice(_ROLE_HINT_CHOICES),
),
vendor: str | None = typer.Option(None, "--vendor"),
new_alias: str | None = typer.Option(None, "--new-alias"),
Expand Down
30 changes: 21 additions & 9 deletions src/keboola_agent_cli/commands/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
No business logic belongs here.
"""

import click
from enum import StrEnum

import typer
from rich.markup import escape

Expand All @@ -18,8 +19,6 @@
KILLABLE_JOB_STATUSES,
MAX_JOB_LIMIT,
MAX_LOG_TAIL_LINES,
VALID_JOB_MODES,
VALID_POLL_STRATEGIES,
VALID_STATUSES,
)
from ..errors import ConfigError, ErrorCode, KeboolaApiError
Expand All @@ -34,6 +33,21 @@
validate_branch_requires_project,
)


class JobMode(StrEnum):
"""Queue API job mode."""

run = "run"
debug = "debug"


class PollStrategy(StrEnum):
"""Polling cadence for --wait."""

exponential = "exponential"
fixed = "fixed"


job_app = typer.Typer(help="Browse job history and run jobs")


Expand Down Expand Up @@ -183,10 +197,9 @@ def job_run(
"--branch",
help="Dev branch ID (overrides active branch)",
),
mode: str = typer.Option(
DEFAULT_JOB_MODE,
mode: JobMode = typer.Option(
JobMode(DEFAULT_JOB_MODE),
"--mode",
click_type=click.Choice(sorted(VALID_JOB_MODES)),
help=(
"Queue API job mode. 'run' (default) executes the component "
"normally and writes to mapped output tables. 'debug' executes "
Expand Down Expand Up @@ -216,10 +229,9 @@ def job_run(
"--variable-values-id."
),
),
poll_strategy: str = typer.Option(
DEFAULT_POLL_STRATEGY,
poll_strategy: PollStrategy = typer.Option(
PollStrategy(DEFAULT_POLL_STRATEGY),
"--poll-strategy",
click_type=click.Choice(sorted(VALID_POLL_STRATEGIES)),
help=(
"Polling cadence used with --wait. 'exponential' (default) "
"starts at 2s and relaxes toward 15s as a job runs long "
Expand Down
21 changes: 14 additions & 7 deletions src/keboola_agent_cli/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
"""

import sys
from enum import StrEnum
from pathlib import Path
from typing import Any

import click
import typer
from rich.console import Console
from rich.table import Table
Expand All @@ -31,6 +31,16 @@
)
from ._metadata_input import resolve_text_input


class ProjectRole(StrEnum):
"""Project membership role."""

admin = "admin"
guest = "guest"
readOnly = "readOnly"
share = "share"


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


Expand Down Expand Up @@ -890,11 +900,10 @@ def project_invite(
email: str | None = typer.Option(
None, "--email", "-e", help="Email address of the user to invite"
),
role: str | None = typer.Option(
role: ProjectRole | None = typer.Option(
None,
"--role",
"-r",
click_type=click.Choice(list(PROJECT_ROLES)),
help="Role to grant: " + " | ".join(PROJECT_ROLES),
),
reason: str | None = typer.Option(
Expand All @@ -905,10 +914,9 @@ def project_invite(
"--from-csv",
help="CSV file with columns email, project (alias or numeric ID), role[, reason]",
),
default_role: str | None = typer.Option(
default_role: ProjectRole | None = typer.Option(
None,
"--default-role",
click_type=click.Choice(list(PROJECT_ROLES)),
help="Role to apply when a CSV row has no role column",
),
workers: int = typer.Option(
Expand Down Expand Up @@ -1145,11 +1153,10 @@ def project_member_set_role(
ctx: typer.Context,
project: str = typer.Option(..., "--project", "-p", help="Project alias"),
email: str = typer.Option(..., "--email", "-e", help="Email of the member to update"),
role: str = typer.Option(
role: ProjectRole = typer.Option(
...,
"--role",
"-r",
click_type=click.Choice(list(PROJECT_ROLES)),
help="New role: " + " | ".join(PROJECT_ROLES),
),
) -> None:
Expand Down
17 changes: 11 additions & 6 deletions src/keboola_agent_cli/commands/repl.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import shlex
import sys
from pathlib import Path
from typing import TypeGuard

import click
import platformdirs
Expand All @@ -23,6 +24,11 @@
from ._helpers import get_formatter


def _is_group(cmd: object) -> TypeGuard[click.Group]:
"""True if ``cmd`` is a command group (duck-typed; Typer >=0.25 vendors Click)."""
return callable(getattr(cmd, "list_commands", None))


def _build_command_tree(click_group: click.Group, prefix: str = "") -> dict:
"""Walk Typer/Click command tree and return a dict of command paths to help strings."""
tree: dict[str, str] = {}
Expand All @@ -35,10 +41,11 @@ def _build_command_tree(click_group: click.Group, prefix: str = "") -> dict:
full_name = f"{prefix}{name}"
help_text = cmd.get_short_help_str(limit=80)
tree[full_name] = help_text
if isinstance(cmd, click.Group):
if _is_group(cmd):
tree.update(_build_command_tree(cmd, f"{full_name} "))
except Exception:
pass
except Exception as exc:
# Report rather than degrade silently to an empty tree.
sys.stderr.write(f"kbagent: failed to build REPL command tree: {exc!r}\n")
return tree


Expand Down Expand Up @@ -110,9 +117,7 @@ def _run_repl(

# Build command tree for completion
click_app = typer.main.get_command(typer_app)
# typer.main.get_command returns a click.Group for multi-command Typer apps.
# The isinstance guard narrows the type and gracefully handles test mocks.
command_tree = _build_command_tree(click_app) if isinstance(click_app, click.Group) else {}
command_tree = _build_command_tree(click_app) if _is_group(click_app) else {}

# Add REPL-specific commands
command_tree["help"] = "Show available commands"
Expand Down
Loading