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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@
>>> [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.

=== install a specific experimental 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.

=== asking for an experimental skill without --experimental flag errors out
>>> [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

Expand Down
6 changes: 3 additions & 3 deletions acceptance/experimental/aitools/skills/install/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@
>>> [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.

=== re-run with --experimental installs the experimental one too
>>> [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.

=== no-op re-run is idempotent (no new fetches, no errors)
>>> [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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion acceptance/help/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cmd/aitools/aitools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 56 additions & 26 deletions cmd/aitools/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions cmd/aitools/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 32 additions & 15 deletions cmd/aitools/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{},
}
Expand Down Expand Up @@ -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))

Expand All @@ -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
Expand All @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions cmd/aitools/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/databricks/cli/libs/aitools/installer"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cmd/aitools/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading