Skip to content

Commit 6178c2b

Browse files
committed
Improve aitools command UX
1 parent 3f79ee7 commit 6178c2b

16 files changed

Lines changed: 835 additions & 66 deletions

cmd/aitools/install_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,15 @@ func TestAgentChoicesOnlyOffersActionableAgents(t *testing.T) {
147147
fakeBinsOnPath(t, "claude")
148148
ctx := cmdio.MockDiscard(t.Context())
149149

150-
// Project scope: only Claude (plugin) and Cursor (skills) support it; the
151-
// user-only plugin agents and files-only agents are not offered as choices.
150+
// Project scope: only Claude (plugin) supports it; the user-only plugin
151+
// agents and files-only agents are not offered as choices.
152152
choices := agentChoices(ctx, installer.ScopeProject, false)
153153
var names []string
154154
for _, c := range choices {
155155
names = append(names, c.agent.Name)
156156
}
157157
assert.Contains(t, names, agents.NameClaudeCode)
158-
assert.Contains(t, names, agents.NameCursor)
158+
assert.NotContains(t, names, agents.NameCursor)
159159
assert.NotContains(t, names, agents.NameCodex)
160160
assert.NotContains(t, names, agents.NameOpenCode)
161161
assert.NotContains(t, names, agents.NameCopilot)

cmd/aitools/list.go

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ type agentEntry struct {
8989

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

9596
type skillEntry struct {
@@ -213,7 +214,7 @@ func buildAgentEntries(states map[string]*installer.InstallState) []agentEntry {
213214
installed := map[string]pluginInfo{}
214215
for scope, st := range states {
215216
if rec, ok := st.Plugins[a.Name]; ok {
216-
installed[scope] = pluginInfo{Version: rec.Version}
217+
installed[scope] = pluginInfo{Version: rec.Version, NativeScope: rec.Scope}
217218
}
218219
}
219220
if len(installed) > 0 {
@@ -265,29 +266,31 @@ func renderListText(ctx context.Context, out listOutput, scope string) {
265266
}
266267
}
267268

268-
cmdio.LogString(ctx, "Available skills ("+versionToken(out.Release)+"):")
269-
cmdio.LogString(ctx, "")
270-
cmdio.LogString(ctx, renderSkillTable(stable, bothScopes))
271-
272-
if len(experimental) > 0 {
273-
cmdio.LogString(ctx, "Experimental skills:")
274-
cmdio.LogString(ctx, "")
275-
cmdio.LogString(ctx, renderSkillTable(experimental, bothScopes))
276-
}
277-
278-
cmdio.LogString(ctx, summaryLine(out, scope))
279-
280269
if len(out.Agents) > 0 {
270+
cmdio.LogString(ctx, "Plugin installs:")
281271
cmdio.LogString(ctx, "")
282272
var ab strings.Builder
283273
atw := tabwriter.NewWriter(&ab, 0, 4, 2, ' ', 0)
284274
fmt.Fprintln(atw, " AGENT\tSTATUS")
285275
for _, a := range out.Agents {
286-
fmt.Fprintf(atw, " %s\t%s\n", a.Name, agentStatusLabel(a, out.Release))
276+
fmt.Fprintf(atw, " %s\t%s\n", agentDisplayName(a.Name), agentStatusLabel(a, out.Release))
287277
}
288278
atw.Flush()
289279
cmdio.LogString(ctx, ab.String())
280+
cmdio.LogString(ctx, "")
290281
}
282+
283+
cmdio.LogString(ctx, "Available raw skill directories ("+versionToken(out.Release)+"):")
284+
cmdio.LogString(ctx, "")
285+
cmdio.LogString(ctx, renderSkillTable(stable, bothScopes))
286+
287+
if len(experimental) > 0 {
288+
cmdio.LogString(ctx, "Experimental skills:")
289+
cmdio.LogString(ctx, "")
290+
cmdio.LogString(ctx, renderSkillTable(experimental, bothScopes))
291+
}
292+
293+
cmdio.LogString(ctx, summaryLine(out, scope))
291294
}
292295

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

325328
if upToDate {
326-
return "plugin · " + versionToken(version) + " · up to date"
329+
return "databricks plugin · " + versionToken(version) + " · up to date"
327330
}
328-
return "plugin · " + versionToken(version) + " · update available"
331+
return "databricks plugin · " + versionToken(version) + " · update available"
329332
}
330333

331334
func installedStatusFromEntry(s skillEntry, bothScopes bool) string {
@@ -372,15 +375,15 @@ func summaryLine(out listOutput, scope string) string {
372375
// Mirror prior behavior: only print the dual-scope line when both
373376
// scopes have a state file; otherwise only mention the one that does.
374377
if g.loaded && p.loaded {
375-
return fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", g.Installed, g.Total, p.Installed, p.Total)
378+
return fmt.Sprintf("%d/%d raw skill directories installed (global), %d/%d (project)", g.Installed, g.Total, p.Installed, p.Total)
376379
}
377380
if p.loaded {
378-
return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total)
381+
return fmt.Sprintf("%d/%d raw skill directories installed (project)", p.Installed, p.Total)
379382
}
380-
return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total)
383+
return fmt.Sprintf("%d/%d raw skill directories installed (global)", g.Installed, g.Total)
381384
case pOK:
382-
return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total)
385+
return fmt.Sprintf("%d/%d raw skill directories installed (project)", p.Installed, p.Total)
383386
default:
384-
return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total)
387+
return fmt.Sprintf("%d/%d raw skill directories installed (global)", g.Installed, g.Total)
385388
}
386389
}

cmd/aitools/list_test.go

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,12 +156,12 @@ func TestBuildAgentEntries(t *testing.T) {
156156
require.Contains(t, byName, "claude-code")
157157
assert.True(t, byName["claude-code"].Managed)
158158
assert.Equal(t, "0.2.6", byName["claude-code"].Installed[installer.ScopeGlobal].Version)
159-
assert.Equal(t, "plugin · v0.2.6 · up to date", agentStatusLabel(byName["claude-code"], "0.2.6"))
159+
assert.Equal(t, "databricks plugin · v0.2.6 · up to date", agentStatusLabel(byName["claude-code"], "0.2.6"))
160160

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

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

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

200200
func TestRenderListJSONScopeFiltersSummary(t *testing.T) {
@@ -282,7 +282,7 @@ func TestSummaryLinePreservesStatePresence(t *testing.T) {
282282
installer.ScopeProject: {Installed: 0, Total: 1, loaded: true},
283283
},
284284
},
285-
want: "0/1 skills installed (global), 0/1 (project)",
285+
want: "0/1 raw skill directories installed (global), 0/1 (project)",
286286
},
287287
{
288288
name: "only project state loaded",
@@ -295,7 +295,7 @@ func TestSummaryLinePreservesStatePresence(t *testing.T) {
295295
installer.ScopeProject: {Installed: 0, Total: 1, loaded: true},
296296
},
297297
},
298-
want: "0/1 skills installed (project)",
298+
want: "0/1 raw skill directories installed (project)",
299299
},
300300
{
301301
name: "only global state loaded",
@@ -308,7 +308,7 @@ func TestSummaryLinePreservesStatePresence(t *testing.T) {
308308
installer.ScopeProject: {Installed: 0, Total: 1},
309309
},
310310
},
311-
want: "0/1 skills installed (global)",
311+
want: "0/1 raw skill directories installed (global)",
312312
},
313313
}
314314

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

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

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

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

