diff --git a/acceptance/experimental/aitools/skills/install-experimental-empty/output.txt b/acceptance/experimental/aitools/skills/install-experimental-empty/output.txt index 6fd3ed10d7e..188ea02fa30 100644 --- a/acceptance/experimental/aitools/skills/install-experimental-empty/output.txt +++ b/acceptance/experimental/aitools/skills/install-experimental-empty/output.txt @@ -3,7 +3,7 @@ >>> [CLI] experimental aitools install --skills-only --global --experimental Command "install" is deprecated, use "databricks aitools install" instead. Flag --global has been deprecated, use --scope=global -Installing Databricks AI skills for Claude Code... +Installing Databricks skills for Claude Code... Using skills version test-ref Warn: --experimental was set but the manifest at test-ref exposes no experimental skills. Set DATABRICKS_SKILLS_REF to a release that includes them (or =main for the latest). Installed 1 skill. diff --git a/acceptance/experimental/aitools/skills/install-specific/output.txt b/acceptance/experimental/aitools/skills/install-specific/output.txt index d1d47861a30..74e415457d8 100644 --- a/acceptance/experimental/aitools/skills/install-specific/output.txt +++ b/acceptance/experimental/aitools/skills/install-specific/output.txt @@ -3,7 +3,7 @@ >>> [CLI] experimental aitools install --skills-only --global --skills test-stable-a Command "install" is deprecated, use "databricks aitools install" instead. Flag --global has been deprecated, use --scope=global -Installing Databricks AI skills for Claude Code... +Installing Databricks skills for Claude Code... Using skills version test-ref Installed 1 skill. @@ -11,7 +11,7 @@ Installed 1 skill. >>> [CLI] experimental aitools install --skills-only --global --skills test-exp --experimental Command "install" is deprecated, use "databricks aitools install" instead. Flag --global has been deprecated, use --scope=global -Installing Databricks AI skills for Claude Code... +Installing Databricks skills for Claude Code... Using skills version test-ref Installed 1 skill. @@ -19,7 +19,7 @@ Installed 1 skill. >>> [CLI] experimental aitools install --skills-only --global --skills test-exp Command "install" is deprecated, use "databricks aitools install" instead. Flag --global has been deprecated, use --scope=global -Installing Databricks AI skills for Claude Code... +Installing Databricks skills for Claude Code... Using skills version test-ref Error: skill "test-exp" is experimental; use --experimental to install diff --git a/acceptance/experimental/aitools/skills/install/output.txt b/acceptance/experimental/aitools/skills/install/output.txt index 1350774e06f..2cc59803022 100644 --- a/acceptance/experimental/aitools/skills/install/output.txt +++ b/acceptance/experimental/aitools/skills/install/output.txt @@ -3,7 +3,7 @@ >>> [CLI] experimental aitools install --skills-only --global Command "install" is deprecated, use "databricks aitools install" instead. Flag --global has been deprecated, use --scope=global -Installing Databricks AI skills for Claude Code... +Installing Databricks skills for Claude Code... Using skills version test-ref Installed 1 skill. @@ -11,7 +11,7 @@ Installed 1 skill. >>> [CLI] experimental aitools install --skills-only --global --experimental Command "install" is deprecated, use "databricks aitools install" instead. Flag --global has been deprecated, use --scope=global -Installing Databricks AI skills for Claude Code... +Installing Databricks skills for Claude Code... Using skills version test-ref Installed 2 skills. @@ -19,6 +19,6 @@ Installed 2 skills. >>> [CLI] experimental aitools install --skills-only --global --experimental Command "install" is deprecated, use "databricks aitools install" instead. Flag --global has been deprecated, use --scope=global -Installing Databricks AI skills for Claude Code... +Installing Databricks skills for Claude Code... Using skills version test-ref Installed 2 skills. diff --git a/acceptance/experimental/aitools/skills/update-prune/output.txt b/acceptance/experimental/aitools/skills/update-prune/output.txt index 090f2500129..8664f1fce31 100644 --- a/acceptance/experimental/aitools/skills/update-prune/output.txt +++ b/acceptance/experimental/aitools/skills/update-prune/output.txt @@ -3,7 +3,7 @@ >>> [CLI] experimental aitools install --skills-only --global Command "install" is deprecated, use "databricks aitools install" instead. Flag --global has been deprecated, use --scope=global -Installing Databricks AI skills for Claude Code... +Installing Databricks skills for Claude Code... Using skills version test-ref Installed 2 skills. diff --git a/acceptance/help/output.txt b/acceptance/help/output.txt index 910f761eaae..c5fe67ce374 100644 --- a/acceptance/help/output.txt +++ b/acceptance/help/output.txt @@ -169,7 +169,7 @@ Developer Tools Additional Commands: account Databricks Account Commands - aitools Databricks AI Tools for coding agents + aitools Databricks skills and plugins for coding agents api Perform Databricks API call auth Authentication related commands cache Local cache related commands diff --git a/cmd/aitools/aitools.go b/cmd/aitools/aitools.go index 99ffbb8bc82..94747d94390 100644 --- a/cmd/aitools/aitools.go +++ b/cmd/aitools/aitools.go @@ -7,8 +7,8 @@ import ( func NewAitoolsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "aitools", - Short: "Databricks AI Tools for coding agents", - Long: `Install Databricks skills into your coding agent so it can work + Short: "Databricks skills and plugins for coding agents", + Long: `Install Databricks skills and plugins into your coding agent so it can work effectively with Databricks resources (bundles, jobs, SQL, and more). Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub diff --git a/cmd/aitools/install.go b/cmd/aitools/install.go index abef5ae8219..dd6a8f97f3c 100644 --- a/cmd/aitools/install.go +++ b/cmd/aitools/install.go @@ -61,8 +61,8 @@ func NewInstallCmd() *cobra.Command { cmd := &cobra.Command{ Use: "install", - Short: "Install Databricks AI tools for coding agents", - Long: `Install Databricks AI tools for detected coding agents. + Short: "Install Databricks skills and plugins for coding agents", + Long: `Install Databricks skills and plugins for detected coding agents. By default this installs the databricks plugin through each agent's own CLI (Claude Code, Codex, GitHub Copilot). Agents with no plugin (OpenCode, @@ -178,7 +178,7 @@ func selectAgents(ctx context.Context, scope string, skillsOnly bool) ([]*agents // Interactive: the picker decides; a prompt error or empty selection is a real // error, not a "nothing detected" no-op. if cmdio.IsPromptSupported(ctx) { - return promptAgentSelection(ctx, agentChoices(ctx)) + return promptAgentSelection(ctx, agentChoices(ctx, scope, skillsOnly)) } var selected []*agents.Agent @@ -194,21 +194,36 @@ func selectAgents(ctx context.Context, scope string, skillsOnly bool) ([]*agents return selected, nil } -// agentChoices builds the interactive picker rows over every known agent. -func agentChoices(ctx context.Context) []agentChoice { +// agentChoices builds the interactive picker rows over every known agent. The +// rows are scope-aware: an agent that can't be acted on in the chosen scope +// (e.g. a files-only agent under project scope) shows why and is not pre-checked, +// so the picker never offers an option that would fail at install time. +func agentChoices(ctx context.Context, scope string, skillsOnly bool) []agentChoice { cmdio.LogString(ctx, "Detecting coding agents...") choices := make([]agentChoice, 0, len(agents.Registry)) for _, a := range agents.Registry { + item := planItemFor(a, scope, skillsOnly, false) + label := agentChoiceLabel(ctx, a, item) choices = append(choices, agentChoice{ agent: a, - label: a.DisplayName + " " + agentStateLabel(a.DisplayState(ctx)), - preselect: a.IsPreselected(ctx), + label: a.DisplayName + " " + label, + preselect: a.IsPreselected(ctx) && item.delivery != deliverySkip, }) - cmdio.LogString(ctx, fmt.Sprintf(" %-16s %s", a.DisplayName, agentStateLabel(a.DisplayState(ctx)))) + cmdio.LogString(ctx, fmt.Sprintf(" %-16s %s", a.DisplayName, label)) } return choices } +// agentChoiceLabel is the picker label: the detection state, plus the skip +// reason when the agent can't be acted on in the chosen scope. +func agentChoiceLabel(ctx context.Context, a *agents.Agent, item agentPlanItem) string { + label := agentStateLabel(a.DisplayState(ctx)) + if item.delivery == deliverySkip { + return label + " · " + item.reason + } + return label +} + // agentStateLabel is the short human label for a detection state. func agentStateLabel(s agents.DisplayState) string { switch s { @@ -260,26 +275,41 @@ func defaultPromptAgentSelection(_ context.Context, choices []agentChoice) ([]*a func buildPlan(targetAgents []*agents.Agent, scope string, skillsOnly, explicit bool) []agentPlanItem { plan := make([]agentPlanItem, 0, len(targetAgents)) for _, a := range targetAgents { - item := agentPlanItem{agent: a, explicit: explicit} - switch { - case skillsOnly || a.Plugin == nil: + plan = append(plan, planItemFor(a, scope, skillsOnly, explicit)) + } + return plan +} + +// planItemFor resolves the delivery and scope for a single agent in the given +// install scope. It is shared by buildPlan and the interactive picker so the +// picker and the plan agree on what an agent will (or won't) do. +func planItemFor(a *agents.Agent, scope string, skillsOnly, explicit bool) agentPlanItem { + item := agentPlanItem{agent: a, explicit: explicit} + switch { + case skillsOnly || a.Plugin == nil: + // Raw-skills delivery. Only some agents support project-scoped skills, so + // skip the rest up front instead of offering an option that fails at + // install time (the skills installer rejects them anyway). + if scope == installer.ScopeProject && !a.SupportsProjectScope { + item.delivery = deliverySkip + item.reason = "does not support project-scoped skills" + } else { item.delivery = deliverySkills - case a.Plugin.ManualOnly: - item.delivery = deliveryManualCursor - item.reason = a.Plugin.ManualInstructions - default: - nativeScope, ok, reason := mapAgentScope(a, scope) - if !ok { - item.delivery = deliverySkip - item.reason = reason - } else { - item.delivery = deliveryPlugin - item.scope = nativeScope - } } - plan = append(plan, item) + case a.Plugin.ManualOnly: + item.delivery = deliveryManualCursor + item.reason = a.Plugin.ManualInstructions + default: + nativeScope, ok, reason := mapAgentScope(a, scope) + if !ok { + item.delivery = deliverySkip + item.reason = reason + } else { + item.delivery = deliveryPlugin + item.scope = nativeScope + } } - return plan + return item } // printPlanSummary renders the interactive plan summary before the confirm. @@ -353,7 +383,7 @@ func executePlan(ctx context.Context, src installer.ManifestSource, plan []agent if err := cleanupLegacyFn(ctx, it.agent, opts.Scope); err != nil { log.Debugf(ctx, "Legacy skill cleanup for %s failed: %v", it.agent.DisplayName, err) } - cmdio.LogString(ctx, fmt.Sprintf(" %s databricks plugin v%s", it.agent.DisplayName, rec.Version)) + cmdio.LogString(ctx, fmt.Sprintf(" %s databricks plugin %s", it.agent.DisplayName, versionToken(rec.Version))) } if len(records) > 0 { if err := recordPluginInstallsFn(ctx, opts.Scope, records, ref); err != nil { diff --git a/cmd/aitools/install_test.go b/cmd/aitools/install_test.go index 0a19a8c8bcd..0637a761086 100644 --- a/cmd/aitools/install_test.go +++ b/cmd/aitools/install_test.go @@ -155,6 +155,26 @@ func TestBuildPlanProjectScopeSkipsUserOnlyAgent(t *testing.T) { assert.Contains(t, plan[1].reason, "user-only") } +func TestBuildPlanProjectScopeSkipsFilesOnlyAgent(t *testing.T) { + // A files-only agent that does not support project scope is skipped up front, + // so the picker never offers an option that fails at install time. + opencode := &agents.Agent{Name: agents.NameOpenCode, DisplayName: "OpenCode", Binary: "opencode"} + projectSkills := &agents.Agent{Name: "proj-skills", DisplayName: "Proj", Binary: "proj", SupportsProjectScope: true} + + plan := buildPlan([]*agents.Agent{opencode, projectSkills}, installer.ScopeProject, false, false) + assert.Equal(t, deliverySkip, plan[0].delivery) + assert.Contains(t, plan[0].reason, "project-scoped skills") + assert.Equal(t, deliverySkills, plan[1].delivery) + + // --skills-only does not rescue a project-incompatible agent. + skillsOnly := buildPlan([]*agents.Agent{opencode}, installer.ScopeProject, true, false) + assert.Equal(t, deliverySkip, skillsOnly[0].delivery) + + // Under global scope the same agent gets skills. + globalPlan := buildPlan([]*agents.Agent{opencode}, installer.ScopeGlobal, false, false) + assert.Equal(t, deliverySkills, globalPlan[0].delivery) +} + func TestMapAgentScope(t *testing.T) { claude := testPluginAgent(agents.NameClaudeCode, "Claude Code", "claude", false) codex := testPluginAgent(agents.NameCodex, "Codex CLI", "codex", false) diff --git a/cmd/aitools/list.go b/cmd/aitools/list.go index ca720834549..6d3e8d4dce5 100644 --- a/cmd/aitools/list.go +++ b/cmd/aitools/list.go @@ -30,7 +30,7 @@ func NewListCmd() *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List installed AI tools components", + Short: "List installed skills and plugins", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { // Reject the legacy --project --global combination here so it @@ -151,7 +151,7 @@ func buildListOutput(ctx context.Context, scope string) (listOutput, error) { names := slices.Sorted(maps.Keys(manifest.Skills)) out := listOutput{ - Release: strings.TrimPrefix(ref, "v"), + Release: installer.DisplaySkillsVersion(ref), Skills: make([]skillEntry, 0, len(names)), Summary: map[string]scopeSummary{}, } @@ -262,25 +262,30 @@ func renderListJSON(w io.Writer, out listOutput) error { } func renderListText(ctx context.Context, out listOutput, scope string) { - cmdio.LogString(ctx, "Available skills (v"+out.Release+"):") - cmdio.LogString(ctx, "") - bothScopes := scope == "" && out.Summary[installer.ScopeGlobal].loaded && out.Summary[installer.ScopeProject].loaded - var buf strings.Builder - tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) - fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED") + // Split experimental skills into their own group so they are clearly + // separated from the stable set rather than interleaved alphabetically. + var stable, experimental []skillEntry for _, s := range out.Skills { - tag := "" if s.Experimental { - tag = " [experimental]" + experimental = append(experimental, s) + } else { + stable = append(stable, s) } - fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", s.Name, tag, s.LatestVersion, installedStatusFromEntry(s, bothScopes)) } - tw.Flush() - cmdio.LogString(ctx, buf.String()) + + cmdio.LogString(ctx, "Available skills ("+versionToken(out.Release)+"):") + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, renderSkillTable(stable, bothScopes)) + + if len(experimental) > 0 { + cmdio.LogString(ctx, "Experimental skills:") + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, renderSkillTable(experimental, bothScopes)) + } cmdio.LogString(ctx, summaryLine(out, scope)) @@ -297,6 +302,18 @@ func renderListText(ctx context.Context, out listOutput, scope string) { } } +// renderSkillTable formats a NAME/VERSION/INSTALLED table for a group of skills. +func renderSkillTable(skills []skillEntry, bothScopes bool) string { + var buf strings.Builder + tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED") + for _, s := range skills { + fmt.Fprintf(tw, " %s\tv%s\t%s\n", s.Name, s.LatestVersion, installedStatusFromEntry(s, bothScopes)) + } + tw.Flush() + return buf.String() +} + // agentStatusLabel renders the text-view status for an agent, collapsing the // per-scope plugin records into a single line. A stale scope (version != // release) is surfaced over an up-to-date one so an outdated install is never @@ -322,9 +339,9 @@ func agentStatusLabel(a agentEntry, release string) string { } if upToDate { - return "plugin · v" + version + " · up to date" + return "plugin · " + versionToken(version) + " · up to date" } - return "plugin · v" + version + " · update available" + return "plugin · " + versionToken(version) + " · update available" } func installedStatusFromEntry(s skillEntry, bothScopes bool) string { diff --git a/cmd/aitools/list_test.go b/cmd/aitools/list_test.go index 6a336be4050..0ec52a3b65d 100644 --- a/cmd/aitools/list_test.go +++ b/cmd/aitools/list_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/databricks/cli/libs/aitools/installer" @@ -368,6 +369,34 @@ func TestRenderListTextUsesLoadedStateForScopeLabels(t *testing.T) { assert.Contains(t, got, "1/1 skills installed (global), 0/1 (project)") } +func TestRenderListTextGroupsExperimental(t *testing.T) { + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + out := listOutput{ + Release: "latest", + Skills: []skillEntry{ + {Name: "databricks-jobs", LatestVersion: "1.0.0", Installed: map[string]string{}}, + {Name: "experimental-thing", LatestVersion: "0.1.0", Experimental: true, Installed: map[string]string{}}, + }, + Summary: map[string]scopeSummary{ + installer.ScopeGlobal: {Installed: 0, Total: 2, loaded: true}, + }, + } + + renderListText(ctx, out, installer.ScopeGlobal) + + got := stderr.String() + availIdx := strings.Index(got, "Available skills") + expIdx := strings.Index(got, "Experimental skills:") + require.GreaterOrEqual(t, availIdx, 0, "available group header present") + require.GreaterOrEqual(t, expIdx, 0, "experimental group header present") + assert.Less(t, availIdx, expIdx, "available group comes before experimental group") + // Stable skill sits in the first group; experimental skill sits under its own header. + assert.Less(t, strings.Index(got, "databricks-jobs"), expIdx) + assert.Less(t, expIdx, strings.Index(got, "experimental-thing")) + // No inline tag now that they are grouped. + assert.NotContains(t, got, "[experimental]") +} + func TestListScopeFlag(t *testing.T) { tests := []struct { name string diff --git a/cmd/aitools/uninstall.go b/cmd/aitools/uninstall.go index fef9fa6a197..ab7ce427af4 100644 --- a/cmd/aitools/uninstall.go +++ b/cmd/aitools/uninstall.go @@ -18,8 +18,8 @@ func NewUninstallCmd() *cobra.Command { cmd := &cobra.Command{ Use: "uninstall", - Short: "Uninstall AI skills", - Long: `Remove installed Databricks AI skills from all coding agents. + Short: "Uninstall Databricks skills and plugins", + Long: `Remove installed Databricks skills and plugins from all coding agents. By default, removes all skills. Use --skills to remove specific skills only.`, Args: cobra.NoArgs, diff --git a/cmd/aitools/update.go b/cmd/aitools/update.go index 37225a0a952..a0d3be28965 100644 --- a/cmd/aitools/update.go +++ b/cmd/aitools/update.go @@ -24,8 +24,8 @@ func NewUpdateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "update", - Short: "Update installed AI skills", - Long: `Update installed Databricks AI skills to the latest release. + Short: "Update installed skills and plugins", + Long: `Update installed Databricks skills and plugins to the latest release. By default, updates all installed skills and auto-installs new skills from the manifest. Use --no-new to skip new skills, or --check to @@ -82,13 +82,13 @@ preview what would change without downloading.`, return err } for _, pu := range pluginUpdates { - cmdio.LogString(ctx, fmt.Sprintf(" %s databricks plugin v%s", pu.Agent, pu.Version)) + cmdio.LogString(ctx, fmt.Sprintf(" %s databricks plugin %s", pu.Agent, versionToken(pu.Version))) } } // Reconcile file skills for non-plugin agents. Skip entirely for a // pure-plugin install (no file skills to maintain); keep calling for - // no-state installs so the "no skills installed" guidance still fires. + // no-state installs so the "no skills or plugins installed" guidance still fires. if state == nil || len(state.Skills) > 0 { opts := installer.UpdateOptions{ Check: check, diff --git a/cmd/aitools/version.go b/cmd/aitools/version.go index 90c95ea7643..73d98ab0aa4 100644 --- a/cmd/aitools/version.go +++ b/cmd/aitools/version.go @@ -5,7 +5,6 @@ import ( "fmt" "maps" "slices" - "strings" "github.com/databricks/cli/libs/aitools/agents" "github.com/databricks/cli/libs/aitools/installer" @@ -17,7 +16,7 @@ import ( func NewVersionCmd() *cobra.Command { cmd := &cobra.Command{ Use: "version", - Short: "Show installed AI skills version", + Short: "Show installed skills and plugins version", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -42,7 +41,7 @@ func NewVersionCmd() *cobra.Command { } if globalState == nil && projectState == nil { - cmdio.LogString(ctx, "No Databricks AI Tools components installed.") + cmdio.LogString(ctx, "No Databricks skills or plugins installed.") cmdio.LogString(ctx, "") cmdio.LogString(ctx, "Run 'databricks aitools install' to get started.") return nil @@ -54,7 +53,7 @@ func NewVersionCmd() *cobra.Command { } bothScopes := globalState != nil && projectState != nil - cmdio.LogString(ctx, "Databricks AI Tools:") + cmdio.LogString(ctx, "Databricks skills and plugins:") if globalState != nil { label := "Skills" @@ -89,10 +88,19 @@ func NewVersionCmd() *cobra.Command { func printPluginLines(ctx context.Context, state *installer.InstallState) { for _, name := range slices.Sorted(maps.Keys(state.Plugins)) { rec := state.Plugins[name] - cmdio.LogString(ctx, fmt.Sprintf(" Plugin (%s): v%s", agentDisplayName(name), rec.Version)) + cmdio.LogString(ctx, fmt.Sprintf(" Plugin (%s): %s", agentDisplayName(name), versionToken(rec.Version))) } } +// versionToken renders a skills/plugin version for output: the "latest" sentinel +// is shown as-is; a concrete version gets a leading "v". +func versionToken(v string) string { + if v == "latest" { + return "latest" + } + return "v" + v +} + // agentDisplayName returns the agent's display name, falling back to its // registry name when it isn't a known agent. func agentDisplayName(name string) string { @@ -104,25 +112,25 @@ func agentDisplayName(name string) string { // printVersionLine prints a single version line for a scope. func printVersionLine(ctx context.Context, label string, state *installer.InstallState, latestRef string) { - version := strings.TrimPrefix(state.Release, "v") + version := versionToken(installer.DisplaySkillsVersion(state.Release)) skillNoun := "skills" if len(state.Skills) == 1 { skillNoun = "skill" } if latestRef == "" { - cmdio.LogString(ctx, fmt.Sprintf(" %s: v%s (%d %s)", label, version, len(state.Skills), skillNoun)) + cmdio.LogString(ctx, fmt.Sprintf(" %s: %s (%d %s)", label, version, len(state.Skills), skillNoun)) cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02")) return } if latestRef == state.Release { - cmdio.LogString(ctx, fmt.Sprintf(" %s: v%s (%d %s, up to date)", label, version, len(state.Skills), skillNoun)) + cmdio.LogString(ctx, fmt.Sprintf(" %s: %s (%d %s, up to date)", label, version, len(state.Skills), skillNoun)) cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02")) } else { - latestVersion := strings.TrimPrefix(latestRef, "v") - cmdio.LogString(ctx, fmt.Sprintf(" %s: v%s (%d %s)", label, version, len(state.Skills), skillNoun)) - cmdio.LogString(ctx, " Update available: v"+latestVersion) + latestVersion := versionToken(installer.DisplaySkillsVersion(latestRef)) + cmdio.LogString(ctx, fmt.Sprintf(" %s: %s (%d %s)", label, version, len(state.Skills), skillNoun)) + cmdio.LogString(ctx, " Update available: "+latestVersion) cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02")) cmdio.LogString(ctx, "") cmdio.LogString(ctx, "Run 'databricks aitools update' to update.") diff --git a/internal/build/cli-compat.json b/internal/build/cli-compat.json index 5b7a161185e..d5645e25010 100644 --- a/internal/build/cli-compat.json +++ b/internal/build/cli-compat.json @@ -1,4 +1,4 @@ { - "1.0.0": { "appkit": "0.38.1", "skills": "0.2.5" }, + "1.0.0": { "appkit": "0.38.1", "skills": "latest" }, "0.299.2": { "appkit": "0.24.0", "skills": "0.1.5" } } diff --git a/libs/aitools/installer/installer.go b/libs/aitools/installer/installer.go index b2b38ff0a13..29806cc419f 100644 --- a/libs/aitools/installer/installer.go +++ b/libs/aitools/installer/installer.go @@ -76,18 +76,50 @@ func stateRepoDir(state *InstallState, name string) string { // It is a package-level var so tests can replace it with a mock. var fetchFileFn func(ctx context.Context, ref, repoDir, skillName, filePath string) ([]byte, error) = fetchSkillFile +// latestSkillsRef is the ref used when nothing pins a version. It tracks the +// skills repo's default branch, the same content plugin agents receive (their +// marketplace clones the default branch), so skills and plugin agents stay in +// sync by default. +const latestSkillsRef = "main" + +// skillsLatestSentinel is the value cli-compat.json uses for the skills version +// to mean "track latest" rather than pinning a specific release. +const skillsLatestSentinel = clicompat.AgentSkillsLatest + // GetSkillsRef returns the skills repo ref to use and whether it was explicitly -// set via DATABRICKS_SKILLS_REF (as opposed to auto-resolved from the manifest). -// Resolution order: DATABRICKS_SKILLS_REF env var → compatibility manifest → error. +// pinned (as opposed to tracking latest). +// +// By default we track the latest skills. cli-compat.json is the remote control +// for this: it normally reports "latest" (so skills match what plugin agents +// install), but if a future change makes a skills release incompatible with +// older CLIs, it can be edited remotely to pin those CLIs to the last compatible +// version with no CLI release. DATABRICKS_SKILLS_REF overrides everything for +// eval/reproducibility. (AppKit version resolution is unaffected; cli-compat +// still manages it via its own field.) func GetSkillsRef(ctx context.Context) (ref string, explicit bool, err error) { if ref := env.Get(ctx, "DATABRICKS_SKILLS_REF"); ref != "" { return ref, true, nil } v, err := clicompat.ResolveAgentSkillsVersion(ctx) if err != nil { - return "", false, fmt.Errorf("could not resolve skills version: %w", err) + // If compatibility can't be resolved (offline, parse error), track latest + // rather than failing the install. + log.Debugf(ctx, "could not resolve skills compatibility, tracking latest: %v", err) + return latestSkillsRef, false, nil + } + if v == "" || v == skillsLatestSentinel { + return latestSkillsRef, false, nil + } + return "v" + v, true, nil +} + +// DisplaySkillsVersion renders a skills ref for humans: the latest sentinel +// becomes "latest"; a pinned tag drops its leading "v". +func DisplaySkillsVersion(ref string) string { + if ref == latestSkillsRef { + return "latest" } - return "v" + v, false, nil + return strings.TrimPrefix(ref, "v") } // GetSkillsBaseURL returns the manifest and skill fetch base URL. @@ -167,7 +199,7 @@ func FetchSkillsManifestWithFallback(ctx context.Context, src ManifestSource, re manifest, err := src.FetchManifest(ctx, ref) if err != nil && allowFallback && clicompat.IsNotFoundError(err) { fallbackVersion, fbErr := clicompat.ResolveEmbeddedAgentSkillsVersion() - if fbErr == nil && fallbackVersion != "" && fallbackVersion != tag { + if fbErr == nil && fallbackVersion != "" && fallbackVersion != skillsLatestSentinel && fallbackVersion != tag { log.Warnf(ctx, "Skills version %s not found, falling back to embedded version %s", tag, fallbackVersion) ref = "v" + fallbackVersion manifest, err = src.FetchManifest(ctx, ref) @@ -186,7 +218,7 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent if err != nil { return err } - cmdio.LogString(ctx, "Using skills version "+strings.TrimPrefix(ref, "v")) + cmdio.LogString(ctx, "Using skills version "+DisplaySkillsVersion(ref)) manifest, ref, err := FetchSkillsManifestWithFallback(ctx, src, ref, !explicit) if err != nil { return err @@ -422,7 +454,7 @@ func PrintInstallingFor(ctx context.Context, targetAgents []*agents.Agent) { for i, a := range targetAgents { names[i] = a.DisplayName } - cmdio.LogString(ctx, fmt.Sprintf("Installing Databricks AI skills for %s...", strings.Join(names, ", "))) + cmdio.LogString(ctx, fmt.Sprintf("Installing Databricks skills for %s...", strings.Join(names, ", "))) } func printNoAgentsDetected(ctx context.Context) { diff --git a/libs/aitools/installer/installer_test.go b/libs/aitools/installer/installer_test.go index ebc0b591b79..1b6847b9abb 100644 --- a/libs/aitools/installer/installer_test.go +++ b/libs/aitools/installer/installer_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io/fs" "log/slog" "os" @@ -941,19 +942,52 @@ func TestSupportsProjectScopeSetCorrectly(t *testing.T) { } } -func TestGetSkillsRefResolvesFromManifest(t *testing.T) { - // Pre-populate the cache so FetchManifest returns from tier 1 (local cache) - // without hitting the network. The embedded manifest fallback is tested - // separately in clicompat_test.go. - cacheDir := t.TempDir() - t.Setenv("DATABRICKS_CACHE_DIR", cacheDir) - cachePath := filepath.Join(cacheDir, "compat-manifest.json") - manifest := `{"next":{"appkit":"0.24.0","skills":"0.1.5"},"0.300.0":{"appkit":"0.24.0","skills":"0.1.5"}}` - require.NoError(t, os.WriteFile(cachePath, []byte(manifest), 0o644)) +func TestGetSkillsRefDefaultsToLatest(t *testing.T) { + // cli-compat reports "latest", so we track the repo's default branch (the + // same content plugin agents install). + t.Setenv("DATABRICKS_SKILLS_REF", "") + withCachedCompat(t, skillsLatestSentinel) + + ref, explicit, err := GetSkillsRef(t.Context()) + require.NoError(t, err) + assert.Equal(t, latestSkillsRef, ref) + assert.False(t, explicit, "tracking latest is not an explicit pin") +} + +func TestGetSkillsRefEnvEscapeHatch(t *testing.T) { + t.Setenv("DATABRICKS_SKILLS_REF", "v9.9.9") + + ref, explicit, err := GetSkillsRef(t.Context()) + require.NoError(t, err) + assert.Equal(t, "v9.9.9", ref) + assert.True(t, explicit, "an explicit ref is a pin") +} + +func TestGetSkillsRefPinnedByCliCompat(t *testing.T) { + // cli-compat reports a concrete version: the remote safety valve pinning this + // CLI (e.g. after a future breaking change). + t.Setenv("DATABRICKS_SKILLS_REF", "") + withCachedCompat(t, "0.1.5") ref, explicit, err := GetSkillsRef(t.Context()) require.NoError(t, err, "GetSkillsRef should succeed via cached manifest") - assert.False(t, explicit, "ref resolved from manifest should not be explicit") - assert.NotEmpty(t, ref) - assert.True(t, len(ref) > 1 && ref[0] == 'v', "ref should start with 'v', got %q", ref) + assert.Equal(t, "v0.1.5", ref) + assert.True(t, explicit, "a cli-compat pin is explicit") +} + +func TestDisplaySkillsVersion(t *testing.T) { + assert.Equal(t, "latest", DisplaySkillsVersion(latestSkillsRef)) + assert.Equal(t, "0.2.5", DisplaySkillsVersion("v0.2.5")) + assert.Equal(t, "0.2.5", DisplaySkillsVersion("0.2.5")) +} + +// withCachedCompat pre-populates the cli-compat cache so resolution is offline +// and deterministic (a fresh local cache is tier 1, ahead of the network). A +// single "0.0.0" entry matches every CLI version, including dev test builds. +func withCachedCompat(t *testing.T, skills string) { + t.Helper() + cacheDir := t.TempDir() + t.Setenv("DATABRICKS_CACHE_DIR", cacheDir) + manifest := fmt.Sprintf(`{"0.0.0":{"appkit":"0.24.0","skills":%q}}`, skills) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "compat-manifest.json"), []byte(manifest), 0o644)) } diff --git a/libs/aitools/installer/plugin.go b/libs/aitools/installer/plugin.go index 79fb4d47ac1..62e7d557c69 100644 --- a/libs/aitools/installer/plugin.go +++ b/libs/aitools/installer/plugin.go @@ -213,7 +213,7 @@ func InstallPluginForAgent(ctx context.Context, agent *agents.Agent, nativeScope Marketplace: agent.Plugin.Marketplace, Plugin: agent.Plugin.ID, Scope: nativeScope, - Version: strings.TrimPrefix(ref, "v"), + Version: DisplaySkillsVersion(ref), InstalledMarketplace: installedMarketplace, }, nil } diff --git a/libs/aitools/installer/uninstall.go b/libs/aitools/installer/uninstall.go index 7811bb7bfd9..27973730461 100644 --- a/libs/aitools/installer/uninstall.go +++ b/libs/aitools/installer/uninstall.go @@ -59,7 +59,7 @@ func UninstallSkillsOpts(ctx context.Context, opts UninstallOptions) error { if scope == ScopeGlobal && hasLegacyInstall(ctx, baseDir) { return errors.New("found skills from a previous install without state tracking; run 'databricks aitools install' first, then uninstall") } - return errors.New("no skills installed") + return errors.New("no skills or plugins installed") } // A full uninstall (no --skills filter) also tears down plugins. diff --git a/libs/aitools/installer/uninstall_test.go b/libs/aitools/installer/uninstall_test.go index 3dc8e03af35..7d483fdb4be 100644 --- a/libs/aitools/installer/uninstall_test.go +++ b/libs/aitools/installer/uninstall_test.go @@ -139,7 +139,7 @@ func TestUninstallNoStateReturnsError(t *testing.T) { err := UninstallSkills(ctx) require.Error(t, err) - assert.Contains(t, err.Error(), "no skills installed") + assert.Contains(t, err.Error(), "no skills or plugins installed") } func TestUninstallHandlesMissingDirectories(t *testing.T) { diff --git a/libs/aitools/installer/update.go b/libs/aitools/installer/update.go index 38b5bf9dfd6..0d27bb1dd4b 100644 --- a/libs/aitools/installer/update.go +++ b/libs/aitools/installer/update.go @@ -83,7 +83,7 @@ func UpdateSkills(ctx context.Context, src ManifestSource, targetAgents []*agent if scope == ScopeGlobal && hasLegacyInstall(ctx, baseDir) { return nil, errors.New("found skills from a previous install without state tracking; run 'databricks aitools install' to refresh before updating") } - return nil, errors.New("no skills installed. Run 'databricks aitools install' to install") + return nil, errors.New("no skills or plugins installed. Run 'databricks aitools install' to install") } latestTag, explicit, err := GetSkillsRef(ctx) @@ -91,7 +91,10 @@ func UpdateSkills(ctx context.Context, src ManifestSource, targetAgents []*agent return nil, err } - if state.Release == latestTag && !opts.Force { + // Short-circuit only when pinned to an exact ref. When tracking latest (the + // default), the ref ("main") never changes, so always reconcile against the + // freshly fetched manifest instead of falsely reporting "already up to date". + if explicit && state.Release == latestTag && !opts.Force { cmdio.LogString(ctx, "Already up to date.") return &UpdateResult{Unchanged: slices.Sorted(maps.Keys(state.Skills))}, nil } @@ -433,7 +436,7 @@ func UpdateInstalledPlugins(ctx context.Context, scope, ref string) ([]PluginUpd return nil, nil } - version := strings.TrimPrefix(ref, "v") + version := DisplaySkillsVersion(ref) var updated []PluginUpdate for _, name := range slices.Sorted(maps.Keys(state.Plugins)) { agent := agents.ByName(name) diff --git a/libs/aitools/installer/update_test.go b/libs/aitools/installer/update_test.go index fa867b5f26d..53acc9aa85a 100644 --- a/libs/aitools/installer/update_test.go +++ b/libs/aitools/installer/update_test.go @@ -24,7 +24,7 @@ func TestUpdateNoStateReturnsInstallHint(t *testing.T) { src := &mockManifestSource{manifest: testManifest()} _, err := UpdateSkills(ctx, src, nil, UpdateOptions{}) require.Error(t, err) - assert.Contains(t, err.Error(), "no skills installed") + assert.Contains(t, err.Error(), "no skills or plugins installed") assert.Contains(t, err.Error(), "databricks aitools install") } diff --git a/libs/clicompat/clicompat.go b/libs/clicompat/clicompat.go index be9c27db4b5..2a8a60d0c91 100644 --- a/libs/clicompat/clicompat.go +++ b/libs/clicompat/clicompat.go @@ -417,6 +417,11 @@ func fetchRemote(ctx context.Context) (Manifest, error) { return parseManifest(body) } +// AgentSkillsLatest is the sentinel value the skills field may take instead of a +// concrete version, meaning "track the latest skills" rather than pinning a +// release. The aitools installer maps it to the skills repo's default branch. +const AgentSkillsLatest = "latest" + func parseManifest(data []byte) (Manifest, error) { var m Manifest if err := json.Unmarshal(data, &m); err != nil { @@ -440,7 +445,7 @@ func parseManifest(data []byte) (Manifest, error) { if !semver.IsValid("v" + entry.AppKit) { return nil, fmt.Errorf("manifest entry %q has invalid appkit version %q", k, entry.AppKit) } - if !semver.IsValid("v" + entry.AgentSkills) { + if entry.AgentSkills != AgentSkillsLatest && !semver.IsValid("v"+entry.AgentSkills) { return nil, fmt.Errorf("manifest entry %q has invalid skills version %q", k, entry.AgentSkills) } }