diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 82f1a36d..51f5f0fd 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.59.0", + "version": "0.60.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/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..bbe09e5b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release wheel + +# Publish a prebuilt wheel as a GitHub Release asset so end users install a +# ready-made artifact instead of git-building from source on their machine. +# +# Why this exists (issue #353): `uv tool install git+https://github.com/keboola/cli` +# rebuilds the bundled React SPA via `npm ci` + `vite build` on EVERY install, +# and the uv cache does not cover the npm build. On WSL2 that is 2-4 minutes and +# blows past the auto-update timeout. Building the (universal `py3-none-any`) +# wheel ONCE here, on a fast Linux runner, turns the user-side install/update +# into a few-seconds download. `build_kbagent_upgrade_command` and the +# `install.sh` bootstrap both consume the asset this workflow uploads. + +on: + # Fires for both normal and prerelease (beta) releases -- betas get an asset + # too, so `kbagent update --beta` benefits from the same fast path. + release: + types: [published] + # Manual backfill: attach a wheel to an already-published release that predates + # this workflow (e.g. v0.59.0). Provide the existing tag as input. + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to build + attach the wheel to (e.g. v0.59.0)" + required: true + type: string + +permissions: + # Required for `gh release upload` to attach the asset. + contents: write + +jobs: + build-and-upload: + runs-on: ubuntu-latest + # Route the user-controllable tag through a job env var instead of inlining + # the GitHub expression into shell `run:` steps (Actions security hardening). + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + steps: + # Check out the EXACT released commit, not the default branch. The wheel + # version is derived from pyproject.toml at this ref, so it must match the + # tag or the upload step's version guard below fails. + - uses: actions/checkout@v5 + with: + ref: ${{ env.TAG }} + + - uses: astral-sh/setup-uv@v7 + with: + # Pin uv to download the release asset directly instead of fetching the + # rate-limited uv.ndjson manifest on shared CI egress IPs (see ci.yml). + version: "0.11.16" + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - uses: actions/setup-node@v6 + with: + # Node is needed for the hatch build hook to compile the SPA into the + # wheel (scripts/hatch_build.py). Without it the wheel ships UI-less. + node-version: "20" + package-manager-cache: false + + - name: Build the wheel (with bundled UI) + run: uv build --wheel + + - name: Assert the SPA is bundled + # Guards against shipping a UI-less wheel if the npm build silently + # degraded -- `kbagent serve --ui` would break for everyone otherwise. + run: python scripts/check_wheel_ui.py --expect-ui + + - name: Verify the wheel version matches the tag + # The asset URL that build_kbagent_upgrade_command / install.sh construct + # is `keboola_agent_cli--py3-none-any.whl` under the `v` + # tag. A mismatch here means clients would build a 404 URL, so fail loud. + run: | + tag="$TAG" + version="${tag#v}" + expected="dist/keboola_agent_cli-${version}-py3-none-any.whl" + if [ ! -f "$expected" ]; then + echo "::error::Expected $expected but built: $(ls dist/)" + exit 1 + fi + echo "OK: $expected matches tag $tag" + + - name: Upload the wheel to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # --clobber makes a re-run idempotent (overwrites instead of erroring on + # "asset already exists"). + run: gh release upload "$TAG" dist/*.whl --clobber diff --git a/CLAUDE.md b/CLAUDE.md index 80192880..09621aab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -569,6 +569,10 @@ kbagent update [--beta] # `--beta` (or env `KBAGENT_INCLUDE_PRERELEASE=1`) opts into pre-release versions # (PEP 440 betas/rc, e.g. 0.43.0b1). Default (no flag) is stable-only -- auto-update # startup hook never silently lands on a beta. +# Since 0.60.0 install + self-update prefer a prebuilt wheel Release asset (fast, no +# source build; falls back to git+ when absent). Env `KBAGENT_UPDATE_TIMEOUT` (integer +# seconds, default 300) raises the self-update subprocess timeout for the slow git+ +# fallback on WSL. Bootstrap install: `curl -LsSf .../main/install.sh | sh`. kbagent changelog [--limit N] [--full] # Default shows a one-line summary (first sentence) per version; --full / -v expands every note. kbagent serve [--host HOST] [--port PORT] [--ui] [--ui-dist PATH] [--reload] [--log-level LVL] [--cors-origin ORIGIN] [--config-dir DIR] diff --git a/README.md b/README.md index c2867f60..9017a0df 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,19 @@ No more switching between the UI, old CLI, MCP server, and raw API calls. `kbage ## Install +```bash +curl -LsSf https://raw.githubusercontent.com/keboola/cli/main/install.sh | sh +``` + +This installs a **prebuilt wheel** from the latest GitHub release -- a few-seconds download, no source build. Building from `git+` instead recompiles the bundled React SPA via npm on every install, which takes minutes on WSL ([#353](https://github.com/keboola/cli/issues/353)). The script bundles the `[server]` extras by default (set `KBAGENT_NO_SERVER=1` for a CLI-only install) and needs only `curl` + [`uv`](https://docs.astral.sh/uv/). + +Prefer to build from source, or pin a specific ref? + ```bash uv tool install git+https://github.com/keboola/cli ``` -Auto-updates kbagent **and** its `keboola-mcp-server` dependency on every launch (since 0.30.1) -- no more silently running on a six-month-old MCP server. Run `kbagent changelog` to see what changed. +Auto-updates kbagent **and** its `keboola-mcp-server` dependency on every launch (since 0.30.1) -- no more silently running on a six-month-old MCP server; the self-update prefers the prebuilt wheel when available. Run `kbagent changelog` to see what changed. ## Web UI (optional) diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..fcab83df --- /dev/null +++ b/install.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# kbagent bootstrap installer. +# +# Usage: +# curl -LsSf https://raw.githubusercontent.com/keboola/cli/main/install.sh | sh +# +# Installs the kbagent CLI from a PREBUILT wheel attached to the latest GitHub +# release -- no source build. This is the fast path for issue #353: building +# from `git+` recompiles the bundled React SPA via npm on every install, which +# takes 2-4 minutes on WSL2. A prebuilt wheel is a few-seconds download instead. +# +# Requirements: `uv` (the install backend) and `curl`. The guide installs uv a +# couple of steps before this. If no wheel asset exists for the latest release +# yet (older releases predate the release workflow), this falls back to the +# `git+` source build so the install still succeeds. +# +# Env knobs: +# KBAGENT_NO_SERVER=1 install CLI-only (skip the [server] extras: FastAPI/ +# uvicorn for `kbagent serve`). Default bundles them so +# `kbagent serve --ui` works out of the box. + +set -eu + +REPO="keboola/cli" +PKG="keboola-agent-cli" +DIST="keboola_agent_cli" # normalized distribution name used in the wheel filename + +info() { printf '%s\n' "$*" >&2; } + +# --- preconditions -------------------------------------------------------- +if ! command -v uv >/dev/null 2>&1; then + info "error: 'uv' was not found on PATH. Install it first, then re-run:" + info " curl -LsSf https://astral.sh/uv/install.sh | sh" + info " source \$HOME/.local/bin/env # or restart your shell" + exit 1 +fi + +# Pick the install spec. [server] pulls in FastAPI/uvicorn so `kbagent serve` +# (REST + MCP + UI) works; KBAGENT_NO_SERVER=1 opts out for a lighter install. +if [ "${KBAGENT_NO_SERVER:-}" = "1" ]; then + spec="$PKG" +else + spec="${PKG}[server]" +fi + +# --- resolve the latest release version ----------------------------------- +# Read the redirect target of /releases/latest instead of calling the GitHub +# API -- no token, no 60-req/h rate limit. The effective URL after following +# redirects looks like https://github.com/keboola/cli/releases/tag/v0.59.0. +info "Resolving latest ${PKG} release..." +final_url=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + "https://github.com/${REPO}/releases/latest" 2>/dev/null || true) +version=$(printf '%s' "$final_url" | sed -n 's#.*/releases/tag/v\{0,1\}##p') + +# --- install -------------------------------------------------------------- +installed=0 +if [ -n "$version" ]; then + wheel_url="https://github.com/${REPO}/releases/download/v${version}/${DIST}-${version}-py3-none-any.whl" + # Confirm the asset exists before committing to it (a release may predate the + # wheel-publishing workflow and have no asset attached). + if curl -fsSL -I "$wheel_url" >/dev/null 2>&1; then + info "Installing prebuilt wheel v${version} (no build)..." + if uv tool install --force "${spec} @ ${wheel_url}"; then + installed=1 + else + info "Prebuilt wheel install failed; falling back to source build." + fi + else + info "No prebuilt wheel for v${version} yet; falling back to source build." + fi +else + info "Could not resolve the latest version; falling back to source build." +fi + +if [ "$installed" -ne 1 ]; then + info "Building from source via git+ (this can take a few minutes on WSL)..." + uv tool install --force "${spec} @ git+https://github.com/${REPO}" +fi + +# --- verify --------------------------------------------------------------- +info "" +if command -v kbagent >/dev/null 2>&1; then + info "Done. $(kbagent --version 2>/dev/null || echo 'kbagent installed')" +else + info "Done. Restart your shell, then run: kbagent --version" +fi diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index bdb9cec8..0defe6ee 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.59.0", + "version": "0.60.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/pyproject.toml b/pyproject.toml index 7d9eac9f..c3110df2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.59.0" +version = "0.60.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/auto_update.py b/src/keboola_agent_cli/auto_update.py index 6a684701..64a47e5d 100644 --- a/src/keboola_agent_cli/auto_update.py +++ b/src/keboola_agent_cli/auto_update.py @@ -8,6 +8,7 @@ try/except so it NEVER crashes the CLI. """ +import enum import json import logging import os @@ -40,11 +41,27 @@ _is_up_to_date, _perform_mcp_update, build_kbagent_upgrade_command, + get_update_timeout, + resolve_kbagent_wheel_url, ) logger = logging.getLogger(__name__) +class UpdateOutcome(enum.Enum): + """Outcome of a single kbagent self-update attempt (issue #353). + + Distinguishes a build/install TIMEOUT (the git+ source build outran the + timeout -- not a real failure; the next run picks it up) from a genuine + FAILED install, so the startup hook stops printing a misleading + "Auto-update failed" banner when the install is merely slow. + """ + + SUCCESS = "success" + TIMEOUT = "timeout" + FAILED = "failed" + + # Process-level sentinel for the auto-update flow. # # Bug D fix from issue #263: ``kbagent repl`` re-enters the entire CLI @@ -179,6 +196,31 @@ def _should_skip_kbagent_stage() -> bool: return os.environ.get(ENV_SKIP_UPDATE) == "1" +def _top_level_subcommand_is_versioning(args: list[str]) -> bool: + """True iff the top-level subcommand is ``update`` / ``version``. + + Walks past global flags to the first POSITIONAL token -- the top-level Typer + subcommand -- so it correctly catches the subcommand sitting after global + flags (``kbagent --json update``) WITHOUT matching a nested ``update`` like + ``kbagent config update`` / ``flow update`` / ``agent update`` (whose first + positional is ``config`` / ``flow`` / ``agent``). ``--config-dir`` is the one + global option that consumes the following token as its value, so its value is + skipped too; every other global option is a boolean flag. + """ + value_flags = {"--config-dir"} + i = 0 + while i < len(args): + arg = args[i] + if arg in value_flags: + i += 2 # skip the flag AND its value (e.g. `--config-dir /path`) + continue + if arg.startswith("-"): + i += 1 # boolean flag, or `--flag=value` form; skip just this token + continue + return arg.lower() in ("update", "version") + return False + + def _should_skip_all() -> bool: """Whether the entire auto-update flow should be skipped. @@ -202,14 +244,13 @@ def _should_skip_all() -> bool: if _is_dev_install(): return True - # Skip for update/version commands (they handle versioning themselves) - argv = sys.argv - if len(argv) >= 2: - cmd = argv[1].lower() - if cmd in ("update", "version"): - return True - - return False + # Skip for `update` / `version` -- they handle versioning themselves and + # would otherwise double-fire and disagree with the startup banner (Bug 3, + # issue #353). The subcommand can sit AFTER global flags (`kbagent --json + # update`), so we resolve the first positional token rather than checking + # argv[1] -- WITHOUT matching nested `update` subcommands like + # `kbagent config update`. + return _top_level_subcommand_is_versioning(sys.argv[1:]) def _should_skip() -> bool: @@ -223,7 +264,7 @@ def _should_skip() -> bool: return _should_skip_kbagent_stage() or _should_skip_all() -def _perform_update(latest_version: str) -> bool: +def _perform_update(latest_version: str) -> UpdateOutcome: """Download and install the latest version. Delegates to :func:`build_kbagent_upgrade_command` so this path stays @@ -238,24 +279,29 @@ def _perform_update(latest_version: str) -> bool: latest_version: The version being updated to (for logging). Returns: - True if the update succeeded, False otherwise. + :class:`UpdateOutcome`: ``SUCCESS`` on a clean install, ``TIMEOUT`` when + the install subprocess outran :func:`get_update_timeout` (a slow git+ + build -- retried next run, not a real failure), ``FAILED`` otherwise. """ - cmd = build_kbagent_upgrade_command() + # Prefer the prebuilt-wheel Release asset (issue #353) when present; falls + # back to the git+ source build for releases without an asset. + wheel_url = resolve_kbagent_wheel_url(latest_version) + cmd = build_kbagent_upgrade_command(wheel_url=wheel_url) if cmd is None: - return False + return UpdateOutcome.FAILED try: result = subprocess.run( cmd, capture_output=True, text=True, - timeout=120, + timeout=get_update_timeout(), ) - return result.returncode == 0 + return UpdateOutcome.SUCCESS if result.returncode == 0 else UpdateOutcome.FAILED except subprocess.TimeoutExpired: - return False + return UpdateOutcome.TIMEOUT except OSError: - return False + return UpdateOutcome.FAILED def _re_exec() -> None: @@ -454,7 +500,8 @@ def maybe_auto_update() -> None: up_to_date = _is_up_to_date(__version__, latest_version) if up_to_date is False: sys.stderr.write(f"Updating kbagent v{__version__} -> v{latest_version}...\n") - if _perform_update(latest_version): + outcome = _perform_update(latest_version) + if outcome is UpdateOutcome.SUCCESS: sys.stderr.write(f"Updated to v{latest_version}. Re-launching...\n") # Persist cache before re-exec so the new process does # not refetch immediately. The re-exec'd process will @@ -468,7 +515,17 @@ def maybe_auto_update() -> None: os.environ[ENV_UPDATED_FROM] = __version__ _re_exec() return # Defensive: _re_exec replaces the process. - sys.stderr.write("Auto-update failed; continuing with current version.\n") + if outcome is UpdateOutcome.TIMEOUT: + # Not a failure: the git+ source build outran the timeout. + # The wheel fast path makes this rare; when it happens, say + # so plainly instead of "failed" and let a later run finish + # what uv already started (issue #353). + sys.stderr.write( + f"Update still building after {int(get_update_timeout())}s; " + "it will finish on a later run. Continuing with current version.\n" + ) + else: + sys.stderr.write("Auto-update failed; continuing with current version.\n") # ----- Stage 2: keboola-mcp-server update -------------------------- # Always runs (subject only to _should_skip_all above). After a diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index bad05660..839e4a31 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,33 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.60.0": [ + "New (#353): kbagent installs and self-updates from a prebuilt wheel attached to each " + "GitHub release instead of building from `git+` source. `uv tool install git+...` " + "recompiled the bundled React SPA via `npm ci` + `vite build` on every install -- the uv " + "cache never covered the npm step -- which took 2-4 minutes on WSL2 and tripped the " + "auto-update timeout. The universal `py3-none-any` wheel is now built once in CI " + "(`release.yml`, on `release: published`) and uploaded as a release asset; " + "`build_kbagent_upgrade_command` installs it via a PEP 508 direct reference " + "(`keboola-agent-cli[server] @ `) when the asset exists, falling back to the " + "`git+` source build for older releases without one. Both the startup auto-update hook and " + "`kbagent update` benefit -- install/update drops from minutes to a seconds-long download.", + "New (#353): a `curl -LsSf https://raw.githubusercontent.com/keboola/cli/main/install.sh | " + "sh` bootstrap installer resolves the latest release and installs its prebuilt wheel -- no " + "source build, no `gh` CLI (just `curl` + `uv`), matching the pattern the install guide " + "already uses for uv and Claude Code. Set `KBAGENT_NO_SERVER=1` for a CLI-only install " + "without the `[server]` extras.", + "Fix (#353): the self-update subprocess timeout is no longer hardcoded at 120s in two " + "places (the startup hook and `kbagent update`). It is a single `UPDATE_TIMEOUT_SECONDS` " + "constant (raised to 300s) overridable via `KBAGENT_UPDATE_TIMEOUT` -- useful for the slow " + "`git+` fallback build on WSL.", + "Fix (#353): a slow update is no longer reported as a failure. The startup hook now " + "distinguishes a build TIMEOUT (the git+ build outran the timeout; it finishes on a later " + "run) from a genuine failure, printing 'still building' instead of 'Auto-update failed'. It " + "also skips the auto-update when the subcommand is `update` / `version` even behind global " + "flags (`kbagent --json update`), so the startup banner no longer disagrees with the " + "explicit command's JSON output.", + ], "0.59.0": [ "Faster: `kbagent workspace query` now reads results via the Query Service's inline " "`GET /api/v1/queries/{job}/{stmt}/results` endpoint by default instead of materializing a " diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 99ef8483..67b18f37 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1339,6 +1339,9 @@ but KBC_TOKEN / KBC_STORAGE_API_URL are missing. KBAGENT_MAX_PARALLEL_WORKERS Max concurrent threads for multi-project ops (default 10, max 100) KBAGENT_AUTO_UPDATE Set to "false" to disable automatic update on startup + KBAGENT_UPDATE_TIMEOUT Integer seconds; overrides the 300s self-update subprocess timeout + (raise for slow WSL git+ source builds). Since 0.60.0 install/update + prefer a prebuilt wheel Release asset, so timeouts are rare. KBAGENT_UPDATED_FROM Set to an older version to trigger "What's new" display on next run KBAGENT_MCP_TRANSPORT MCP transport mode: "http" (default, persistent) or "stdio" (subprocess) KBAGENT_INCLUDE_PRERELEASE Set to "1" (or "true"/"yes"/"on") to opt into pre-release versions for diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index d98c96d8..4dd8d3b5 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -260,6 +260,12 @@ ENV_SKIP_UPDATE: str = "KBAGENT_SKIP_UPDATE" AUTO_UPDATE_CHECK_INTERVAL: int = 3600 # 1 hour TTL for version cache VERSION_CACHE_FILENAME: str = "version_cache.json" +# Self-update subprocess timeout. Previously a hardcoded 120s in two places, +# which falsely tripped on WSL where the git+ source build (npm/React SPA) runs +# for minutes (issue #353). The prebuilt-wheel install is a seconds download, so +# 300s is ample; KBAGENT_UPDATE_TIMEOUT raises it for the slow git+ fallback. +UPDATE_TIMEOUT_SECONDS: int = 300 +ENV_UPDATE_TIMEOUT: str = "KBAGENT_UPDATE_TIMEOUT" # --- AI Service --- AI_SERVICE_TIMEOUT: httpx.Timeout = httpx.Timeout(connect=5.0, read=15.0, write=5.0, pool=5.0) diff --git a/src/keboola_agent_cli/services/version_service.py b/src/keboola_agent_cli/services/version_service.py index bb0ca4d3..6e6d0426 100644 --- a/src/keboola_agent_cli/services/version_service.py +++ b/src/keboola_agent_cli/services/version_service.py @@ -9,6 +9,7 @@ import importlib.util import logging +import os import re import shutil import subprocess @@ -19,6 +20,7 @@ from .. import __version__ from ..constants import ( + ENV_UPDATE_TIMEOUT, KBAGENT_GITHUB_REPO, KBAGENT_INSTALL_SOURCE, MCP_PIP_PRERELEASE_FLAG, @@ -26,6 +28,7 @@ MCP_PYPI_URL, MCP_UPGRADE_TIMEOUT, MCP_UV_PRERELEASE_FLAG, + UPDATE_TIMEOUT_SECONDS, VERSION_CHECK_TIMEOUT, ) @@ -65,8 +68,63 @@ def has_server_extras() -> bool: return importlib.util.find_spec("fastapi") is not None +def resolve_kbagent_wheel_url( + version: str | None, *, timeout: float = VERSION_CHECK_TIMEOUT +) -> str | None: + """Return the prebuilt-wheel Release asset URL for ``version`` if present. + + The ``release.yml`` workflow (issue #353) attaches a universal + ``keboola_agent_cli--py3-none-any.whl`` to every GitHub release. + Installing that prebuilt wheel skips the on-machine npm/React SPA build that + makes ``git+`` installs take minutes on WSL. + + A lightweight HEAD probe (verified to return 200 through GitHub's asset CDN + redirect) confirms the asset actually exists -- releases published before the + workflow have none, and those must fall back to the ``git+`` source build. + + Args: + version: Target version (no ``v`` prefix), e.g. ``"0.60.0"``. The tag is + ``v`` and the asset filename embeds the same version. + timeout: HEAD-probe timeout in seconds. + + Returns: + The asset URL on HTTP 200, or ``None`` on any non-200 / network error so + the caller falls back to :data:`KBAGENT_INSTALL_SOURCE` (git+). + """ + if not version: + return None + url = ( + f"https://github.com/{KBAGENT_GITHUB_REPO}/releases/download/" + f"v{version}/keboola_agent_cli-{version}-py3-none-any.whl" + ) + try: + resp = httpx.head(url, follow_redirects=True, timeout=timeout) + except httpx.HTTPError: + return None + return url if resp.status_code == 200 else None + + +def get_update_timeout() -> float: + """Resolve the kbagent self-update subprocess timeout in seconds. + + Defaults to :data:`UPDATE_TIMEOUT_SECONDS`; ``KBAGENT_UPDATE_TIMEOUT`` + overrides it (a ``git+`` source build on WSL can exceed the default -- raise + it there). Non-numeric or non-positive overrides fall back to the default + rather than disabling the timeout entirely. + """ + raw = os.environ.get(ENV_UPDATE_TIMEOUT, "").strip() + if raw: + try: + value = float(raw) + except ValueError: + return float(UPDATE_TIMEOUT_SECONDS) + if value > 0: + return value + return float(UPDATE_TIMEOUT_SECONDS) + + def build_kbagent_upgrade_command( - *, prerelease: bool = False, target_version: str | None = None + *, prerelease: bool = False, target_version: str | None = None, wheel_url: str | None = None ) -> list[str] | None: """Build the argv command to upgrade kbagent in-place. @@ -92,12 +150,38 @@ def build_kbagent_upgrade_command( main HEAD even though the version fetcher advertised the beta tag. Ignored for stable upgrades (the auto-update path always tracks main, which IS the latest stable). + wheel_url: When set, install the prebuilt wheel at this URL (a GitHub + Release asset) via a PEP 508 direct reference instead of building + from ``git+`` source -- the issue #353 fast path that skips the + on-machine npm/React build. Takes precedence over ``prerelease`` / + ``target_version`` (those are git-source knobs; the wheel URL + already pins an exact version). ``None`` keeps the git+ behaviour. Returns: Command list ready for :func:`subprocess.run`, or ``None`` if neither ``uv`` nor ``pip`` is on ``PATH`` (in which case the caller surfaces a manual-install hint). """ + # Prebuilt-wheel fast path (issue #353): when the caller resolved a Release + # asset URL, install the ready-made wheel via a PEP 508 direct reference + # instead of git-building from source. The bundled npm/React build is what + # makes git+ installs take minutes on WSL; the wheel is a seconds download. + # wheel_url already pins the exact version, so prerelease / target_version + # (git-source knobs) do not apply here. + if wheel_url is not None: + spec = ( + f"keboola-agent-cli[server] @ {wheel_url}" + if has_server_extras() + else f"keboola-agent-cli @ {wheel_url}" + ) + uv_path = shutil.which("uv") + if uv_path: + return [uv_path, "tool", "install", "--force", spec] + pip_path = shutil.which("pip") + if pip_path is None: + return None + return [pip_path, "install", "--upgrade", spec] + # Tag-pin the install source ONLY for beta opt-in (Variant B fix). # Stable upgrades let uv resolve main HEAD as before -- main IS # the stable channel, so an extra HTTP round-trip to fetch the @@ -656,8 +740,15 @@ def get_versions(self, *, include_prerelease: bool = False) -> dict[str, Any]: # latest_version advertised a beta tag -- silently landing on the # wrong version. kbagent_target_version = kbagent_latest if include_prerelease else None + # Mirror the _update_kbagent path (issue #353, NB-1): advertise the + # prebuilt-wheel install command when the asset exists, so a programmatic + # consumer copy-pasting `upgrade_command` from `kbagent version --json` + # gets the fast path too instead of a slow git+ source build. + kbagent_wheel_url = resolve_kbagent_wheel_url(kbagent_latest) kbagent_upgrade_cmd = build_kbagent_upgrade_command( - prerelease=include_prerelease, target_version=kbagent_target_version + prerelease=include_prerelease, + target_version=kbagent_target_version, + wheel_url=kbagent_wheel_url, ) kbagent_upgrade_str = ( " ".join(kbagent_upgrade_cmd) @@ -783,8 +874,12 @@ def _update_kbagent(*, include_prerelease: bool = False) -> dict[str, Any]: # Stable upgrades intentionally pass target_version=None so uv # resolves main HEAD as before -- main IS the stable channel. target_version = kbagent_latest if include_prerelease else None + # Prefer the prebuilt-wheel Release asset (issue #353) when present; + # resolve_kbagent_wheel_url returns None for older releases without an + # asset, in which case build_kbagent_upgrade_command keeps the git+ path. + wheel_url = resolve_kbagent_wheel_url(kbagent_latest) cmd = build_kbagent_upgrade_command( - prerelease=include_prerelease, target_version=target_version + prerelease=include_prerelease, target_version=target_version, wheel_url=wheel_url ) if cmd is None: with_flag = "--with 'keboola-agent-cli[server]' " if has_server_extras() else "" @@ -806,7 +901,7 @@ def _update_kbagent(*, include_prerelease: bool = False) -> dict[str, Any]: cmd, capture_output=True, text=True, - timeout=120, + timeout=get_update_timeout(), ) if result.returncode == 0: return { @@ -825,11 +920,15 @@ def _update_kbagent(*, include_prerelease: bool = False) -> dict[str, Any]: "output": result.stderr.strip(), } except subprocess.TimeoutExpired: + timeout_s = int(get_update_timeout()) return { "updated": False, "current_version": old_version, "latest_version": kbagent_latest, - "message": "Update timed out after 120 seconds.", + "message": ( + f"Update still building after {timeout_s}s (slow git+ source build). " + "It will finish on a later run; raise KBAGENT_UPDATE_TIMEOUT to wait longer." + ), } @staticmethod diff --git a/tests/conftest.py b/tests/conftest.py index d30d530e..5c3afe48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,35 @@ def _clear_updated_from(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("KBAGENT_UPDATED_FROM", raising=False) +@pytest.fixture(autouse=True) +def _no_wheel_asset_probe(monkeypatch: pytest.MonkeyPatch) -> None: + """Default the prebuilt-wheel HEAD probe to "asset absent" (issue #353). + + ``resolve_kbagent_wheel_url`` makes a live HEAD request to GitHub. Without + this guard every test exercising an update path would hit the network and + its result would depend on which versions have wheel assets. The patched + ``head`` returns 404 ONLY for the kbagent release-asset URL (so the resolver + yields None -> the ``git+`` path existing tests assert). Any OTHER + ``httpx.head`` call raises loudly rather than silently returning 404, so a + future caller that leans on this fixture by accident fails visibly instead of + getting a surprise 404. Wheel-path tests override ``httpx.head`` (or patch + the resolver) themselves to simulate a present asset. + """ + from types import SimpleNamespace + + from keboola_agent_cli.services import version_service + + def _head(url: str, *args: object, **kwargs: object) -> SimpleNamespace: + if "keboola/cli/releases/download" in str(url): + return SimpleNamespace(status_code=404) + raise RuntimeError( + f"unexpected httpx.head({url!r}) in tests -- add an explicit mock; " + "the _no_wheel_asset_probe fixture only stubs the kbagent wheel-asset probe" + ) + + monkeypatch.setattr(version_service.httpx, "head", _head) + + @pytest.fixture def tmp_config_dir(tmp_path: Path) -> Path: """Provide a temporary directory for configuration files.""" diff --git a/tests/test_auto_update.py b/tests/test_auto_update.py index 71ae9406..32a536ac 100644 --- a/tests/test_auto_update.py +++ b/tests/test_auto_update.py @@ -9,6 +9,7 @@ import keboola_agent_cli.auto_update as auto_update_module from keboola_agent_cli.auto_update import ( + UpdateOutcome, _get_cache_path, _is_cache_fresh, _is_dev_install, @@ -17,6 +18,7 @@ _re_exec, _read_cache, _should_skip, + _top_level_subcommand_is_versioning, _write_cache, maybe_auto_update, ) @@ -26,6 +28,31 @@ # --------------------------------------------------------------------------- # _should_skip # --------------------------------------------------------------------------- +class TestTopLevelSubcommandVersioning: + """_top_level_subcommand_is_versioning resolves the real subcommand (issue #353).""" + + @pytest.mark.parametrize( + ("argv_tail", "expected"), + [ + (["update"], True), + (["version"], True), + (["--json", "update"], True), # Bug 3: subcommand sits after a global flag + (["-j", "version"], True), + (["--config-dir", "/tmp/x", "update"], True), # value-taking global flag + (["--config-dir=/tmp/x", "update"], True), # `--flag=value` form + (["config", "update"], False), # nested -- must NOT skip (Devin finding) + (["flow", "update"], False), + (["agent", "update"], False), + (["config", "row-update"], False), + (["project", "list"], False), + ([], False), + (["--json"], False), + ], + ) + def test_resolves_top_level_subcommand(self, argv_tail, expected): + assert _top_level_subcommand_is_versioning(argv_tail) is expected + + class TestShouldSkip: """Tests for the _should_skip() function.""" @@ -62,6 +89,20 @@ def test_skip_for_update_command(self, _mock): with patch.dict(os.environ, env, clear=True), patch("sys.argv", ["kbagent", "update"]): assert _should_skip() is True + @patch("keboola_agent_cli.auto_update._is_dev_install", return_value=False) + def test_skip_for_update_after_global_flags(self, _mock): + """Bug 3 (issue #353): the subcommand can sit after global flags. + + `kbagent --json update` has argv[1] == "--json"; the old argv[1]-only + check let the startup hook fire and disagree with the explicit command. + """ + env = {k: v for k, v in os.environ.items() if k not in (ENV_SKIP_UPDATE, ENV_AUTO_UPDATE)} + with ( + patch.dict(os.environ, env, clear=True), + patch("sys.argv", ["kbagent", "--json", "update"]), + ): + assert _should_skip() is True + @patch("keboola_agent_cli.auto_update._is_dev_install", return_value=False) def test_skip_for_version_command(self, _mock): env = {k: v for k, v in os.environ.items() if k not in (ENV_SKIP_UPDATE, ENV_AUTO_UPDATE)} @@ -203,7 +244,7 @@ class TestPerformUpdate: def test_update_with_uv_success(self, mock_run, mock_which): mock_which.return_value = "/usr/local/bin/uv" mock_run.return_value = MagicMock(returncode=0) - assert _perform_update("2.0.0") is True + assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS # Verify uv was called call_args = mock_run.call_args assert "uv" in call_args[0][0][0] @@ -213,7 +254,7 @@ def test_update_with_uv_success(self, mock_run, mock_which): def test_update_with_uv_failure(self, mock_run, mock_which): mock_which.return_value = "/usr/local/bin/uv" mock_run.return_value = MagicMock(returncode=1, stderr="error") - assert _perform_update("2.0.0") is False + assert _perform_update("2.0.0") is UpdateOutcome.FAILED @patch("shutil.which") @patch("subprocess.run") @@ -223,14 +264,14 @@ def test_update_pip_fallback(self, mock_run, mock_which): None if cmd == "uv" else "/usr/bin/pip" if cmd == "pip" else None ) mock_run.return_value = MagicMock(returncode=0) - assert _perform_update("2.0.0") is True + assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS call_args = mock_run.call_args assert "pip" in call_args[0][0][0] @patch("shutil.which") def test_update_no_tools(self, mock_which): mock_which.return_value = None - assert _perform_update("2.0.0") is False + assert _perform_update("2.0.0") is UpdateOutcome.FAILED @patch("shutil.which") @patch("subprocess.run") @@ -239,7 +280,7 @@ def test_update_timeout(self, mock_run, mock_which): mock_which.return_value = "/usr/local/bin/uv" mock_run.side_effect = sp.TimeoutExpired(cmd="uv", timeout=120) - assert _perform_update("2.0.0") is False + assert _perform_update("2.0.0") is UpdateOutcome.TIMEOUT @patch( "keboola_agent_cli.services.version_service.has_server_extras", @@ -260,7 +301,7 @@ def test_update_preserves_server_extras(self, mock_run, mock_which, mock_has_ser ``--force`` when ``fastapi`` is importable. """ mock_run.return_value = MagicMock(returncode=0) - assert _perform_update("2.0.0") is True + assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS argv = mock_run.call_args[0][0] # uv tool install --force --with 'keboola-agent-cli[server]' git+... assert "--force" in argv @@ -278,13 +319,52 @@ def test_update_preserves_server_extras(self, mock_run, mock_which, mock_has_ser def test_update_without_server_extras_uses_upgrade(self, mock_run, mock_which, mock_has_server): """No-extras install keeps the simpler ``--upgrade`` form.""" mock_run.return_value = MagicMock(returncode=0) - assert _perform_update("2.0.0") is True + assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS argv = mock_run.call_args[0][0] assert "--upgrade" in argv assert "--with" not in argv assert "keboola-agent-cli[server]" not in argv +class TestPerformUpdateWheel: + """_perform_update prefers the prebuilt-wheel Release asset (issue #353).""" + + @patch("keboola_agent_cli.services.version_service.httpx.head") + @patch( + "keboola_agent_cli.services.version_service.has_server_extras", + return_value=False, + ) + @patch("shutil.which", return_value="/usr/local/bin/uv") + @patch("subprocess.run") + def test_installs_wheel_when_asset_present( + self, mock_run, mock_which, mock_has_server, mock_head + ): + """A 200 HEAD on the asset -> install the prebuilt wheel, not git+.""" + mock_head.return_value = MagicMock(status_code=200) + mock_run.return_value = MagicMock(returncode=0) + + assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS + + argv = mock_run.call_args[0][0] + # PEP 508 direct ref to the versioned wheel, --force, and no git+ source. + assert "--force" in argv + assert any(part.endswith("keboola_agent_cli-2.0.0-py3-none-any.whl") for part in argv) + assert all("git+" not in part for part in argv) + + @patch("keboola_agent_cli.services.version_service.httpx.head") + @patch("shutil.which", return_value="/usr/local/bin/uv") + @patch("subprocess.run") + def test_falls_back_to_git_when_no_asset(self, mock_run, mock_which, mock_head): + """A 404 HEAD (older release without an asset) -> git+ source build.""" + mock_head.return_value = MagicMock(status_code=404) + mock_run.return_value = MagicMock(returncode=0) + + assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS + + argv = mock_run.call_args[0][0] + assert any("git+" in part for part in argv) + + # --------------------------------------------------------------------------- # _re_exec # --------------------------------------------------------------------------- @@ -418,7 +498,7 @@ def test_up_to_date_no_update( @patch("keboola_agent_cli.auto_update._fetch_kbagent_latest_version", return_value="2.0.0") @patch("keboola_agent_cli.auto_update._write_cache") @patch("keboola_agent_cli.auto_update._is_up_to_date", return_value=False) - @patch("keboola_agent_cli.auto_update._perform_update", return_value=True) + @patch("keboola_agent_cli.auto_update._perform_update", return_value=UpdateOutcome.SUCCESS) @patch("keboola_agent_cli.auto_update._re_exec") @patch("keboola_agent_cli.auto_update.__version__", "1.0.0") def test_newer_available_updates_and_reexec( @@ -438,7 +518,7 @@ def test_newer_available_updates_and_reexec( @patch("keboola_agent_cli.auto_update._fetch_kbagent_latest_version", return_value="2.0.0") @patch("keboola_agent_cli.auto_update._write_cache") @patch("keboola_agent_cli.auto_update._is_up_to_date", return_value=False) - @patch("keboola_agent_cli.auto_update._perform_update", return_value=False) + @patch("keboola_agent_cli.auto_update._perform_update", return_value=UpdateOutcome.FAILED) @patch("keboola_agent_cli.auto_update._re_exec") @patch("keboola_agent_cli.auto_update.__version__", "1.0.0") def test_update_failure_continues( @@ -450,11 +530,47 @@ def test_update_failure_continues( mock_fetch, mock_cache, ): - """If _perform_update returns False, re-exec should NOT be called.""" + """If _perform_update returns FAILED, re-exec should NOT be called.""" maybe_auto_update() mock_update.assert_called_once() mock_reexec.assert_not_called() + @patch("keboola_agent_cli.auto_update._read_cache", return_value=None) + @patch("keboola_agent_cli.auto_update._fetch_kbagent_latest_version", return_value="2.0.0") + @patch("keboola_agent_cli.auto_update._is_up_to_date", return_value=False) + @patch( + "keboola_agent_cli.auto_update._perform_update", + return_value=UpdateOutcome.TIMEOUT, + ) + @patch("keboola_agent_cli.auto_update._re_exec") + @patch("keboola_agent_cli.auto_update._maybe_update_mcp") + @patch("keboola_agent_cli.auto_update._detect_mcp_install_method", return_value="none") + @patch("keboola_agent_cli.auto_update._write_cache") + @patch("keboola_agent_cli.auto_update.__version__", "1.0.0") + def test_timeout_outcome_is_not_a_failure( + self, + mock_write, + mock_detect, + mock_mcp, + mock_reexec, + mock_perform, + mock_up_to_date, + mock_fetch, + mock_cache, + capsys, + ): + """A TIMEOUT must not re-exec nor print 'failed' (issue #353). + + The banner should say the build is still running, not that it failed -- + the wheel fast path makes timeouts rare, but when the git+ fallback runs + long the next invocation finishes it. + """ + maybe_auto_update() + mock_reexec.assert_not_called() + err = capsys.readouterr().err + assert "still building" in err + assert "failed" not in err.lower() + @patch("keboola_agent_cli.auto_update._read_cache", return_value=None) @patch("keboola_agent_cli.auto_update._fetch_kbagent_latest_version", return_value=None) @patch("keboola_agent_cli.auto_update._perform_update") @@ -704,7 +820,7 @@ def test_kbagent_uptodate_still_runs_mcp_stage( @patch("keboola_agent_cli.auto_update._read_cache", return_value=None) @patch("keboola_agent_cli.auto_update._fetch_kbagent_latest_version", return_value="2.0.0") @patch("keboola_agent_cli.auto_update._is_up_to_date", return_value=False) - @patch("keboola_agent_cli.auto_update._perform_update", return_value=False) + @patch("keboola_agent_cli.auto_update._perform_update", return_value=UpdateOutcome.FAILED) @patch("keboola_agent_cli.auto_update._maybe_update_mcp") @patch("keboola_agent_cli.auto_update._detect_mcp_install_method", return_value="uv_tool") @patch("keboola_agent_cli.auto_update._write_cache") diff --git a/tests/test_version_service.py b/tests/test_version_service.py index 5fee7471..4a96a8d1 100644 --- a/tests/test_version_service.py +++ b/tests/test_version_service.py @@ -20,6 +20,8 @@ _uv_tool_list_get_mcp_version, _uv_tool_list_has_mcp, build_kbagent_upgrade_command, + get_update_timeout, + resolve_kbagent_wheel_url, ) @@ -912,6 +914,136 @@ def test_prerelease_http_failure_returns_none(self, mock_get: MagicMock) -> None assert _fetch_kbagent_latest_version(include_prerelease=True) is None +class TestResolveKbagentWheelUrl: + """resolve_kbagent_wheel_url HEAD-probes the Release asset (issue #353).""" + + @patch("keboola_agent_cli.services.version_service.httpx.head") + def test_returns_url_when_asset_present(self, mock_head: MagicMock) -> None: + mock_head.return_value = MagicMock(status_code=200) + url = resolve_kbagent_wheel_url("0.60.0") + assert url == ( + "https://github.com/keboola/cli/releases/download/" + "v0.60.0/keboola_agent_cli-0.60.0-py3-none-any.whl" + ) + # follow_redirects is required to traverse GitHub's asset CDN redirect. + assert mock_head.call_args.kwargs.get("follow_redirects") is True + + @patch("keboola_agent_cli.services.version_service.httpx.head") + def test_returns_none_on_404(self, mock_head: MagicMock) -> None: + mock_head.return_value = MagicMock(status_code=404) + assert resolve_kbagent_wheel_url("9.9.9") is None + + @patch("keboola_agent_cli.services.version_service.httpx.head") + def test_returns_none_on_http_error(self, mock_head: MagicMock) -> None: + import httpx + + mock_head.side_effect = httpx.HTTPError("network down") + assert resolve_kbagent_wheel_url("0.60.0") is None + + def test_returns_none_for_empty_version(self) -> None: + # Guards the None/"" caller path -- no network call is made. + assert resolve_kbagent_wheel_url(None) is None + assert resolve_kbagent_wheel_url("") is None + + +class TestGetUpdateTimeout: + """get_update_timeout resolves the self-update subprocess timeout (issue #353).""" + + def test_default_is_300(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KBAGENT_UPDATE_TIMEOUT", raising=False) + assert get_update_timeout() == 300.0 + + def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KBAGENT_UPDATE_TIMEOUT", "600") + assert get_update_timeout() == 600.0 + + @pytest.mark.parametrize("bad_value", ["", " ", "bogus", "-5", "0", "12.x"]) + def test_invalid_env_falls_back_to_default( + self, monkeypatch: pytest.MonkeyPatch, bad_value: str + ) -> None: + # Non-numeric or non-positive overrides must NOT disable the timeout -- + # they silently fall back to the 300s default. + monkeypatch.setenv("KBAGENT_UPDATE_TIMEOUT", bad_value) + assert get_update_timeout() == 300.0 + + +class TestBuildKbagentWheelInstall: + """build_kbagent_upgrade_command wheel_url fast path (issue #353).""" + + WHEEL = ( + "https://github.com/keboola/cli/releases/download/" + "v1.2.3/keboola_agent_cli-1.2.3-py3-none-any.whl" + ) + + @patch("keboola_agent_cli.services.version_service.has_server_extras", return_value=True) + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_uv_wheel_with_server_extras( + self, mock_which: MagicMock, mock_has_server: MagicMock + ) -> None: + mock_which.side_effect = lambda x: "/usr/bin/uv" if x == "uv" else None + cmd = build_kbagent_upgrade_command(wheel_url=self.WHEEL) + assert cmd is not None + assert cmd == [ + "/usr/bin/uv", + "tool", + "install", + "--force", + f"keboola-agent-cli[server] @ {self.WHEEL}", + ] + # The wheel path uses a PEP 508 direct ref -- no git+ source, no --with. + assert all("git+" not in part for part in cmd) + assert "--with" not in cmd + + @patch("keboola_agent_cli.services.version_service.has_server_extras", return_value=False) + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_uv_wheel_without_server_extras( + self, mock_which: MagicMock, mock_has_server: MagicMock + ) -> None: + mock_which.side_effect = lambda x: "/usr/bin/uv" if x == "uv" else None + cmd = build_kbagent_upgrade_command(wheel_url=self.WHEEL) + assert cmd == [ + "/usr/bin/uv", + "tool", + "install", + "--force", + f"keboola-agent-cli @ {self.WHEEL}", + ] + + @patch("keboola_agent_cli.services.version_service.has_server_extras", return_value=False) + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_pip_fallback_wheel(self, mock_which: MagicMock, mock_has_server: MagicMock) -> None: + mock_which.side_effect = lambda x: "/usr/bin/pip" if x == "pip" else None + cmd = build_kbagent_upgrade_command(wheel_url=self.WHEEL) + assert cmd == [ + "/usr/bin/pip", + "install", + "--upgrade", + f"keboola-agent-cli @ {self.WHEEL}", + ] + + @patch("keboola_agent_cli.services.version_service.has_server_extras", return_value=False) + @patch("keboola_agent_cli.services.version_service.shutil.which", return_value=None) + def test_wheel_no_tools_returns_none( + self, mock_which: MagicMock, mock_has_server: MagicMock + ) -> None: + assert build_kbagent_upgrade_command(wheel_url=self.WHEEL) is None + + @patch("keboola_agent_cli.services.version_service.has_server_extras", return_value=True) + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_wheel_url_takes_precedence_over_prerelease( + self, mock_which: MagicMock, mock_has_server: MagicMock + ) -> None: + """wheel_url wins over prerelease / target_version (git-source knobs).""" + mock_which.side_effect = lambda x: "/usr/bin/uv" if x == "uv" else None + cmd = build_kbagent_upgrade_command( + prerelease=True, target_version="1.2.3", wheel_url=self.WHEEL + ) + assert cmd is not None + assert "--prerelease=allow" not in cmd + assert all("git+" not in part for part in cmd) + assert cmd[-1] == f"keboola-agent-cli[server] @ {self.WHEEL}" + + class TestBuildKbagentUpgradeCommand: """Resolver pre-release opt-in propagation (since v0.42.0).""" diff --git a/uv.lock b/uv.lock index 845469b6..c883ad7c 100644 --- a/uv.lock +++ b/uv.lock @@ -580,7 +580,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.59.0" +version = "0.60.0" source = { editable = "." } dependencies = [ { name = "croniter" },