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
1 change: 1 addition & 0 deletions .nextchanges/cli/aitools-plugin-ref-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* `databricks aitools install` no longer fails a plugin-only install when the lookup of the latest skills release on GitHub fails or is rate-limited. The plugin is installed and its version is recorded as unknown. ([#7474](https://github.com/databricks/cli/pull/7474))
11 changes: 7 additions & 4 deletions cmd/aitools/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -591,9 +591,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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] After this change, executePlan no longer fails on the plugin ref lookup. The comment above topLevelFailure in buildInstallOutput (line 710) still lists "ref lookup" among the top-level failures, and the skills group's own lookup is already covered by "skills-group install". Readers of that comment will expect a lookup failure in the top-level error/error_category fields, where it no longer appears. Consider dropping "ref lookup" from that list.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped "ref lookup" from that list in 21231db. The comment now names the skills-group install and plugin recording as the top-level failures.

ref = ""
}
records := map[string]installer.PluginRecord{}
for _, it := range pluginItems {
Expand Down Expand Up @@ -703,9 +706,9 @@ func buildInstallOutput(scope string, outcomes []agentOutcome, runErr error) ins
}
out.Agents = append(out.Agents, entry)
}
// A top-level failure (skills-group install, ref lookup, plugin recording)
// has no per-agent entry, so surface it here too; otherwise the consumer sees
// a non-zero exit with an empty agents array and no reason. Per-agent failures
// A top-level failure (skills-group install, plugin recording) has no
// per-agent entry, so surface it here too; otherwise the consumer sees a
// non-zero exit with an empty agents array and no reason. Per-agent failures
// stay in the agents entries above and are not repeated here.
if e := topLevelFailure(runErr); e != nil {
out.Error = e.Error()
Expand Down
48 changes: 48 additions & 0 deletions cmd/aitools/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -269,6 +271,52 @@ func TestExecutePlanSkipBlockedPluginExit0(t *testing.T) {
require.Error(t, err)
}

func TestExecutePlanPluginWithUnresolvedSkillsRelease(t *testing.T) {
tmp := t.TempDir()
t.Setenv("DATABRICKS_SKILLS_REF", "")
t.Setenv("DATABRICKS_FORCE_EMBEDDED_COMPAT", "false")
t.Setenv("DATABRICKS_CACHE_ENABLED", "true")
t.Setenv("DATABRICKS_CACHE_DIR", tmp)
require.NoError(t, os.WriteFile(filepath.Join(tmp, "compat-manifest.json"), []byte(`{"0.0.0":{"appkit":"0.84.0","skills":"latest"}}`), 0o600))
lookups := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
lookups++
w.WriteHeader(http.StatusForbidden)
}))
t.Cleanup(srv.Close)
t.Setenv("DATABRICKS_CLI_GITHUB_API_URL", srv.URL)

installRef, recordRef := "unset", "unset"
orig := installPluginForAgentFn
origRec := recordPluginInstallsFn
origCleanup := cleanupLegacyFn
t.Cleanup(func() {
installPluginForAgentFn = orig
recordPluginInstallsFn = origRec
cleanupLegacyFn = origCleanup
})
cleanupLegacyFn = func(context.Context, *agents.Agent, string) error { return nil }
installPluginForAgentFn = func(_ context.Context, _ *agents.Agent, scope, ref string) (installer.PluginRecord, error) {
installRef = ref
return installer.PluginRecord{Plugin: "databricks", Scope: scope, Version: installer.DisplaySkillsVersion(ref)}, nil
}
recordPluginInstallsFn = func(_ context.Context, _ string, _ map[string]installer.PluginRecord, ref string) error {
recordRef = ref
return nil
}

claude := testPluginAgent(agents.NameClaudeCode, "Claude Code", "claude")
plan := buildPlan([]*agents.Agent{claude}, installer.ScopeGlobal, false, true)
outcomes, err := executePlan(cmdio.MockDiscard(t.Context()), nil, plan, installer.InstallOptions{Scope: installer.ScopeGlobal}, false)
require.NoError(t, err)
require.Len(t, outcomes, 1)
assert.Equal(t, outcomeInstalled, outcomes[0].status)
assert.Equal(t, 1, lookups)
// The marketplace picks the plugin version, so a failed lookup records it as unknown.
assert.Empty(t, installRef)
assert.Empty(t, recordRef)
}

// --- RunE: skills-only path (config-dir detection, no plugin) ---

func TestInstallSkillsOnlyAllAgents(t *testing.T) {
Expand Down
8 changes: 6 additions & 2 deletions libs/aitools/installer/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,8 @@ func claudeAlreadyDisabled(err error) bool {

// RecordPluginInstalls persists plugin install records into the state file for
// the given CLI scope (global or project), creating state if none exists. ref
// is the resolved skills release the install corresponds to.
// is the resolved skills release the install corresponds to. An empty ref
// (release unresolved) keeps the existing state.Release.
func RecordPluginInstalls(ctx context.Context, cliScope string, records map[string]PluginRecord, ref string) error {
dir, err := skillsDir(ctx, cliScope)
if err != nil {
Expand All @@ -414,7 +415,10 @@ func RecordPluginInstalls(ctx context.Context, cliScope string, records map[stri
state.Plugins = make(map[string]PluginRecord, len(records))
}
maps.Copy(state.Plugins, records)
state.Release = ref
// An unresolved release keeps the one recorded by earlier installs.
if ref != "" {
state.Release = ref
}
state.LastUpdated = time.Now()
state.Scope = cliScope
return SaveState(dir, state)
Expand Down
19 changes: 19 additions & 0 deletions libs/aitools/installer/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,25 @@ func TestUninstallPluginKeepMarketplaceFlag(t *testing.T) {
}
}

func TestRecordPluginInstallsUnresolvedRefKeepsRelease(t *testing.T) {
setupTestHome(t)
ctx := cmdio.MockDiscard(t.Context())
dir, err := GlobalSkillsDir(ctx)
require.NoError(t, err)

require.NoError(t, RecordPluginInstalls(ctx, ScopeGlobal, map[string]PluginRecord{
agents.NameClaudeCode: {Marketplace: "databricks-agent-skills", Plugin: "databricks", Version: "0.2.6"},
}, "v0.2.6"))
require.NoError(t, RecordPluginInstalls(ctx, ScopeGlobal, map[string]PluginRecord{
agents.NameCodex: {Marketplace: "databricks-agent-skills", Plugin: "databricks"},
}, ""))

st, err := LoadState(dir)
require.NoError(t, err)
assert.Equal(t, "v0.2.6", st.Release)
assert.Contains(t, st.Plugins, agents.NameCodex)
}

func TestRecordPluginInstallsThenSkillsInstallNoPanic(t *testing.T) {
tmp := setupTestHome(t)
ctx := cmdio.MockDiscard(t.Context())
Expand Down
Loading