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 @@ -15,6 +15,8 @@ Installed 2 skills.
=== update against a release where beta is gone: alpha updates, beta is pruned
>>> DATABRICKS_SKILLS_REF=v2-ref [CLI] experimental aitools update --scope global
Command "update" is deprecated, use "databricks aitools update" instead.
Installing databricks plugin for Claude Code...
Skipped Claude Code: claude-code: install-failed: ✘ Failed to install plugin "databricks@claude-plugins-official": Plugin "databricks" not found in marketplace "claude-plugins-official". Your local copy may be out of date — try `claude plugin marketplace update claude-plugins-official`.
Downloading alpha...
Exposing alpha to 1 agent...
updated alpha v1.0.0 -> v2.0.0
Expand Down
6 changes: 3 additions & 3 deletions cmd/aitools/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,15 @@ func TestAgentChoicesOnlyOffersActionableAgents(t *testing.T) {
fakeBinsOnPath(t, "claude")
ctx := cmdio.MockDiscard(t.Context())

// Project scope: only Claude (plugin) and Cursor (skills) support it; the
// user-only plugin agents and files-only agents are not offered as choices.
// Project scope: only Claude (plugin) supports it; the user-only plugin
// agents and files-only agents are not offered as choices.
choices := agentChoices(ctx, installer.ScopeProject, false)
var names []string
for _, c := range choices {
names = append(names, c.agent.Name)
}
assert.Contains(t, names, agents.NameClaudeCode)
assert.Contains(t, names, agents.NameCursor)
assert.NotContains(t, names, agents.NameCursor)
assert.NotContains(t, names, agents.NameCodex)
assert.NotContains(t, names, agents.NameOpenCode)
assert.NotContains(t, names, agents.NameCopilot)
Expand Down
47 changes: 25 additions & 22 deletions cmd/aitools/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ type agentEntry struct {

// pluginInfo is the per-scope plugin record surfaced in list output.
type pluginInfo struct {
Version string `json:"version,omitempty"`
Version string `json:"version,omitempty"`
NativeScope string `json:"native_scope,omitempty"`
}

type skillEntry struct {
Expand Down Expand Up @@ -213,7 +214,7 @@ func buildAgentEntries(states map[string]*installer.InstallState) []agentEntry {
installed := map[string]pluginInfo{}
for scope, st := range states {
if rec, ok := st.Plugins[a.Name]; ok {
installed[scope] = pluginInfo{Version: rec.Version}
installed[scope] = pluginInfo{Version: rec.Version, NativeScope: rec.Scope}
}
}
if len(installed) > 0 {
Expand Down Expand Up @@ -265,29 +266,31 @@ func renderListText(ctx context.Context, out listOutput, scope 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))

if len(out.Agents) > 0 {
cmdio.LogString(ctx, "Plugin installs:")
cmdio.LogString(ctx, "")
var ab strings.Builder
atw := tabwriter.NewWriter(&ab, 0, 4, 2, ' ', 0)
fmt.Fprintln(atw, " AGENT\tSTATUS")
for _, a := range out.Agents {
fmt.Fprintf(atw, " %s\t%s\n", a.Name, agentStatusLabel(a, out.Release))
fmt.Fprintf(atw, " %s\t%s\n", agentDisplayName(a.Name), agentStatusLabel(a, out.Release))
}
atw.Flush()
cmdio.LogString(ctx, ab.String())
cmdio.LogString(ctx, "")
}

cmdio.LogString(ctx, "Available raw skill directories ("+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))
}

// renderSkillTable formats a NAME/VERSION/INSTALLED table for a group of skills.
Expand Down Expand Up @@ -323,9 +326,9 @@ func agentStatusLabel(a agentEntry, release string) string {
}

if upToDate {
return "plugin · " + versionToken(version) + " · up to date"
return "databricks plugin · " + versionToken(version) + " · up to date"
}
return "plugin · " + versionToken(version) + " · update available"
return "databricks plugin · " + versionToken(version) + " · update available"
}

func installedStatusFromEntry(s skillEntry, bothScopes bool) string {
Expand Down Expand Up @@ -372,15 +375,15 @@ func summaryLine(out listOutput, scope string) string {
// Mirror prior behavior: only print the dual-scope line when both
// scopes have a state file; otherwise only mention the one that does.
if g.loaded && p.loaded {
return fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", g.Installed, g.Total, p.Installed, p.Total)
return fmt.Sprintf("%d/%d raw skill directories installed (global), %d/%d (project)", g.Installed, g.Total, p.Installed, p.Total)
}
if p.loaded {
return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total)
return fmt.Sprintf("%d/%d raw skill directories installed (project)", p.Installed, p.Total)
}
return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total)
return fmt.Sprintf("%d/%d raw skill directories installed (global)", g.Installed, g.Total)
case pOK:
return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total)
return fmt.Sprintf("%d/%d raw skill directories installed (project)", p.Installed, p.Total)
default:
return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total)
return fmt.Sprintf("%d/%d raw skill directories installed (global)", g.Installed, g.Total)
}
}
48 changes: 40 additions & 8 deletions cmd/aitools/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,12 @@ func TestBuildAgentEntries(t *testing.T) {
require.Contains(t, byName, "claude-code")
assert.True(t, byName["claude-code"].Managed)
assert.Equal(t, "0.2.6", byName["claude-code"].Installed[installer.ScopeGlobal].Version)
assert.Equal(t, "plugin · v0.2.6 · up to date", agentStatusLabel(byName["claude-code"], "0.2.6"))
assert.Equal(t, "databricks plugin · v0.2.6 · up to date", agentStatusLabel(byName["claude-code"], "0.2.6"))

require.Contains(t, byName, "codex")
assert.True(t, byName["codex"].Managed)
assert.Equal(t, "0.2.5", byName["codex"].Installed[installer.ScopeGlobal].Version)
assert.Equal(t, "plugin · v0.2.5 · update available", agentStatusLabel(byName["codex"], "0.2.6"))
assert.Equal(t, "databricks plugin · v0.2.5 · update available", agentStatusLabel(byName["codex"], "0.2.6"))

// Cursor has no plugin, so it never appears as a plugin agent entry.
assert.NotContains(t, byName, "cursor")
Expand Down Expand Up @@ -194,7 +194,7 @@ func TestBuildAgentEntriesRecordsPerScopeVersions(t *testing.T) {

// The renderer collapses the scopes and surfaces the stale one, rather than
// hiding it behind the up-to-date scope.
assert.Equal(t, "plugin · v0.2.5 · update available", agentStatusLabel(cc, "0.2.6"))
assert.Equal(t, "databricks plugin · v0.2.5 · update available", agentStatusLabel(cc, "0.2.6"))
}

func TestRenderListJSONScopeFiltersSummary(t *testing.T) {
Expand Down Expand Up @@ -282,7 +282,7 @@ func TestSummaryLinePreservesStatePresence(t *testing.T) {
installer.ScopeProject: {Installed: 0, Total: 1, loaded: true},
},
},
want: "0/1 skills installed (global), 0/1 (project)",
want: "0/1 raw skill directories installed (global), 0/1 (project)",
},
{
name: "only project state loaded",
Expand All @@ -295,7 +295,7 @@ func TestSummaryLinePreservesStatePresence(t *testing.T) {
installer.ScopeProject: {Installed: 0, Total: 1, loaded: true},
},
},
want: "0/1 skills installed (project)",
want: "0/1 raw skill directories installed (project)",
},
{
name: "only global state loaded",
Expand All @@ -308,7 +308,7 @@ func TestSummaryLinePreservesStatePresence(t *testing.T) {
installer.ScopeProject: {Installed: 0, Total: 1},
},
},
want: "0/1 skills installed (global)",
want: "0/1 raw skill directories installed (global)",
},
}

Expand Down Expand Up @@ -342,7 +342,7 @@ func TestRenderListTextUsesLoadedStateForScopeLabels(t *testing.T) {

got := stderr.String()
assert.Contains(t, got, "v1.0.0 (up to date) (global)")
assert.Contains(t, got, "1/1 skills installed (global), 0/1 (project)")
assert.Contains(t, got, "1/1 raw skill directories installed (global), 0/1 (project)")
}

func TestRenderListTextGroupsExperimental(t *testing.T) {
Expand All @@ -361,7 +361,7 @@ func TestRenderListTextGroupsExperimental(t *testing.T) {
renderListText(ctx, out, installer.ScopeGlobal)

got := stderr.String()
availIdx := strings.Index(got, "Available skills")
availIdx := strings.Index(got, "Available raw skill directories")
expIdx := strings.Index(got, "Experimental skills:")
require.GreaterOrEqual(t, availIdx, 0, "available group header present")
require.GreaterOrEqual(t, expIdx, 0, "experimental group header present")
Expand All @@ -373,6 +373,38 @@ func TestRenderListTextGroupsExperimental(t *testing.T) {
assert.NotContains(t, got, "[experimental]")
}

func TestRenderListTextShowsPluginInstallsBeforeRawSkills(t *testing.T) {
ctx, stderr := cmdio.NewTestContextWithStderr(t.Context())
out := listOutput{
Release: "0.2.6",
Skills: []skillEntry{
{Name: "databricks-jobs", LatestVersion: "1.0.0", Installed: map[string]string{}},
},
Summary: map[string]scopeSummary{
installer.ScopeGlobal: {Installed: 0, Total: 1, loaded: true},
},
Agents: []agentEntry{
{
Name: "claude-code",
Managed: true,
Installed: map[string]pluginInfo{installer.ScopeGlobal: {Version: "0.2.6", NativeScope: "user"}},
},
},
}

renderListText(ctx, out, installer.ScopeGlobal)

got := stderr.String()
pluginIdx := strings.Index(got, "Plugin installs:")
rawIdx := strings.Index(got, "Available raw skill directories")
require.GreaterOrEqual(t, pluginIdx, 0)
require.GreaterOrEqual(t, rawIdx, 0)
assert.Less(t, pluginIdx, rawIdx)
assert.Contains(t, got, "Claude Code")
assert.Contains(t, got, "databricks plugin · v0.2.6 · up to date")
assert.Contains(t, got, "0/1 raw skill directories installed (global)")
}

func TestListScopeFlag(t *testing.T) {
tests := []struct {
name string
Expand Down
21 changes: 18 additions & 3 deletions cmd/aitools/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ var uninstallSkillsFn = func(ctx context.Context, opts installer.UninstallOption
}

func NewUninstallCmd() *cobra.Command {
var skillsFlag, scopeFlag string
var skillsFlag, agentsFlag, scopeFlag string
var projectFlag, globalFlag, keepMarketplace bool

cmd := &cobra.Command{
Expand Down Expand Up @@ -53,6 +53,16 @@ By default, removes all skills. Use --skills to remove specific skills only.`,
KeepMarketplace: keepMarketplace,
}
opts.Skills = splitAndTrim(skillsFlag)
if agentsFlag != "" {
targetAgents, err := resolveAgentNames(ctx, agentsFlag)
if err != nil {
return err
}
opts.Agents = make([]string, 0, len(targetAgents))
for _, agent := range targetAgents {
opts.Agents = append(opts.Agents, agent.Name)
}
}

// Uninstall is destructive, so confirm interactively before doing
// anything. Non-interactive runs (no TTY) proceed unprompted so
Expand All @@ -77,6 +87,7 @@ By default, removes all skills. Use --skills to remove specific skills only.`,
}

cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to uninstall (comma-separated)")
cmd.Flags().StringVar(&agentsFlag, "agents", "", "Agents to uninstall from (comma-separated, e.g. claude-code,cursor)")
cmd.Flags().BoolVar(&keepMarketplace, "keep-marketplace", false, "Keep the marketplace registration when removing a plugin")
cmd.Flags().StringVar(&scopeFlag, "scope", "", "Uninstall scope: project or global")
cmd.Flags().BoolVar(&projectFlag, "project", false, "Uninstall project-scoped skills")
Expand Down Expand Up @@ -108,8 +119,12 @@ func uninstallConfirmMessage(state *installer.InstallState, opts installer.Unins
if state == nil {
return "", false
}
target := "all agents"
if len(opts.Agents) > 0 {
target = strings.Join(opts.Agents, ", ")
}
if len(opts.Skills) > 0 {
return fmt.Sprintf("This will remove %s %s (%s scope).", plural(len(opts.Skills), "skill"), strings.Join(opts.Skills, ", "), opts.Scope), true
return fmt.Sprintf("This will remove %s %s from %s (%s scope).", plural(len(opts.Skills), "skill"), strings.Join(opts.Skills, ", "), target, opts.Scope), true
}
var parts []string
if n := len(state.Skills); n > 0 {
Expand All @@ -121,7 +136,7 @@ func uninstallConfirmMessage(state *installer.InstallState, opts installer.Unins
if len(parts) == 0 {
return "", false
}
return fmt.Sprintf("This will remove %s (%s scope).", strings.Join(parts, " and "), opts.Scope), true
return fmt.Sprintf("This will remove %s from %s (%s scope).", strings.Join(parts, " and "), target, opts.Scope), true
}

// plural returns noun for n == 1 and noun+"s" otherwise.
Expand Down
38 changes: 38 additions & 0 deletions cmd/aitools/uninstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,37 @@ func TestUninstallScopeFlag(t *testing.T) {
}
}

func TestUninstallAgentsFlag(t *testing.T) {
setupTestAgents(t)
calls := setupUninstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
cmd := NewUninstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--scope", "global", "--agents", "cursor,claude-code", "--skills", "databricks-sql"})

require.NoError(t, cmd.Execute())
require.Len(t, *calls, 1)
assert.Equal(t, []string{"cursor", "claude-code"}, (*calls)[0].Agents)
assert.Equal(t, []string{"databricks-sql"}, (*calls)[0].Skills)
}

func TestUninstallUnknownAgentErrors(t *testing.T) {
setupTestAgents(t)
setupUninstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
cmd := NewUninstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--scope", "global", "--agents", "invalid-agent"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true

err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown agent")
}

func TestUninstallConfirmMessage(t *testing.T) {
// Nothing recorded: no prompt (installer surfaces its own guidance).
_, ask := uninstallConfirmMessage(nil, installer.UninstallOptions{Scope: installer.ScopeGlobal})
Expand All @@ -90,5 +121,12 @@ func TestUninstallConfirmMessage(t *testing.T) {
msg2, ask2 := uninstallConfirmMessage(st, filtered)
require.True(t, ask2)
assert.Contains(t, msg2, "skill alpha")
assert.Contains(t, msg2, "from all agents")
assert.Contains(t, msg2, "(project scope)")

targeted := installer.UninstallOptions{Scope: installer.ScopeGlobal, Agents: []string{"cursor"}}
targeted.Skills = []string{"alpha"}
msg3, ask3 := uninstallConfirmMessage(st, targeted)
require.True(t, ask3)
assert.Contains(t, msg3, "from cursor")
}
Loading
Loading