Skip to content
Open
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
20 changes: 10 additions & 10 deletions .agents/skills/bump-cli-compat/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ If any tag doesn't exist, report the error and stop.

### Step 3: Read current manifest

Read `internal/build/cli-compat.json` and `AgentSkillsFallbackVersion` in `internal/build/clicompat.go`. Note the current versions and the list of versioned entries. The newest CLI range uses `"skills": "latest"`; the compiled constant supplies the tested fallback. Keep older released ranges pinned. Routine skills releases need no manifest bump. CLIs before `1.6.0` reject `"latest"` and use a valid cached or embedded manifest without returning a parse error; losing remote AppKit and skills compatibility updates for these CLIs is accepted.
Read `internal/build/cli-compat.json`. Note the current versions and the list of versioned entries. The newest CLI range uses `"skills": "latest"`; release builds embed the release that is latest at build time, and the newest pinned range below it is the fallback. Keep older released ranges pinned. Routine skills releases need no manifest bump. CLIs before `1.6.0` reject `"latest"` and use a valid cached or embedded manifest without returning a parse error; losing remote AppKit and skills compatibility updates for these CLIs is accepted.

### Step 4: Determine update type

Expand All @@ -81,17 +81,17 @@ Ask the user:

### Step 4a: No breaking changes (update in-place)

Update the **highest versioned entry** to the new `appkit` version, keeping `"skills": "latest"`. Update `AgentSkillsFallbackVersion` in `internal/build/clicompat.go` to the tested skills version. Do NOT add new versioned keys. The manifest is range-based: updating the highest entry automatically covers all CLI versions in that range.
Update the **highest versioned entry** to the new `appkit` version, keeping `"skills": "latest"`. Do NOT add new versioned keys. The manifest is range-based: updating the highest entry automatically covers all CLI versions in that range.

Write the updated `internal/build/cli-compat.json` and `internal/build/clicompat.go`.
Write the updated `internal/build/cli-compat.json`.

### Step 4b: Breaking changes (add new entry)

Ask the user for the **minimum CLI version** that supports the new features.

Add a new entry keyed to that CLI version with the new `appkit` and `"skills": "latest"`. Update `AgentSkillsFallbackVersion` in `internal/build/clicompat.go` to the tested skills version. If skills compatibility changed, replace `"latest"` in the affected older ranges with the last compatible release. Preserve unrelated AppKit and skills compatibility boundaries.
Add a new entry keyed to that CLI version with the new `appkit` and `"skills": "latest"`. If skills compatibility changed, replace `"latest"` in the affected older ranges with the last compatible release. Preserve unrelated AppKit and skills compatibility boundaries.

Write the updated `internal/build/cli-compat.json` and `internal/build/clicompat.go`.
Write the updated `internal/build/cli-compat.json`.

### Step 5: Validate

Expand All @@ -107,21 +107,21 @@ If validation fails, show the errors and fix them before proceeding.

```bash
# Create a new branch from the current branch (or main)
git checkout -b bump-cli-compat-appkit-{appkit_version}-skills-fallback-{skills_version}
git checkout -b bump-cli-compat-appkit-{appkit_version}

# Stage and commit
git add internal/build/cli-compat.json internal/build/clicompat.go
git commit -s -m "Bump cli-compat to appkit {appkit_version}, skills fallback {skills_version}"
git add internal/build/cli-compat.json
git commit -s -m "Bump cli-compat to appkit {appkit_version}"

# Push and create PR
git push -u origin HEAD
gh pr create \
--title "Bump cli-compat to appkit {appkit_version}, skills fallback {skills_version}" \
--title "Bump cli-compat to appkit {appkit_version}" \
--body "$(cat <<'EOF'
## Summary
Bump `cli-compat.json` to use:
- AppKit `{appkit_version}`
- Agent Skills track latest, with compiled fallback `{skills_version}`
- Agent Skills track latest

## Checklist
- [ ] Evals passed with no regressions
Expand Down
148 changes: 148 additions & 0 deletions .github/scripts/pin-cli-compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Resolve floating skills versions for go:embed without changing the checkout.

GoReleaser and the local build tasks pass the resulting overlay to go build.
The GitHub CLI authenticates using GITHUB_TOKEN in release CI, or its local
credentials for developer builds. If the lookup fails, "latest" stays in the
manifest and the CLI falls back to the newest pin below that range.
"""

import json
import os
import re
import subprocess
import sys
from pathlib import Path

SOURCE = Path("internal/build/cli-compat.json")
OUTPUT = Path(".databricks/cli-compat")


def fetch_latest_skills_release() -> str:
return subprocess.check_output(
[
"gh",
"api",
"repos/databricks/databricks-agent-skills/releases/latest",
"--jq",
".tag_name",
],
text=True,
timeout=60,
).strip()


def pin_latest_skills(manifest: dict, fetch_latest=fetch_latest_skills_release):
"""Resolve once for every floating range, preserving pins and other fields.

Returns a warning if the lookup fails.

>>> manifest = {
... "1.22.0": {"appkit": "0.84.0", "skills": "latest"},
... "1.23.0": {"skills": "latest"},
... "1.20.0": {"skills": "0.2.30"},
... }
>>> from unittest.mock import Mock
>>> fetch = Mock(return_value="v0.2.32")
>>> pin_latest_skills(manifest, fetch)
>>> manifest
{'1.22.0': {'appkit': '0.84.0', 'skills': '0.2.32', 'skills_resolved_from_latest': True}, '1.23.0': {'skills': '0.2.32', 'skills_resolved_from_latest': True}, '1.20.0': {'skills': '0.2.30'}}
>>> fetch.call_count
1
>>> pin_latest_skills(manifest, fetch)
>>> fetch.call_count
1
>>> pin_latest_skills({"1.22.0": {"skills": "latest"}}, lambda: "0.2.33")

A failed lookup or an invalid tag leaves "latest" in place.

>>> failures = [
... Mock(side_effect=FileNotFoundError("gh")),
... Mock(side_effect=subprocess.CalledProcessError(1, "gh")),
... Mock(side_effect=subprocess.TimeoutExpired("gh", 60)),
... *(Mock(return_value=tag) for tag in ("latest", "main", "", "v1.2", "v01.2.3")),
... ]
>>> for fetch in failures:
... manifest = {"1.20.0": {"skills": "0.2.30"}, "1.22.0": {"appkit": "0.84.0", "skills": "latest"}}
... assert "Could not resolve the latest skills release" in pin_latest_skills(manifest, fetch), fetch
... assert manifest["1.22.0"] == {"appkit": "0.84.0", "skills": "latest"}, fetch
"""
floating = [entry for entry in manifest.values() if entry.get("skills") == "latest"]
if not floating:
return

try:
version = fetch_latest().removeprefix("v")
if not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version):
raise ValueError(f"latest skills release must be a concrete stable version, got {version!r}")
except (OSError, subprocess.SubprocessError, ValueError) as err:
return f"Could not resolve the latest skills release ({err}). The CLI falls back to the newest pinned skills version."
for entry in floating:
entry["skills"] = version
entry["skills_resolved_from_latest"] = True


def build_overlay(root: Path, fetch_latest=fetch_latest_skills_release):
"""Write resolved build inputs while leaving the checked-in manifest intact.

Returns the warning from pin_latest_skills, if any.

>>> from tempfile import TemporaryDirectory
>>> from unittest.mock import Mock
>>> with TemporaryDirectory() as tmp:
... root = Path(tmp)
... source = root / SOURCE
... source.parent.mkdir(parents=True)
... original = '{"1.22.0": {"appkit": "0.84.0", "skills": "latest"}}'
... _ = source.write_text(original)
... build_overlay(root, lambda: "v0.2.32")
... overlay = json.loads((root / OUTPUT / "overlay.json").read_text())
... assert overlay == {"Replace": {"internal/build/cli-compat.json": ".databricks/cli-compat/cli-compat.json"}}
... assert json.loads((root / OUTPUT / "cli-compat.json").read_text())["1.22.0"]["skills"] == "0.2.32"
... assert source.read_text() == original

Release CI can build tags from before cli-compat.json was introduced.

>>> with TemporaryDirectory() as tmp:
... root = Path(tmp)
... build_overlay(root, Mock(side_effect=AssertionError("must not fetch")))
... assert json.loads((root / OUTPUT / "overlay.json").read_text()) == {"Replace": {}}

An all-pinned manifest must build without GitHub credentials or even gh.

>>> with TemporaryDirectory() as tmp:
... root = Path(tmp)
... source = root / SOURCE
... source.parent.mkdir(parents=True)
... _ = source.write_text('{"1.22.0": {"skills": "0.2.30"}}')
... build_overlay(root, Mock(side_effect=AssertionError("must not fetch")))
... assert json.loads((root / OUTPUT / "cli-compat.json").read_text())["1.22.0"]["skills"] == "0.2.30"
"""
source = root / SOURCE
output = root / OUTPUT
output.mkdir(parents=True, exist_ok=True)
replacements = {}
warning = None
if source.exists():
manifest = json.loads(source.read_text(encoding="utf-8"))
warning = pin_latest_skills(manifest, fetch_latest)
(output / "cli-compat.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
# Relative paths, because go build matches overlay entries against its
# own working directory, which may be a symlink that resolve() expands.
replacements[SOURCE.as_posix()] = (OUTPUT / "cli-compat.json").as_posix()
(output / "overlay.json").write_text(json.dumps({"Replace": replacements}, indent=2) + "\n", encoding="utf-8")
return warning


def warn(message: str) -> None:
print(f"WARNING: {message}", file=sys.stderr)
# GoReleaser shows hook output only with --verbose, so release builds
# surface the warning in the GitHub Actions job summary instead.
if summary := os.environ.get("GITHUB_STEP_SUMMARY"):
with open(summary, "a", encoding="utf-8") as f:
f.write(f"> [!WARNING]\n> {message}\n")


if __name__ == "__main__":
if warning := build_overlay(Path.cwd()):
warn(warning)
2 changes: 2 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ version: 2
before:
hooks:
- go mod download
- python3 .github/scripts/pin-cli-compat.py

builds:
- env:
Expand All @@ -17,6 +18,7 @@ builds:
mod_timestamp: '{{ .CommitTimestamp }}'
flags:
- -trimpath
- -overlay=.databricks/cli-compat/overlay.json
ldflags:
- '-s -w'
- -X github.com/databricks/cli/internal/build.buildProjectName={{ .ProjectName }}
Expand Down
1 change: 1 addition & 0 deletions .nextchanges/cli/aitools-skills-release-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Fix `aitools list` and plugin installation failures when GitHub's release API is unavailable or rate limited, and embed a concrete skills fallback during release builds. ([#7162](https://github.com/databricks/cli/pull/7162))
11 changes: 9 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ tasks:
desc: Lint first-party Python scripts with ruff
sources:
- "**/*.py"
- .github/scripts/pin-cli-compat.py
- ruff.toml
cmds:
# Lints the whole repo; fixtures and generated outputs are skipped via
Expand Down Expand Up @@ -173,6 +174,7 @@ tasks:
desc: Lint-fix and format Python files
sources:
- "**/*.py"
- .github/scripts/pin-cli-compat.py
- ruff.toml
cmds:
# Auto-fix the lint issues that ruff can fix (whole repo, minus lint.exclude);
Expand Down Expand Up @@ -345,19 +347,22 @@ tasks:
- exclude: tools/**
- go.mod
- go.sum
- .github/scripts/pin-cli-compat.py
- "{{.EMBED_SOURCES}}"
generates:
- cli
cmds:
- "go build"
- 'uv run -p ">=3.11" --no-project python .github/scripts/pin-cli-compat.py'
- "go build -overlay=.databricks/cli-compat/overlay.json"

snapshot:
desc: Build snapshot binary to .databricks/databricks
sources: *BUILD_SOURCES
generates:
- .databricks/databricks
cmds:
- "go build -o .databricks/databricks"
- 'uv run -p ">=3.11" --no-project python .github/scripts/pin-cli-compat.py'
- "go build -overlay=.databricks/cli-compat/overlay.json -o .databricks/databricks"

snapshot-release:
desc: Build release binaries locally without uploading
Expand All @@ -372,6 +377,7 @@ tasks:
- go.mod
- go.sum
- .goreleaser.yaml
- .github/scripts/pin-cli-compat.py
- .github/scripts/sign-windows.sh
- "{{.EMBED_SOURCES}}"
generates:
Expand Down Expand Up @@ -404,6 +410,7 @@ tasks:
desc: Run doctests for all Python files in the repo
sources:
- "**/*.py"
- .github/scripts/pin-cli-compat.py
cmds:
- 'uv run -p ">=3.11" --no-project python tools/run_doctests.py'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Cloud = false
23 changes: 23 additions & 0 deletions acceptance/cmd/aitools/list-release-rate-limited/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@

=== list falls back to the embedded skills version when the release lookup is rate limited

>>> [CLI] aitools list --output json
Warn: Could not resolve latest skills release (HTTP 403 fetching latest skills release); falling back to embedded skills version 0.2.32
{
"release": "0.2.32",
"skills": [
"test-stable"
],
"agents": [
"claude-code",
"cursor",
"codex",
"opencode",
"copilot",
"antigravity",
"pi",
"gemini",
"goose",
"kiro"
]
}
7 changes: 7 additions & 0 deletions acceptance/cmd/aitools/list-release-rate-limited/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Isolate HOME so parallel aitools tests don't race on a shared ~/.databricks.
sethome home

title "list falls back to the embedded skills version when the release lookup is rate limited\n"
# The agent list is what editor integrations render; detection depends on the
# host, so only the names are printed.
trace $CLI aitools list --output json | jq '{release, skills: [.skills[].name], agents: [.agents[].name]}'
31 changes: 31 additions & 0 deletions acceptance/cmd/aitools/list-release-rate-limited/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Mock server replaces api.github.com and raw.githubusercontent.com. No
# DATABRICKS_SKILLS_REF, so list must look up the latest skills release.
Env.DATABRICKS_SKILLS_API_URL = "$DATABRICKS_HOST"
Env.DATABRICKS_SKILLS_BASE_URL = "$DATABRICKS_HOST"
Env.DATABRICKS_FORCE_EMBEDDED_COMPAT = "true"

Ignore = [
"home",
]

[[Server]]
Pattern = "GET /repos/databricks/databricks-agent-skills/releases/latest"
Response.StatusCode = 403
Response.Body = '''{"message": "API rate limit exceeded"}'''

[[Server]]
Pattern = "GET /{ref}/manifest.json"
Response.Body = '''
{
"version": "2",
"updated_at": "2026-01-01T00:00:00Z",
"skills": {
"test-stable": {
"version": "1.0.0",
"description": "Stable test skill",
"files": ["SKILL.md"],
"repo_dir": "skills"
}
}
}
'''
5 changes: 4 additions & 1 deletion cmd/aitools/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,9 +526,12 @@ func executePlan(ctx context.Context, src installer.ManifestSource, plan []agent

pluginCount := 0
if len(pluginItems) > 0 {
// Plugins install from the agent's marketplace, so the skills ref only
// labels the recorded version. Record it as unknown when unresolved.
ref, _, err := installer.GetSkillsRef(ctx)
if err != nil {
return outcomes, err
log.Debugf(ctx, "Recording plugin version as unknown: %v", err)
ref = ""
}
records := map[string]installer.PluginRecord{}
for _, it := range pluginItems {
Expand Down
Loading
Loading