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.0",
"version": "0.63.1",
"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
34 changes: 34 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,40 @@ jobs:
fi
echo "OK: $expected matches tag $tag"

- name: Build the legacy-named compat wheel (self-update bridge for <=0.62 clients)
# MIGRATION BRIDGE (#424). Pre-0.63 clients were installed as the
# `keboola-agent-cli` distribution. Their (immutable, already-shipped)
# self-update code builds the asset URL with the OLD wheel name
# `keboola_agent_cli-<version>-py3-none-any.whl` and runs
# uv tool install --force "keboola-agent-cli[...] @ <url>"
# After the PyPI rename the release only carried `keboola_cli-*.whl`, so
# that HEAD probe 404'd, the client fell back to the git+ build, and uv
# aborted with `Executable already exists: kbagent` -- self-update
# silently failed for every <=0.62 user.
#
# Shipping a SECOND wheel whose DISTRIBUTION name is still
# `keboola-agent-cli` (identical code; only the [project].name differs)
# lets those clients find their asset and upgrade in place. Dynamic
# APP_NAME (constants.py) keeps `kbagent version` / User-Agent working
# under either distribution name. Remove this step (and the verify
# below) once the <=0.62 fleet has migrated off the legacy name.
run: |
cp pyproject.toml pyproject.toml.orig
sed -i 's/^name = "keboola-cli"$/name = "keboola-agent-cli"/' pyproject.toml
uv build --wheel
mv pyproject.toml.orig pyproject.toml

- name: Verify the legacy compat wheel exists
run: |
tag="$TAG"
version="${tag#v}"
expected="dist/keboola_agent_cli-${version}-py3-none-any.whl"
if [ ! -f "$expected" ]; then
echo "::error::Expected legacy compat wheel $expected but built: $(ls dist/)"
exit 1
fi
echo "OK: legacy compat wheel $expected present"

- name: Upload the wheel to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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.0",
"version": "0.63.1",
"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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-cli"
version = "0.63.0"
version = "0.63.1"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
19 changes: 19 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.63.1": [
"Fix (#424): self-update is repaired for users still on <=0.62.0. The PyPI rename "
"`keboola-agent-cli` -> `keboola-cli` broke `kbagent update` for every already-installed "
"client: the immutable pre-0.63 code probes the release for the OLD wheel name "
"`keboola_agent_cli-<version>-py3-none-any.whl`, which the renamed release no longer "
"carried (404), then falls back to a `git+` build that uv aborts with `Executable already "
"exists: kbagent`. The release workflow now ALSO ships a legacy-named compat wheel "
"(`keboola_agent_cli-<version>-py3-none-any.whl`, identical code, distribution name "
"unchanged) so those clients find their asset and upgrade in place. `APP_NAME` is now "
"resolved dynamically (prefers `keboola-cli`, falls back to `keboola-agent-cli`) so "
"`kbagent version` and the User-Agent keep working under either distribution. The on-disk "
"config dir (`~/.config/keboola-agent-cli/`) is deliberately unchanged.",
"Fix: a FAILED `kbagent update` is no longer reported as `already up to date`. "
"`_compose_update_summary` masked any non-upgraded stage as success, so the self-update "
"breakage above surfaced to users as `kbagent vX (already up to date)` while `kbagent "
"version` correctly showed a newer release available. Failures now render as "
"`kbagent vX update FAILED: <reason>` (full transcript stays in `--json` / `--verbose` "
"output); only an explicit `up_to_date` short-circuit prints the up-to-date line.",
],
"0.63.0": [
"New (#428): the importable SDK is now statically typed -- a PEP 561 `py.typed` "
"marker ships in the wheel and the high-traffic facade operations return typed "
Expand Down
39 changes: 36 additions & 3 deletions src/keboola_agent_cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,45 @@
and ensure consistency across the codebase.
"""

from importlib.metadata import PackageNotFoundError, version

import httpx

# --- Application identity ---
# Distribution/package name; single source for importlib.metadata lookup and
# the User-Agent product token that signs every Keboola API call.
APP_NAME: str = "keboola-cli"
# Distribution name used for the importlib.metadata version lookup and the
# User-Agent product token that signs every Keboola API call.
#
# Resolved DYNAMICALLY so one codebase runs correctly under BOTH the current
# distribution (`keboola-cli`) and the legacy pre-0.63 name
# (`keboola-agent-cli`). The PyPI rename (#424) ships a migration-bridge wheel
# under the legacy name so already-installed <=0.62 users self-update in place;
# under that distribution `version("keboola-cli")` raises PackageNotFoundError,
# which would break `kbagent version` if APP_NAME were a fixed literal.
#
# NOTE: this is the PyPI/distribution identity ONLY. The on-disk config dir
# (`~/.config/keboola-agent-cli/`) is a SEPARATE fixed literal in
# config_store.py -- it must not move with the distribution name or existing
# users would lose their config location.
APP_NAME_CANDIDATES: tuple[str, ...] = ("keboola-cli", "keboola-agent-cli")


def _resolve_app_name() -> str:
"""Return the installed distribution name, preferring the current one.

Falls back to the first (current) candidate when neither is importable --
an editable/source checkout without built metadata -- so callers always get
a stable product token.
"""
for name in APP_NAME_CANDIDATES:
try:
version(name)
return name
except PackageNotFoundError:
continue
return APP_NAME_CANDIDATES[0]


APP_NAME: str = _resolve_app_name()

# --- Sentinel for missing metadata keys ---
# Distinguishes "key absent" from "value is None/null" in branch metadata lookups.
Expand Down
34 changes: 30 additions & 4 deletions src/keboola_agent_cli/services/version_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -821,22 +821,46 @@ def self_update(self, *, include_prerelease: bool = False) -> dict[str, Any]:
}

@staticmethod
def _compose_update_summary(kbagent_result: dict[str, Any], mcp_result: dict[str, Any]) -> str:
"""Build a one-line summary of the two-stage update result."""
def _summarize_failure_tail(message: str | None) -> str:
"""Compress a multi-line failure message to its last non-empty line.

Subprocess failures embed the whole uv/pip transcript; the actionable
line (e.g. ``error: Executable already exists: kbagent``) is last. We
surface only that tail in the one-line summary -- the full transcript
stays in the result's ``output`` for ``--json`` / ``--verbose``.
"""
lines = [ln.strip() for ln in (message or "").splitlines() if ln.strip()]
return lines[-1] if lines else "update failed"

@classmethod
def _compose_update_summary(
cls, kbagent_result: dict[str, Any], mcp_result: dict[str, Any]
) -> str:
"""Build a one-line summary of the two-stage update result.

A non-upgraded stage is only "already up to date" when it explicitly
reports ``up_to_date``. Any other ``updated=False`` means the upgrade
was ATTEMPTED and FAILED -- surface it instead of masking it as
success (the silent-failure bug behind the #424 rename: a failed
self-update was reported as "already up to date").
"""
parts: list[str] = []
if kbagent_result.get("updated"):
parts.append(
f"kbagent v{kbagent_result.get('current_version')}"
f" -> v{kbagent_result.get('latest_version')}"
)
elif kbagent_result.get("current_version") and kbagent_result.get("latest_version"):
elif kbagent_result.get("up_to_date"):
parts.append(f"kbagent v{kbagent_result.get('current_version')} (already up to date)")
elif kbagent_result.get("current_version"):
tail = cls._summarize_failure_tail(kbagent_result.get("message"))
parts.append(f"kbagent v{kbagent_result.get('current_version')} update FAILED: {tail}")

if mcp_result.get("updated"):
current = mcp_result.get("current_version") or "unknown"
latest = mcp_result.get("latest_version") or "?"
parts.append(f"keboola-mcp-server v{current} -> v{latest}")
elif mcp_result.get("updated") is False and mcp_result.get("current_version"):
elif mcp_result.get("up_to_date"):
parts.append(
f"keboola-mcp-server v{mcp_result.get('current_version')} (already up to date)"
)
Expand Down Expand Up @@ -865,6 +889,7 @@ def _update_kbagent(*, include_prerelease: bool = False) -> dict[str, Any]:
if up_to_date is True:
return {
"updated": False,
"up_to_date": True,
"current_version": old_version,
"latest_version": kbagent_latest,
"message": f"kbagent v{old_version} is already up to date.",
Expand Down Expand Up @@ -943,6 +968,7 @@ def _update_mcp() -> dict[str, Any]:
if up_to_date is True:
return {
"updated": False,
"up_to_date": True,
"current_version": local_version,
"latest_version": latest_version,
"install_method": method,
Expand Down
40 changes: 40 additions & 0 deletions tests/test_http_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import platform
from typing import SupportsIndex
from unittest.mock import patch

import httpx
import pytest
Expand Down Expand Up @@ -643,6 +644,45 @@ def test_base_client_overrides_caller_user_agent(self, httpx_mock) -> None:
assert "stale/0.0.1" not in request.headers["User-Agent"]


class TestResolveAppName:
"""Dynamic distribution-name resolution (PyPI rename bridge, #424).

One codebase must run under BOTH `keboola-cli` (current) and the legacy
`keboola-agent-cli` distribution (the migration-bridge wheel), so
`version()` / User-Agent never break depending on how the user installed.
"""

def _patch_version(self, installed: set[str]):
from importlib.metadata import PackageNotFoundError

def fake_version(name: str) -> str:
if name in installed:
return "0.63.1"
raise PackageNotFoundError(name)

return patch("keboola_agent_cli.constants.version", side_effect=fake_version)

def test_prefers_current_name(self) -> None:
from keboola_agent_cli.constants import _resolve_app_name

with self._patch_version({"keboola-cli", "keboola-agent-cli"}):
assert _resolve_app_name() == "keboola-cli"

def test_falls_back_to_legacy_name(self) -> None:
from keboola_agent_cli.constants import _resolve_app_name

# Installed via the bridge wheel -> only the legacy distribution exists.
with self._patch_version({"keboola-agent-cli"}):
assert _resolve_app_name() == "keboola-agent-cli"

def test_defaults_to_current_when_neither_installed(self) -> None:
from keboola_agent_cli.constants import APP_NAME_CANDIDATES, _resolve_app_name

# Editable/source checkout without built metadata -> stable default.
with self._patch_version(set()):
assert _resolve_app_name() == APP_NAME_CANDIDATES[0] == "keboola-cli"


class TestBaseHttpClientContextManager:
"""Test context manager protocol on BaseHttpClient."""

Expand Down
56 changes: 56 additions & 0 deletions tests/test_version_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1235,3 +1235,59 @@ def test_user_facing_command_opts_into_prereleases(
assert expected_flag in upgrade_command, (
f"{method} upgrade_command missing {expected_flag}: {upgrade_command!r}"
)


class TestComposeUpdateSummary:
"""The one-line summary must distinguish up-to-date from a FAILED update.

Regression for the #424 rename breakage: a self-update that failed (e.g.
`Executable already exists: kbagent`) was rendered as `already up to date`,
so users had no signal the upgrade never happened.
"""

def test_kbagent_failure_is_surfaced_not_masked(self) -> None:
kbagent = {
"updated": False,
"current_version": "0.62.0",
"latest_version": "0.63.1",
"message": (
"Update failed: Resolved 46 packages\n"
"error: Executable already exists: kbagent (use `--force` to overwrite)"
),
}
mcp = {"updated": False, "up_to_date": True, "current_version": "1.66.0"}

summary = VersionService._compose_update_summary(kbagent, mcp)

assert "update FAILED" in summary
assert "Executable already exists: kbagent" in summary
# The masking bug: a failure must NOT be reported as up to date.
assert "(already up to date)" not in summary.split("|")[0]

def test_kbagent_up_to_date_renders_up_to_date(self) -> None:
kbagent = {
"updated": False,
"up_to_date": True,
"current_version": "0.63.1",
"latest_version": "0.63.1",
}
mcp = {"updated": False, "up_to_date": True, "current_version": "1.66.0"}

summary = VersionService._compose_update_summary(kbagent, mcp)

assert "kbagent v0.63.1 (already up to date)" in summary
assert "FAILED" not in summary

def test_kbagent_upgraded_renders_arrow(self) -> None:
kbagent = {"updated": True, "current_version": "0.62.0", "latest_version": "0.63.1"}
mcp = {"updated": False, "up_to_date": True, "current_version": "1.66.0"}

summary = VersionService._compose_update_summary(kbagent, mcp)

assert "kbagent v0.62.0 -> v0.63.1" in summary

def test_failure_tail_is_last_nonempty_line(self) -> None:
msg = "Update failed: line one\n\n error: the real reason \n"
assert VersionService._summarize_failure_tail(msg) == "error: the real reason"
assert VersionService._summarize_failure_tail("") == "update failed"
assert VersionService._summarize_failure_tail(None) == "update failed"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading