diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 37e1a42c..b4122850 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.43.2", + "version": "0.43.3", "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/CLAUDE.md b/CLAUDE.md index ac41bb53..0f3fa5e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,6 +171,46 @@ All three inherit from `BaseHttpClient` (`http_base.py`) which provides shared r **When bumping the version**: edit `pyproject.toml`, add a changelog entry to `src/keboola_agent_cli/changelog.py`, then run `make version-sync`. Do not edit `__init__.py` or `plugin.json` manually. CI enforces changelog completeness via `make changelog-check`. +### Beta / pre-release versions (since 0.43.3) + +Beta and release-candidate versions follow **PEP 440**: `0.44.0b1`, `0.44.0rc1`, ... -- **not** the SemVer `-beta.1` form (hatchling + uv require PEP 440 syntax in `pyproject.toml`). Three independent gates keep stable users safe from accidentally landing on a beta: + +1. **PEP 440 pre-release suffix.** pip / uv default to **skipping** pre-releases unless told otherwise (`--pre` for pip, `--prerelease=allow` for uv). +2. **GitHub Release `prerelease: true` flag.** The auto-update startup hook calls `/releases/latest`, which GitHub defines as "the most recent non-prerelease, non-draft release". Marking the release `--prerelease` makes it invisible to the auto-update path. +3. **Tag-pinned install URL.** When `--beta` opts into a pre-release, the install command appends `@v` to the git+ source URL so uv pulls the **exact commit** the tag points to. Without this, uv would resolve the default branch (`main`) and -- if the beta lives on a feature branch -- silently install the stale main HEAD even though the version fetcher advertised the beta tag. + +**Author workflow (release a beta from a feature branch):** + +```bash +# 1. Bump pyproject.toml to PEP 440 pre-release form on the feature branch +# version = "0.44.0b1" +make version-sync # propagates to plugin.json / marketplace.json + +# 2. Add a changelog entry under that key in src/keboola_agent_cli/changelog.py +# 3. Commit + push to PR (NOT to main -- main stays on the stable channel) +git push origin feat/my-feature + +# 4. Tag the PR head SHA + push tag +git tag v0.44.0b1 && git push origin v0.44.0b1 + +# 5. KEY STEP: create the GitHub Release WITH --prerelease pointing at the tag +gh release create v0.44.0b1 --prerelease \ + --title "v0.44.0 — Beta 1" \ + --notes-file release-notes-0.44.0b1.md +``` + +When the beta cooks long enough, merge the PR (stable squash) and ship `0.44.0` from main with a normal release **without** `--prerelease` -- auto-update picks it up on next startup. + +**User opt-in (consume betas):** + +- One-shot: `kbagent update --beta` -- resolver opts into pre-releases for this invocation only. +- Per-shell: `export KBAGENT_INCLUDE_PRERELEASE=1` -- every `kbagent update` / `kbagent version` in that shell treats betas as installable. +- **No persistent setting.** Each beta install is an active choice; never a forgotten "I once typed --beta six months ago" foot-gun. + +The startup auto-update hook is **never** affected by `--beta` / env opt-in -- it always uses `/releases/latest` (stable channel). Beta installs only come from explicit `kbagent update --beta`. + +Full author checklist: see `CONTRIBUTING.md` > "Releasing a beta (pre-release) version". + ## Coding Conventions > **0. (BINDING) Follow [CONTRIBUTING.md](CONTRIBUTING.md) in full.** Every code change -- human or AI agent -- must satisfy the rules in `CONTRIBUTING.md`. Specifically, the "Code Quality Patterns" section is non-negotiable: dataclasses (not bare tuples) for multi-value returns; categorical arguments before variable ones; `ErrorCode` enum (never raw strings); file-size budgets; context managers over lambdas; named functions over assigned anonymous functions; `ty` clean for new code. The `.claude/settings.json` post-edit hooks run `ruff check --fix`, `ruff format`, and `ty check` after every edit -- when an AI agent edits a file in this repo, those checks fire automatically and any failure must be addressed before continuing. If a rule conflicts with an existing pattern in legacy code, **fix it in the PR you are touching** or open a follow-up issue; do not propagate the pattern. @@ -447,8 +487,11 @@ kbagent schedule find [--cron-window START-END] [--not-run-since DAYS] [--projec kbagent context kbagent init [--from-global] kbagent doctor [--fix] -kbagent version -kbagent update +kbagent version [--beta] +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. kbagent changelog [--limit N] kbagent serve [--host HOST] [--port PORT] [--ui] [--ui-dist PATH] [--reload] [--log-level LVL] [--cors-origin ORIGIN] [--config-dir DIR] ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd6b4c03..59b85cf9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -527,6 +527,13 @@ steps 5-8 is that **CI will not catch you** if you skip them; they are the manual safety net for the silent-drift risks summarized in the [Plugin synchronization map](#plugin-synchronization-map) above. +> **Want to ship a beta first?** You can. PEP 440 pre-release versions +> (`0.43.0b1`, `0.43.0rc1`) are fully supported by `kbagent update --beta` +> since v0.42.0. The startup auto-update hook never silently lands on a +> beta -- only explicit opt-in installs them. See +> [Releasing a beta (pre-release) version](#releasing-a-beta-pre-release-version) +> below for the workflow. + 1. **Edit `pyproject.toml`** -- bump `version = "X.Y.Z"`. Single source of truth; everything else derives from it. 2. **Add a changelog entry** to `src/keboola_agent_cli/changelog.py` -- one entry per release, no exceptions. CI fails (`make changelog-check`) if this is missing. 3. **Run `make version-sync`** -- propagates the new version to `plugins/kbagent/.claude-plugin/plugin.json`. The pre-commit hook does this automatically on `git commit`, but running it explicitly lets you eyeball the diff. @@ -547,6 +554,57 @@ If any of steps 5-8 reveal "I should have done this in the PR that introduced the command, not at release time", **also patch the per-command checklist** above so the next contributor catches the gap earlier. +### Releasing a beta (pre-release) version + +Beta and release-candidate versions follow PEP 440: `X.Y.Zb1`, `X.Y.Zb2`, +`X.Y.Zrc1`, ... -- not the SemVer `-beta.1` form (hatchling and uv require +PEP 440 syntax in `pyproject.toml`'s `version` field). Two gates keep stable +users safe from accidentally landing on a beta: + +1. **Version string itself.** PEP 440 marks any pre-release suffix as such; + `pip install keboola-agent-cli` and `uv tool install ...` default to + **skipping** pre-releases unless the resolver is told otherwise (`--pre` + for pip, `--prerelease=allow` for uv). +2. **GitHub Release `prerelease: true` flag.** The auto-update startup + hook calls `GET /releases/latest`, which GitHub explicitly defines as + "the most recent non-prerelease, non-draft release". Marking the release + `--prerelease` makes it invisible to the auto-update path. + +**Workflow:** + +1. Bump `pyproject.toml` to the PEP 440 pre-release version + (e.g. `0.43.0b1`). +2. Add a changelog entry under that key in `src/keboola_agent_cli/changelog.py`. +3. `make version-sync` propagates the version to `plugin.json` / + `marketplace.json`. +4. Tag and push: `git tag v0.43.0b1 && git push origin v0.43.0b1`. +5. Create the GitHub release **with the `--prerelease` flag**: + ```bash + gh release create v0.43.0b1 --prerelease \ + --title "v0.43.0 — Beta 1" \ + --notes-file release-notes-0.43.0b1.md + ``` +6. Test by installing yourself: `kbagent update --beta` (or set + `KBAGENT_INCLUDE_PRERELEASE=1` in env). Users who do **not** opt in + keep getting the latest stable; the new beta is invisible to them. +7. Once the beta cooks long enough, bump to the stable equivalent + (`0.43.0`), retag, and create the release **without** `--prerelease` + so auto-update picks it up. + +**Users opt in two ways:** + +- One-shot: `kbagent update --beta` (resolver is told `--prerelease=allow` + / `--pre`, GitHub query switches to `/releases` and picks the highest + PEP 440 version including pre-releases). +- Per-session env var: `export KBAGENT_INCLUDE_PRERELEASE=1` -- every + subsequent `kbagent update` / `kbagent version` in that shell treats + betas as installable. + +**Never persists.** There is no `release_channel: beta` config setting -- +each invocation has to opt in. This is deliberate: betas should always be +an active choice, never a forgotten "I once typed --beta six months ago" +foot-gun. + ## Running CI Locally ```bash diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 175d306f..6083ff46 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.43.2", + "version": "0.43.3", "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/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 25fae127..dfb1b403 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -108,8 +108,9 @@ a critical failure. user's local claude / codex / gemini CLI; backs the dashboard Local AI tile that replaces Kai for non-master-token projects) needs 0.41.9+, - data-app workspace + CLI sandbox annotation = 0.42.0+ (#304), - HTTP opt-in `?include_sandbox_annotation=true` = 0.43.1+ (#312), + data-app CLI sandbox annotation = 0.42.0+ (#304), + HTTP `?include_sandbox_annotation=true` = 0.43.1+ #312, + `kbagent update --beta` = 0.43.3+, `storage retype` is a future composite), you MUST refuse the task and return a handoff message to the parent: `"Cannot proceed safely on kbagent . Missing: . diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 09dfdfa9..6ef1962d 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -5,8 +5,8 @@ All commands support `--json` for structured output. Multi-project flags (`--pro ## Setup & Info - `init [--from-global]` -- create local `.kbagent/` workspace in current directory - `doctor [--fix]` -- health check for CLI config and MCP server -- `version` -- show version info and dependency update status -- `update` -- self-update to latest version +- `version [--beta]` -- show version info and dependency update status. `--beta` (since v0.42.0) reports the latest pre-release (beta / rc) instead of the latest stable. Env override: `KBAGENT_INCLUDE_PRERELEASE=1` +- `update [--beta]` -- self-update to latest version. `--beta` (since v0.42.0) opts into pre-release versions (PEP 440 betas / rc, e.g. `0.43.0b1`). Default behaviour: GitHub's `/releases/latest` endpoint filters prereleases server-side, so the startup auto-update hook never silently lands on a beta. Resolver-level opt-in (`--prerelease=allow` for uv, `--pre` for pip) is added automatically when `--beta` is set - `changelog [--limit N]` -- show recent changelog (default: last 5 versions). After auto-update, "What's new" is printed automatically. Manual trigger: `KBAGENT_UPDATED_FROM=0.17.0 kbagent version` - `context` -- print full CLI reference for AI agents diff --git a/pyproject.toml b/pyproject.toml index f001bf6b..a0f44a5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.43.2" +version = "0.43.3" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index f7071e8a..7a928216 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,9 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.43.3": [ + "New: `kbagent update --beta` (alternatively `KBAGENT_INCLUDE_PRERELEASE=1` per-shell env var) opts into pre-release versions. Default behaviour is unchanged -- the startup auto-update hook hits GitHub's `/releases/latest` endpoint, which is defined as the latest non-prerelease, non-draft release; betas marked `--prerelease` are invisible. With `--beta`, the version fetcher switches to `/releases` (plural) and picks the highest PEP 440 version including pre-releases (e.g. `0.44.0b1` beats `0.43.3`). The install command additionally propagates `--prerelease=allow` (uv) / `--pre` (pip) so the resolver accepts PEP 440 pre-release tags that it would otherwise refuse by default, AND appends `@v` to the git+ install URL so uv installs the exact commit pointed to by the tag rather than the default branch (this matters when beta tags live on a feature branch, not main -- without `@v` uv would always install the latest main commit, even though `_fetch_kbagent_latest_prerelease` advertised a different version). `kbagent version --beta` mirrors the same lookup for inspection. No `release_channel: beta` persistent config setting -- each opt-in is ad-hoc and explicit so a beta install is never a forgotten preference. CONTRIBUTING.md gets a new 'Releasing a beta' workflow section documenting the PEP 440 + `gh release create --prerelease` convention. 12 unit tests in `test_version_service.py` (default uses /releases/latest, prerelease uses /releases with PEP 440 sort, skips drafts, falls back to stable, ignores invalid tags, HTTP failure returns None, `build_kbagent_upgrade_command` propagates `--prerelease=allow` for uv + `--pre` for pip, prerelease+target_version appends `@v` to git URL, stable install URL is unchanged when target_version not provided).", + ], "0.43.2": [ "UX: `kbagent changelog` now renders entries with Rich-styled prefixes (`New:` bold green, `Fix:` bold yellow, `Change:` bold blue, `UX:` bold magenta, `Note:` bold cyan, `Security:` bold red, `Closed:` bold blue; `Tests:` / `Plugin docs:` / `Internal:` / `Observability:` / `E2E:` / `Review fixes:` / `Why:` dim), cyan inline backtick spans (e.g. `kbagent serve --ui`), dim bullets, and a 4-space continuation indent under the bullet on the first line only. Body text is word-wrapped to terminal width via `Text.wrap()` (Rich's span-preserving wrap) so long entries no longer render as one wall of unwrapped text. Renderer-only change in `commands/changelog.py` (~70 lines added); the `CHANGELOG` data dict in `changelog.py` is untouched. JSON envelope (`--json changelog`) is byte-for-byte unchanged: data shape stays 1:1 with prior releases so AI agents that consume `kbagent changelog` as context see zero diff. Two implementation details that mattered: (1) wrap-output `Text` lines keep a trailing word-break space that surfaces as visible trailing whitespace on copy/paste -- fixed by calling `Text.rstrip()` in place on each wrapped line before printing; (2) the gutter `Text` is built styleless and the dim attribute is appended only to the bullet glyph itself, otherwise Rich's parent-style inheritance would dim the whole body line including colored prefixes. Existing regression test `tests/test_auto_update.py::TestChangelogCommandConsumesWhatsNewTrigger` (assertion is content-based, not style-based) continues to pass without modification; full suite 3373 passed.", ], diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 6de6dcb1..af329904 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -952,16 +952,24 @@ kbagent doctor [--fix] Health checks. --fix auto-installs MCP server binary. - kbagent version + kbagent version [--beta] Version info for kbagent + keboola-mcp-server. Reports both the locally installed version and the latest available; flags any staleness. + --beta (since 0.42.0) reports the latest pre-release (beta / rc) instead + of the latest stable. Same env override: KBAGENT_INCLUDE_PRERELEASE=1. - kbagent update + kbagent update [--beta] Two-stage upgrade (since 0.30.1): kbagent itself AND keboola-mcp-server. The MCP server is detected (uv tool / pip env / uvx) and bumped via the matching command. Both stages always run, regardless of whether kbagent itself needed an upgrade. The same flow runs automatically on every kbagent startup -- the explicit `update` command forces a fresh check. + --beta (since 0.42.0) opts into pre-release versions (PEP 440 betas/rc, + e.g. 0.43.0b1). Without --beta the auto-update path uses GitHub's + /releases/latest endpoint, which excludes prereleases server-side -- + stable users never silently land on a beta. Set + KBAGENT_INCLUDE_PRERELEASE=1 in env to make every update in the session + treat betas as installable without re-typing --beta. kbagent changelog [--limit N] Show recent changelog (what changed in each version). Default: last 5 versions. @@ -1027,6 +1035,10 @@ KBAGENT_AUTO_UPDATE Set to "false" to disable automatic update on startup 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 + `kbagent update` / `kbagent version` in this shell (equivalent to --beta flag, + since 0.43.3). NEVER affects the startup auto-update hook -- that path is + stable-channel-only by design so betas stay an explicit per-invocation choice. 8. Config resolution order: --config-dir flag > KBAGENT_CONFIG_DIR env > .kbagent/ in CWD/parents > ~/.config/keboola-agent-cli/ diff --git a/src/keboola_agent_cli/commands/version.py b/src/keboola_agent_cli/commands/version.py index 1104082f..bfda554b 100644 --- a/src/keboola_agent_cli/commands/version.py +++ b/src/keboola_agent_cli/commands/version.py @@ -107,15 +107,37 @@ def _format_version_panel(console: Console, data: dict) -> None: console.print(Panel(text, title="Version Info", border_style="blue")) -def version_command(ctx: typer.Context) -> None: +def version_command( + ctx: typer.Context, + beta: bool = typer.Option( + False, + "--beta", + help="Report the latest pre-release (beta / rc) instead of the latest stable.", + ), +) -> None: """Show kbagent version and check for dependency updates.""" formatter = get_formatter(ctx) version_service = get_service(ctx, "version_service") - result = version_service.get_versions() + include_prerelease = beta or _env_opted_into_prerelease() + result = version_service.get_versions(include_prerelease=include_prerelease) formatter.output(result, _format_version_panel) -def update_command(ctx: typer.Context) -> None: +def update_command( + ctx: typer.Context, + beta: bool = typer.Option( + False, + "--beta", + help=( + "Opt into pre-release versions (beta / rc). Without this flag (the " + "default) and without KBAGENT_INCLUDE_PRERELEASE=1 in env, the " + "GitHub /releases/latest endpoint -- which filters out prereleases " + "server-side -- is used, so a beta release will never silently " + "install. With --beta, the resolver opts into PEP 440 prereleases " + "(``uv --prerelease=allow`` / ``pip --pre``)." + ), + ), +) -> None: """Update kbagent + keboola-mcp-server to the latest versions. Two-stage upgrade (since v0.30.1): @@ -130,10 +152,16 @@ def update_command(ctx: typer.Context) -> None: JSON output reports both stages independently. Human mode prints a one-line summary such as ``kbagent v0.30.0 -> v0.30.1 | keboola-mcp-server v1.49.0 -> v1.59.1``. + + Pre-release channel (since v0.42.0): use ``--beta`` (or set + ``KBAGENT_INCLUDE_PRERELEASE=1`` in env) to opt into beta / rc + releases. The startup auto-update hook never auto-installs a beta; + only this explicit command does. """ formatter = get_formatter(ctx) version_service = get_service(ctx, "version_service") - result = version_service.self_update() + include_prerelease = beta or _env_opted_into_prerelease() + result = version_service.self_update(include_prerelease=include_prerelease) if formatter.json_mode: formatter.output(result) @@ -142,3 +170,17 @@ def update_command(ctx: typer.Context) -> None: formatter.success(result["message"]) else: formatter.console.print(result["message"]) + + +def _env_opted_into_prerelease() -> bool: + """Honour ``KBAGENT_INCLUDE_PRERELEASE=1`` as a per-shell opt-in. + + Some users want every kbagent invocation in a session to use the beta + channel without re-typing ``--beta`` (e.g. CI smoke-tests for an + upcoming release). Mirrors the truthy parse pattern used by + ``KBAGENT_SKIP_UPDATE`` so the two env vars behave consistently. + """ + import os + + raw = os.environ.get("KBAGENT_INCLUDE_PRERELEASE", "").strip().lower() + return raw in {"1", "true", "yes", "on"} diff --git a/src/keboola_agent_cli/services/version_service.py b/src/keboola_agent_cli/services/version_service.py index 792ec58c..a8972fec 100644 --- a/src/keboola_agent_cli/services/version_service.py +++ b/src/keboola_agent_cli/services/version_service.py @@ -59,7 +59,9 @@ def has_server_extras() -> bool: return importlib.util.find_spec("fastapi") is not None -def build_kbagent_upgrade_command() -> list[str] | None: +def build_kbagent_upgrade_command( + *, prerelease: bool = False, target_version: str | None = None +) -> list[str] | None: """Build the argv command to upgrade kbagent in-place. Used by both ``kbagent update`` (explicit) and the startup @@ -67,11 +69,36 @@ def build_kbagent_upgrade_command() -> list[str] | None: in particular, both preserve the optional ``[server]`` extras when they were originally installed. + Args: + prerelease: When True, opt into pre-release versions (beta / rc). + uv gets ``--prerelease=allow`` (resolver-level opt-in for the + entire tool environment), pip gets ``--pre`` (the legacy + equivalent). Without this flag, both resolvers reject + pre-release version strings like ``0.44.0b1`` even if they + are the newest available -- the default-deny behaviour we + want for cron-driven auto-update so stable users never + silently land on a beta release. + target_version: When set together with ``prerelease=True``, append + ``@v`` to the git+ source URL so uv installs + the exact commit pointed to by the tag. Critical when betas + live on a feature branch instead of main -- without this, uv + resolves the default branch and silently installs the stale + 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). + 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). """ + # 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 + # tag name would be pure overhead. + install_source = KBAGENT_INSTALL_SOURCE + if prerelease and target_version: + install_source = f"{KBAGENT_INSTALL_SOURCE}@v{target_version}" has_server = has_server_extras() uv_path = shutil.which("uv") if uv_path: @@ -82,28 +109,33 @@ def build_kbagent_upgrade_command() -> list[str] | None: # spec resolves to a different version than the existing # tool environment -- ``--force`` is uv's documented way to # reapply both in one shot. - return [ + cmd = [ uv_path, "tool", "install", "--force", "--with", "keboola-agent-cli[server]", - KBAGENT_INSTALL_SOURCE, + install_source, ] - return [uv_path, "tool", "install", "--upgrade", KBAGENT_INSTALL_SOURCE] + else: + cmd = [uv_path, "tool", "install", "--upgrade", install_source] + if prerelease: + # Insert before the source spec so uv parses it as a global + # resolver flag (not a positional arg). + cmd.insert(-1, "--prerelease=allow") + return cmd pip_path = shutil.which("pip") if pip_path is None: return None # pip extras syntax: the [server] suffix attaches to the project # name in the PEP 508 spec; for git+ URLs we wrap with the project # name on the left of the URL. - install_spec = ( - f"keboola-agent-cli[server] @ {KBAGENT_INSTALL_SOURCE}" - if has_server - else KBAGENT_INSTALL_SOURCE - ) - return [pip_path, "install", "--upgrade", install_spec] + install_spec = f"keboola-agent-cli[server] @ {install_source}" if has_server else install_source + cmd = [pip_path, "install", "--upgrade", install_spec] + if prerelease: + cmd.insert(2, "--pre") + return cmd def _get_local_mcp_version(timeout: float = MCP_PROBE_TIMEOUT) -> str | None: @@ -394,16 +426,29 @@ def _perform_mcp_update( return False, f"subprocess error: {exc}" -def _fetch_kbagent_latest_version(timeout: float = VERSION_CHECK_TIMEOUT) -> str | None: +def _fetch_kbagent_latest_version( + timeout: float = VERSION_CHECK_TIMEOUT, *, include_prerelease: bool = False +) -> str | None: """Fetch latest kbagent version from GitHub releases. Args: timeout: HTTP request timeout in seconds. + include_prerelease: When False (default), call ``/releases/latest`` + which GitHub explicitly defines as "the most recent non-prerelease, + non-draft release" -- beta tags marked with ``--prerelease`` are + skipped automatically by the API. When True, call ``/releases`` + (full list), discard drafts, and pick the highest version by + PEP 440 ordering. This is the opt-in path behind ``kbagent + update --beta`` so users explicitly asking for a beta can get + ``0.43.0b1`` even when ``0.42.0`` is the stable. Returns: - Version string like '0.16.0', or None on failure. + Version string like '0.16.0' (stable) or '0.43.0b1' (beta), or + None on failure. """ try: + if include_prerelease: + return _fetch_kbagent_latest_prerelease(timeout) response = httpx.get( f"https://api.github.com/repos/{KBAGENT_GITHUB_REPO}/releases/latest", timeout=timeout, @@ -422,6 +467,45 @@ def _fetch_kbagent_latest_version(timeout: float = VERSION_CHECK_TIMEOUT) -> str return None +def _fetch_kbagent_latest_prerelease(timeout: float) -> str | None: + """Fetch the highest non-draft release (incl. pre-release) from GitHub. + + Pulls up to 30 most recent releases (the API's default page size, plenty + for kbagent's release cadence), filters drafts, parses every tag through + :class:`packaging.version.Version`, and returns the maximum by PEP 440 + ordering. Pre-release detection relies on ``Version.is_prerelease`` -- + PEP 440 normalises ``v0.43.0-beta.1`` and ``0.43.0b1`` to the same + canonical form, so the function works for either tag style. + + Returns: + Highest-by-version tag (stable OR pre-release), normalised to PEP 440 + canonical form (e.g. ``"0.43.0b1"``). None on HTTP / parse failure. + """ + response = httpx.get( + f"https://api.github.com/repos/{KBAGENT_GITHUB_REPO}/releases", + timeout=timeout, + follow_redirects=True, + headers={"Accept": "application/vnd.github.v3+json"}, + params={"per_page": 30}, + ) + response.raise_for_status() + releases = response.json() + if not isinstance(releases, list): + return None + best: Version | None = None + for entry in releases: + if not isinstance(entry, dict) or entry.get("draft"): + continue + tag = str(entry.get("tag_name", "")).lstrip("v") + try: + parsed = Version(tag) + except InvalidVersion: + continue + if best is None or parsed > best: + best = parsed + return str(best) if best is not None else None + + def _fetch_mcp_latest_version(timeout: float = VERSION_CHECK_TIMEOUT) -> str | None: """Fetch latest keboola-mcp-server version from PyPI. @@ -473,7 +557,7 @@ class VersionService: keboola-mcp-server updates. """ - def get_versions(self) -> dict[str, Any]: + def get_versions(self, *, include_prerelease: bool = False) -> dict[str, Any]: """Get version information for kbagent and its dependency. Both ``kbagent`` and ``keboola-mcp-server`` are auto-updated on @@ -487,10 +571,17 @@ def get_versions(self) -> dict[str, Any]: - the install method for MCP (drives which upgrade command runs), - the upgrade command shown to the user. + Args: + include_prerelease: When True, ``latest_version`` for kbagent + reflects the newest pre-release (beta / rc) if one is more + recent than the latest stable; surfaces what ``kbagent + update --beta`` would install. MCP's PyPI lookup is not + gated (MCP releases do not currently use pre-release tags). + Returns: Structured dict with kbagent + MCP version info. """ - kbagent_latest = _fetch_kbagent_latest_version() + kbagent_latest = _fetch_kbagent_latest_version(include_prerelease=include_prerelease) kbagent_up_to_date = _is_up_to_date(__version__, kbagent_latest) mcp_local = _get_local_mcp_version() @@ -547,19 +638,34 @@ def get_versions(self) -> dict[str, Any]: ), } + # Reflect the actual install command the user should run, including + # --prerelease=allow and @v tag-pin when --beta is active. + # Without this, programmatic JSON consumers reading upgrade_command + # would copy a stable-channel install command even though + # latest_version advertised a beta tag -- silently landing on the + # wrong version. + kbagent_target_version = kbagent_latest if include_prerelease else None + kbagent_upgrade_cmd = build_kbagent_upgrade_command( + prerelease=include_prerelease, target_version=kbagent_target_version + ) + kbagent_upgrade_str = ( + " ".join(kbagent_upgrade_cmd) + if kbagent_upgrade_cmd is not None + else f"uv tool install --upgrade {KBAGENT_INSTALL_SOURCE}" + ) return { "kbagent": { "version": __version__, "latest_version": kbagent_latest, "up_to_date": kbagent_up_to_date, - "upgrade_command": f"uv tool install --upgrade {KBAGENT_INSTALL_SOURCE}", + "upgrade_command": kbagent_upgrade_str, }, "dependencies": [ mcp_entry, ], } - def self_update(self) -> dict[str, Any]: + def self_update(self, *, include_prerelease: bool = False) -> dict[str, Any]: """Update kbagent + keboola-mcp-server to the latest versions. Two-stage flow (both stages always run -- kbagent up-to-date does @@ -577,6 +683,14 @@ def self_update(self) -> dict[str, Any]: install on first run) the stage still attempts the upgrade -- a refreshed cache is the desired outcome there. + Args: + include_prerelease: When True (driven by ``kbagent update --beta`` + or ``KBAGENT_INCLUDE_PRERELEASE=1``), kbagent's version + lookup considers pre-release (beta / rc) releases and the + install command opts into resolver-level pre-release + acceptance. The MCP stage is unaffected -- MCP releases + do not use pre-release tags today. + Returns: Dict with both stages' results:: @@ -591,7 +705,7 @@ def self_update(self) -> dict[str, Any]: "message": str, # Human-readable single-line summary } """ - kbagent_result = self._update_kbagent() + kbagent_result = self._update_kbagent(include_prerelease=include_prerelease) mcp_result = self._update_mcp() any_updated = bool(kbagent_result.get("updated") or mcp_result.get("updated")) @@ -632,10 +746,18 @@ def _compose_update_summary(kbagent_result: dict[str, Any], mcp_result: dict[str return " | ".join(parts) @staticmethod - def _update_kbagent() -> dict[str, Any]: - """Run the kbagent self-upgrade subprocess (or short-circuit).""" + def _update_kbagent(*, include_prerelease: bool = False) -> dict[str, Any]: + """Run the kbagent self-upgrade subprocess (or short-circuit). + + Args: + include_prerelease: When True (driven by ``kbagent update --beta`` + or ``KBAGENT_INCLUDE_PRERELEASE=1``), the version lookup + considers beta / rc releases and the install command + propagates ``--prerelease=allow`` so the resolver accepts + PEP 440 pre-release tags like ``0.43.0b1``. + """ old_version = __version__ - kbagent_latest = _fetch_kbagent_latest_version() + kbagent_latest = _fetch_kbagent_latest_version(include_prerelease=include_prerelease) up_to_date = _is_up_to_date(old_version, kbagent_latest) if up_to_date is True: @@ -646,17 +768,25 @@ def _update_kbagent() -> dict[str, Any]: "message": f"kbagent v{old_version} is already up to date.", } - cmd = build_kbagent_upgrade_command() + # Tag-pin the install URL ONLY for beta opt-in (Variant B fix). + # 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 + cmd = build_kbagent_upgrade_command( + prerelease=include_prerelease, target_version=target_version + ) if cmd is None: with_flag = "--with 'keboola-agent-cli[server]' " if has_server_extras() else "" + pre_flag = "--prerelease=allow " if include_prerelease else "" + tag_suffix = f"@v{target_version}" if target_version else "" return { "updated": False, "current_version": old_version, "latest_version": kbagent_latest, "message": ( "Neither 'uv' nor 'pip' found on PATH. " - f"Install manually: uv tool install --upgrade {with_flag}" - f"{KBAGENT_INSTALL_SOURCE}" + f"Install manually: uv tool install --upgrade {pre_flag}{with_flag}" + f"{KBAGENT_INSTALL_SOURCE}{tag_suffix}" ), } diff --git a/tests/test_version_service.py b/tests/test_version_service.py index a7d1d0df..0e666c82 100644 --- a/tests/test_version_service.py +++ b/tests/test_version_service.py @@ -11,6 +11,7 @@ MCP_PACKAGE_NAME, VersionService, _detect_mcp_install_method, + _fetch_kbagent_latest_version, _fetch_mcp_latest_version, _get_local_mcp_version, _is_up_to_date, @@ -18,6 +19,7 @@ _perform_mcp_update, _uv_tool_list_get_mcp_version, _uv_tool_list_has_mcp, + build_kbagent_upgrade_command, ) @@ -801,3 +803,209 @@ def test_fresh_install_pre_none_post_set_reports_updated( assert "vNone" not in result["mcp"]["message"] # Overall flag flips to True too. assert result["updated"] is True + + +class TestFetchKbagentLatestVersion: + """Beta / pre-release opt-in for kbagent version lookup (since v0.42.0).""" + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_default_uses_releases_latest_endpoint(self, mock_get: MagicMock) -> None: + """Without --beta: hit /releases/latest (GitHub filters prerelease).""" + mock_response = MagicMock() + mock_response.json.return_value = {"tag_name": "v0.42.0"} + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + result = _fetch_kbagent_latest_version() + + assert result == "0.42.0" + # Only one call, to /releases/latest -- the prerelease path uses + # /releases (plural) instead. + assert mock_get.call_count == 1 + url = mock_get.call_args.args[0] + assert url.endswith("/releases/latest") + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_prerelease_returns_highest_pep440_version(self, mock_get: MagicMock) -> None: + """With include_prerelease=True: pick highest by PEP 440 ordering.""" + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + # GitHub /releases returns newest-first, but the function must sort + # by SemVer/PEP 440, not by API order -- otherwise a hot-fix to an + # older release line (e.g. v0.41.11 after v0.42.0) would win. + mock_response.json.return_value = [ + {"tag_name": "v0.41.11", "draft": False, "prerelease": False}, + {"tag_name": "v0.42.0", "draft": False, "prerelease": False}, + {"tag_name": "v0.43.0b1", "draft": False, "prerelease": True}, + {"tag_name": "v0.43.0b2", "draft": False, "prerelease": True}, + ] + mock_get.return_value = mock_response + + result = _fetch_kbagent_latest_version(include_prerelease=True) + + assert result == "0.43.0b2" + # Plural /releases endpoint -- only one call. + url = mock_get.call_args.args[0] + assert url.endswith("/releases") + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_prerelease_skips_drafts(self, mock_get: MagicMock) -> None: + """Draft releases must be ignored even when newest by tag.""" + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = [ + {"tag_name": "v0.42.0", "draft": False, "prerelease": False}, + {"tag_name": "v0.43.0b1", "draft": True, "prerelease": True}, + ] + mock_get.return_value = mock_response + + result = _fetch_kbagent_latest_version(include_prerelease=True) + + # Draft 0.43.0b1 skipped -> 0.42.0 wins. + assert result == "0.42.0" + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_prerelease_falls_back_to_stable_when_no_betas(self, mock_get: MagicMock) -> None: + """When no pre-releases exist, the highest stable still wins.""" + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = [ + {"tag_name": "v0.41.10", "draft": False, "prerelease": False}, + {"tag_name": "v0.42.0", "draft": False, "prerelease": False}, + ] + mock_get.return_value = mock_response + + assert _fetch_kbagent_latest_version(include_prerelease=True) == "0.42.0" + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_prerelease_ignores_invalid_tags(self, mock_get: MagicMock) -> None: + """Hand-rolled tags that don't parse as PEP 440 are silently dropped.""" + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = [ + {"tag_name": "vnext", "draft": False, "prerelease": True}, # invalid + {"tag_name": "v0.42.0", "draft": False, "prerelease": False}, + {"tag_name": "wip", "draft": False, "prerelease": True}, # invalid + ] + mock_get.return_value = mock_response + + assert _fetch_kbagent_latest_version(include_prerelease=True) == "0.42.0" + + @patch("keboola_agent_cli.services.version_service.httpx.get") + def test_prerelease_http_failure_returns_none(self, mock_get: MagicMock) -> None: + """Any httpx error returns None without crashing the caller.""" + import httpx + + mock_get.side_effect = httpx.HTTPError("upstream is down") + assert _fetch_kbagent_latest_version(include_prerelease=True) is None + + +class TestBuildKbagentUpgradeCommand: + """Resolver pre-release opt-in propagation (since v0.42.0).""" + + @patch("keboola_agent_cli.services.version_service.has_server_extras") + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_uv_without_extras_no_prerelease( + self, mock_which: MagicMock, mock_has_server: MagicMock + ) -> None: + mock_which.side_effect = lambda x: "/usr/bin/uv" if x == "uv" else None + mock_has_server.return_value = False + + cmd = build_kbagent_upgrade_command() + + assert cmd is not None + assert "--prerelease=allow" not in cmd + assert "--upgrade" in cmd + + @patch("keboola_agent_cli.services.version_service.has_server_extras") + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_uv_without_extras_with_prerelease( + self, mock_which: MagicMock, mock_has_server: MagicMock + ) -> None: + mock_which.side_effect = lambda x: "/usr/bin/uv" if x == "uv" else None + mock_has_server.return_value = False + + cmd = build_kbagent_upgrade_command(prerelease=True) + + assert cmd is not None + assert "--prerelease=allow" in cmd + # Flag must sit before the install spec (positional last arg). + assert cmd.index("--prerelease=allow") == len(cmd) - 2 + + @patch("keboola_agent_cli.services.version_service.has_server_extras") + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_uv_with_extras_with_prerelease( + self, mock_which: MagicMock, mock_has_server: MagicMock + ) -> None: + mock_which.side_effect = lambda x: "/usr/bin/uv" if x == "uv" else None + mock_has_server.return_value = True + + cmd = build_kbagent_upgrade_command(prerelease=True) + + assert cmd is not None + assert "--prerelease=allow" in cmd + # Extras flag preserved + assert "--with" in cmd + assert "keboola-agent-cli[server]" in cmd + + @patch("keboola_agent_cli.services.version_service.has_server_extras") + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_pip_fallback_with_prerelease( + self, mock_which: MagicMock, mock_has_server: MagicMock + ) -> None: + mock_which.side_effect = lambda x: "/usr/bin/pip" if x == "pip" else None + mock_has_server.return_value = False + + cmd = build_kbagent_upgrade_command(prerelease=True) + + assert cmd is not None + # pip uses --pre, not --prerelease=allow + assert "--pre" in cmd + # Must sit after the `install` verb, before `--upgrade` + assert cmd.index("--pre") == cmd.index("install") + 1 + + @patch("keboola_agent_cli.services.version_service.has_server_extras") + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_uv_prerelease_with_target_version_appends_tag( + self, mock_which: MagicMock, mock_has_server: MagicMock + ) -> None: + """Variant B fix: prerelease+target_version tag-pins install URL. + + Without this, uv resolves the default branch (`main`) which + carries the latest stable pyproject.toml -- even though the + version fetcher advertised a beta tag on a feature branch. Pinning + ``@v`` forces uv to install the exact commit the tag + points to. + """ + mock_which.side_effect = lambda x: "/usr/bin/uv" if x == "uv" else None + mock_has_server.return_value = False + + cmd = build_kbagent_upgrade_command(prerelease=True, target_version="0.44.0b1") + + assert cmd is not None + # Install source = last positional arg, must end with @v. + assert cmd[-1].endswith("@v0.44.0b1") + # --prerelease=allow still required so the resolver accepts the + # PEP 440 pre-release spec at the tag's pyproject.toml. + assert "--prerelease=allow" in cmd + + @patch("keboola_agent_cli.services.version_service.has_server_extras") + @patch("keboola_agent_cli.services.version_service.shutil.which") + def test_uv_stable_with_target_version_ignores_tag( + self, mock_which: MagicMock, mock_has_server: MagicMock + ) -> None: + """target_version is ignored unless prerelease=True. + + Stable upgrades always track main (which IS the stable channel), + so tag-pinning would just add a needless HTTP round-trip without + changing the resolved version. + """ + mock_which.side_effect = lambda x: "/usr/bin/uv" if x == "uv" else None + mock_has_server.return_value = False + + cmd = build_kbagent_upgrade_command(prerelease=False, target_version="0.43.3") + + assert cmd is not None + # No tag suffix when prerelease=False, even if target_version supplied. + assert not cmd[-1].endswith("@v0.43.3") + assert "@v" not in cmd[-1] diff --git a/uv.lock b/uv.lock index 111fa201..aa673310 100644 --- a/uv.lock +++ b/uv.lock @@ -496,7 +496,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.43.2" +version = "0.43.3" source = { editable = "." } dependencies = [ { name = "httpx" },