376+
func TestRenderListTextShowsPluginInstallsBeforeRawSkills(t *testing.T) {
377+
ctx, stderr := cmdio.NewTestContextWithStderr(t.Context())
378+
out := listOutput{
379+
Release: "0.2.6",
380+
Skills: []skillEntry{
381+
{Name: "databricks-jobs", LatestVersion: "1.0.0", Installed: map[string]string{}},
382+
},
383+
Summary: map[string]scopeSummary{
384+
installer.ScopeGlobal: {Installed: 0, Total: 1, loaded: true},
385+
},
386+
Agents: []agentEntry{
387+
{
388+
Name: "claude-code",
389+
Managed: true,
390+
Installed: map[string]pluginInfo{installer.ScopeGlobal: {Version: "0.2.6", NativeScope: "user"}},
391+
},
392+
},
393+
}
394+
395+
renderListText(ctx, out, installer.ScopeGlobal)
396+
397+
got := stderr.String()
398+
pluginIdx := strings.Index(got, "Plugin installs:")
399+
rawIdx := strings.Index(got, "Available raw skill directories")
400+
require.GreaterOrEqual(t, pluginIdx, 0)
401+
require.GreaterOrEqual(t, rawIdx, 0)
402+
assert.Less(t, pluginIdx, rawIdx)
403+
assert.Contains(t, got, "Claude Code")
404+
assert.Contains(t, got, "databricks plugin · v0.2.6 · up to date")
405+
assert.Contains(t, got, "0/1 raw skill directories installed (global)")
406+
}
407+
376408
func TestListScopeFlag(t *testing.T) {
377409
tests := []struct {
378410
name string

cmd/aitools/uninstall.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ var uninstallSkillsFn = func(ctx context.Context, opts installer.UninstallOption
1616
}
1717

1818
func NewUninstallCmd() *cobra.Command {
19-
var skillsFlag, scopeFlag string
19+
var skillsFlag, agentsFlag, scopeFlag string
2020
var projectFlag, globalFlag, keepMarketplace bool
2121

2222
cmd := &cobra.Command{
@@ -53,6 +53,16 @@ By default, removes all skills. Use --skills to remove specific skills only.`,
5353
KeepMarketplace: keepMarketplace,
5454
}
5555
opts.Skills = splitAndTrim(skillsFlag)
56+
if agentsFlag != "" {
57+
targetAgents, err := resolveAgentNames(ctx, agentsFlag)
58+
if err != nil {
59+
return err
60+
}
61+
opts.Agents = make([]string, 0, len(targetAgents))
62+
for _, agent := range targetAgents {
63+
opts.Agents = append(opts.Agents, agent.Name)
64+
}
65+
}
5666

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

7989
cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to uninstall (comma-separated)")
90+
cmd.Flags().StringVar(&agentsFlag, "agents", "", "Agents to uninstall from (comma-separated, e.g. claude-code,cursor)")
8091
cmd.Flags().BoolVar(&keepMarketplace, "keep-marketplace", false, "Keep the marketplace registration when removing a plugin")
8192
cmd.Flags().StringVar(&scopeFlag, "scope", "", "Uninstall scope: project or global")
8293
cmd.Flags().BoolVar(&projectFlag, "project", false, "Uninstall project-scoped skills")
@@ -108,8 +119,12 @@ func uninstallConfirmMessage(state *installer.InstallState, opts installer.Unins
108119
if state == nil {
109120
return "", false
110121
}
122+
target := "all agents"
123+
if len(opts.Agents) > 0 {
124+
target = strings.Join(opts.Agents, ", ")
125+
}
111126
if len(opts.Skills) > 0 {
112-
return fmt.Sprintf("This will remove %s %s (%s scope).", plural(len(opts.Skills), "skill"), strings.Join(opts.Skills, ", "), opts.Scope), true
127+
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
113128
}
114129
var parts []string
115130
if n := len(state.Skills); n > 0 {
@@ -121,7 +136,7 @@ func uninstallConfirmMessage(state *installer.InstallState, opts installer.Unins
121136
if len(parts) == 0 {
122137
return "", false
123138
}
124-
return fmt.Sprintf("This will remove %s (%s scope).", strings.Join(parts, " and "), opts.Scope), true
139+
return fmt.Sprintf("This will remove %s from %s (%s scope).", strings.Join(parts, " and "), target, opts.Scope), true
125140
}
126141

127142
// plural returns noun for n == 1 and noun+"s" otherwise.

cmd/aitools/uninstall_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,37 @@ func TestUninstallScopeFlag(t *testing.T) {
6565
}
6666
}
6767

68+
func TestUninstallAgentsFlag(t *testing.T) {
69+
setupTestAgents(t)
70+
calls := setupUninstallMock(t)
71+
72+
ctx := cmdio.MockDiscard(t.Context())
73+
cmd := NewUninstallCmd()
74+
cmd.SetContext(ctx)
75+
cmd.SetArgs([]string{"--scope", "global", "--agents", "cursor,claude-code", "--skills", "databricks-sql"})
76+
77+
require.NoError(t, cmd.Execute())
78+
require.Len(t, *calls, 1)
79+
assert.Equal(t, []string{"cursor", "claude-code"}, (*calls)[0].Agents)
80+
assert.Equal(t, []string{"databricks-sql"}, (*calls)[0].Skills)
81+
}
82+
83+
func TestUninstallUnknownAgentErrors(t *testing.T) {
84+
setupTestAgents(t)
85+
setupUninstallMock(t)
86+
87+
ctx := cmdio.MockDiscard(t.Context())
88+
cmd := NewUninstallCmd()
89+
cmd.SetContext(ctx)
90+
cmd.SetArgs([]string{"--scope", "global", "--agents", "invalid-agent"})
91+
cmd.SilenceErrors = true
92+
cmd.SilenceUsage = true
93+
94+
err := cmd.Execute()
95+
require.Error(t, err)
96+
assert.Contains(t, err.Error(), "unknown agent")
97+
}
98+
6899
func TestUninstallConfirmMessage(t *testing.T) {
69100
// Nothing recorded: no prompt (installer surfaces its own guidance).
70101
_, ask := uninstallConfirmMessage(nil, installer.UninstallOptions{Scope: installer.ScopeGlobal})
@@ -90,5 +121,12 @@ func TestUninstallConfirmMessage(t *testing.T) {
90121
msg2, ask2 := uninstallConfirmMessage(st, filtered)
91122
require.True(t, ask2)
92123
assert.Contains(t, msg2, "skill alpha")
124+
assert.Contains(t, msg2, "from all agents")
93125
assert.Contains(t, msg2, "(project scope)")
126+
127+
targeted := installer.UninstallOptions{Scope: installer.ScopeGlobal, Agents: []string{"cursor"}}
128+
targeted.Skills = []string{"alpha"}
129+
msg3, ask3 := uninstallConfirmMessage(st, targeted)
130+
require.True(t, ask3)
131+
assert.Contains(t, msg3, "from cursor")
94132
}

0 commit comments

Comments
 (0)