Skip to content

Commit 3fbef89

Browse files
authored
[2/5] Aitools: install writes state, interactive agent selection, idempotent install (#4811)
## PR Stack 1. [1/5] State + release discovery + directory rename (#4810) 2. **[2/5] Install writes state + interactive agent selection** (this PR) 3. [3/5] Update + uninstall + version commands (#4812) 4. [4/5] List improvements + command restructuring + flags (#4813) 5. [5/5] Project scope (--project/--global) (pending) **Base**: `simonfaltum/aitools-pr1-state` (PR 1) ## Why Install currently has no state tracking, no filtering, and outputs every file download to the console. Users can't tell what version they have, and there's no way to install for specific agents. ## Changes Before: `skills install` downloads everything, prints every file, has no state, no filtering, no agent selection. Now: - `InstallSkillsForAgents(ctx, src, agents, opts)` as the core install function. `InstallAllSkills` becomes a thin wrapper (signature preserved for `cmd/apps/init.go` compat). - State written to `.state.json` after successful install. Merges with existing state (doesn't overwrite). - Idempotent: second install skips already-present skills with matching versions. - Experimental filtering: skip `experimental: true` skills by default. - `min_cli_version` enforcement: skip incompatible skills with warning (hard error for single-skill install). - Interactive agent selection via `charmbracelet/huh` multi-select. Skip prompt for 1 agent, all detected for non-interactive. - Legacy install detection: prints "reinstall" message when old install found without state. - Concise default output (2 lines). Per-file detail only at debug level. ## Test plan - [x] State created after install-all and install-single - [x] Experimental filtering (skip by default, include with flag) - [x] min_cli_version: warning for all, hard error for single - [x] Idempotent: skip same version, update changed - [x] Legacy detection with helpful message - [x] Interactive prompt for 2+ agents, skip for 1 - [x] Non-interactive: all detected, no prompt - [x] `InstallAllSkills` signature preserved - [x] Concise output, per-file at debug level
1 parent ead07cf commit 3fbef89

File tree

6 files changed

+965
-101
lines changed

6 files changed

+965
-101
lines changed

experimental/aitools/cmd/install.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import (
55
)
66

77
func newInstallCmd() *cobra.Command {
8-
return &cobra.Command{
8+
var includeExperimental bool
9+
10+
cmd := &cobra.Command{
911
Use: "install [skill-name]",
1012
Short: "Alias for skills install",
1113
Long: `Alias for "databricks experimental aitools skills install".
1214
1315
Installs Databricks skills for detected coding agents.`,
1416
RunE: func(cmd *cobra.Command, args []string) error {
15-
return runSkillsInstall(cmd.Context(), args)
17+
return runSkillsInstall(cmd.Context(), args, includeExperimental)
1618
},
1719
}
20+
21+
cmd.Flags().BoolVar(&includeExperimental, "experimental", false, "Include experimental skills")
22+
return cmd
1823
}
Lines changed: 163 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,199 @@
11
package aitools
22

33
import (
4+
"bufio"
45
"context"
6+
"os"
7+
"path/filepath"
58
"testing"
69

10+
"github.com/databricks/cli/experimental/aitools/lib/agents"
11+
"github.com/databricks/cli/experimental/aitools/lib/installer"
12+
"github.com/databricks/cli/libs/cmdio"
713
"github.com/spf13/cobra"
814
"github.com/stretchr/testify/assert"
915
"github.com/stretchr/testify/require"
1016
)
1117

18+
func setupInstallMock(t *testing.T) *[]installCall {
19+
t.Helper()
20+
orig := installSkillsForAgentsFn
21+
t.Cleanup(func() { installSkillsForAgentsFn = orig })
22+
23+
var calls []installCall
24+
installSkillsForAgentsFn = func(_ context.Context, _ installer.ManifestSource, targetAgents []*agents.Agent, opts installer.InstallOptions) error {
25+
names := make([]string, len(targetAgents))
26+
for i, a := range targetAgents {
27+
names[i] = a.Name
28+
}
29+
calls = append(calls, installCall{agents: names, opts: opts})
30+
return nil
31+
}
32+
return &calls
33+
}
34+
35+
type installCall struct {
36+
agents []string
37+
opts installer.InstallOptions
38+
}
39+
40+
func setupTestAgents(t *testing.T) string {
41+
t.Helper()
42+
tmp := t.TempDir()
43+
t.Setenv("HOME", tmp)
44+
// Create config dirs for two agents.
45+
require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".claude"), 0o755))
46+
require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".cursor"), 0o755))
47+
return tmp
48+
}
49+
1250
func TestInstallCommandsDelegateToSkillsInstall(t *testing.T) {
13-
originalInstallAllSkills := installAllSkills
14-
originalInstallSkill := installSkill
15-
t.Cleanup(func() {
16-
installAllSkills = originalInstallAllSkills
17-
installSkill = originalInstallSkill
18-
})
51+
setupTestAgents(t)
52+
calls := setupInstallMock(t)
1953

2054
tests := []struct {
21-
name string
22-
newCmd func() *cobra.Command
23-
args []string
24-
wantAllCalls int
25-
wantSkillCalls []string
55+
name string
56+
newCmd func() *cobra.Command
57+
args []string
58+
flags []string
59+
wantAgents int
60+
wantSkills []string
61+
wantExperimental bool
2662
}{
2763
{
28-
name: "skills install installs all skills",
29-
newCmd: newSkillsInstallCmd,
30-
wantAllCalls: 1,
64+
name: "skills install installs all skills for all agents",
65+
newCmd: newSkillsInstallCmd,
66+
wantAgents: 2,
67+
},
68+
{
69+
name: "skills install forwards skill name",
70+
newCmd: newSkillsInstallCmd,
71+
args: []string{"bundle/review"},
72+
wantAgents: 2,
73+
wantSkills: []string{"bundle/review"},
3174
},
3275
{
33-
name: "skills install forwards skill name",
34-
newCmd: newSkillsInstallCmd,
35-
args: []string{"bundle/review"},
36-
wantSkillCalls: []string{"bundle/review"},
76+
name: "skills install with --experimental",
77+
newCmd: newSkillsInstallCmd,
78+
flags: []string{"--experimental"},
79+
wantAgents: 2,
80+
wantExperimental: true,
3781
},
3882
{
39-
name: "top level install installs all skills",
40-
newCmd: newInstallCmd,
41-
wantAllCalls: 1,
83+
name: "top level install installs all skills",
84+
newCmd: newInstallCmd,
85+
wantAgents: 2,
4286
},
4387
{
44-
name: "top level install forwards skill name",
45-
newCmd: newInstallCmd,
46-
args: []string{"bundle/review"},
47-
wantSkillCalls: []string{"bundle/review"},
88+
name: "top level install forwards skill name",
89+
newCmd: newInstallCmd,
90+
args: []string{"bundle/review"},
91+
wantAgents: 2,
92+
wantSkills: []string{"bundle/review"},
93+
},
94+
{
95+
name: "top level install with --experimental",
96+
newCmd: newInstallCmd,
97+
flags: []string{"--experimental"},
98+
wantAgents: 2,
99+
wantExperimental: true,
48100
},
49101
}
50102

51103
for _, tt := range tests {
52104
t.Run(tt.name, func(t *testing.T) {
53-
allCalls := 0
54-
var skillCalls []string
55-
56-
installAllSkills = func(context.Context) error {
57-
allCalls++
58-
return nil
59-
}
60-
installSkill = func(_ context.Context, skillName string) error {
61-
skillCalls = append(skillCalls, skillName)
62-
return nil
63-
}
105+
*calls = nil
64106

107+
ctx := cmdio.MockDiscard(t.Context())
65108
cmd := tt.newCmd()
66-
cmd.SetContext(t.Context())
109+
cmd.SetContext(ctx)
110+
if len(tt.flags) > 0 {
111+
require.NoError(t, cmd.ParseFlags(tt.flags))
112+
}
67113

68114
err := cmd.RunE(cmd, tt.args)
69115
require.NoError(t, err)
70116

71-
assert.Equal(t, tt.wantAllCalls, allCalls)
72-
assert.Equal(t, tt.wantSkillCalls, skillCalls)
117+
require.Len(t, *calls, 1)
118+
assert.Len(t, (*calls)[0].agents, tt.wantAgents)
119+
assert.Equal(t, tt.wantSkills, (*calls)[0].opts.SpecificSkills)
120+
assert.Equal(t, tt.wantExperimental, (*calls)[0].opts.IncludeExperimental)
73121
})
74122
}
75123
}
124+
125+
func TestRunSkillsInstallInteractivePrompt(t *testing.T) {
126+
setupTestAgents(t)
127+
calls := setupInstallMock(t)
128+
129+
origPrompt := promptAgentSelection
130+
t.Cleanup(func() { promptAgentSelection = origPrompt })
131+
132+
promptCalled := false
133+
promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) {
134+
promptCalled = true
135+
// Return only the first agent.
136+
return detected[:1], nil
137+
}
138+
139+
// Use SetupTest with PromptSupported=true to simulate interactive terminal.
140+
ctx, test := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true})
141+
defer test.Done()
142+
143+
// Drain both pipes in background to prevent blocking.
144+
drain := func(r *bufio.Reader) {
145+
buf := make([]byte, 4096)
146+
for {
147+
_, err := r.Read(buf)
148+
if err != nil {
149+
return
150+
}
151+
}
152+
}
153+
go drain(test.Stdout)
154+
go drain(test.Stderr)
155+
156+
err := runSkillsInstall(ctx, nil, false)
157+
require.NoError(t, err)
158+
159+
assert.True(t, promptCalled, "prompt should be called when 2+ agents detected and interactive")
160+
require.Len(t, *calls, 1)
161+
assert.Len(t, (*calls)[0].agents, 1, "only the selected agent should be passed")
162+
}
163+
164+
func TestRunSkillsInstallNonInteractiveUsesAllAgents(t *testing.T) {
165+
setupTestAgents(t)
166+
calls := setupInstallMock(t)
167+
168+
origPrompt := promptAgentSelection
169+
t.Cleanup(func() { promptAgentSelection = origPrompt })
170+
171+
promptCalled := false
172+
promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) {
173+
promptCalled = true
174+
return detected, nil
175+
}
176+
177+
// MockDiscard gives a non-interactive context.
178+
ctx := cmdio.MockDiscard(t.Context())
179+
180+
err := runSkillsInstall(ctx, nil, false)
181+
require.NoError(t, err)
182+
183+
assert.False(t, promptCalled, "prompt should not be called in non-interactive mode")
184+
require.Len(t, *calls, 1)
185+
assert.Len(t, (*calls)[0].agents, 2, "all detected agents should be used")
186+
}
187+
188+
func TestRunSkillsInstallNoAgents(t *testing.T) {
189+
// Set HOME to empty dir so no agents are detected.
190+
tmp := t.TempDir()
191+
t.Setenv("HOME", tmp)
192+
193+
calls := setupInstallMock(t)
194+
ctx := cmdio.MockDiscard(t.Context())
195+
196+
err := runSkillsInstall(ctx, nil, false)
197+
require.NoError(t, err)
198+
assert.Empty(t, *calls, "install should not be called when no agents detected")
199+
}

experimental/aitools/cmd/skills.go

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,52 @@ package aitools
22

33
import (
44
"context"
5+
"errors"
56

7+
"github.com/charmbracelet/huh"
8+
"github.com/databricks/cli/experimental/aitools/lib/agents"
69
"github.com/databricks/cli/experimental/aitools/lib/installer"
10+
"github.com/databricks/cli/libs/cmdio"
11+
"github.com/fatih/color"
712
"github.com/spf13/cobra"
813
)
914

15+
// Package-level vars for testability.
1016
var (
11-
installAllSkills = installer.InstallAllSkills
12-
installSkill = installer.InstallSkill
17+
promptAgentSelection = defaultPromptAgentSelection
18+
installSkillsForAgentsFn = installer.InstallSkillsForAgents
1319
)
1420

21+
func defaultPromptAgentSelection(ctx context.Context, detected []*agents.Agent) ([]*agents.Agent, error) {
22+
options := make([]huh.Option[string], 0, len(detected))
23+
agentsByName := make(map[string]*agents.Agent, len(detected))
24+
for _, a := range detected {
25+
options = append(options, huh.NewOption(a.DisplayName, a.Name).Selected(true))
26+
agentsByName[a.Name] = a
27+
}
28+
29+
var selected []string
30+
err := huh.NewMultiSelect[string]().
31+
Title("Select coding agents to install skills for").
32+
Description("space to toggle, enter to confirm").
33+
Options(options...).
34+
Value(&selected).
35+
Run()
36+
if err != nil {
37+
return nil, err
38+
}
39+
40+
if len(selected) == 0 {
41+
return nil, errors.New("at least one agent must be selected")
42+
}
43+
44+
result := make([]*agents.Agent, 0, len(selected))
45+
for _, name := range selected {
46+
result = append(result, agentsByName[name])
47+
}
48+
return result, nil
49+
}
50+
1551
func newSkillsCmd() *cobra.Command {
1652
cmd := &cobra.Command{
1753
Use: "skills",
@@ -36,7 +72,9 @@ func newSkillsListCmd() *cobra.Command {
3672
}
3773

3874
func newSkillsInstallCmd() *cobra.Command {
39-
return &cobra.Command{
75+
var includeExperimental bool
76+
77+
cmd := &cobra.Command{
4078
Use: "install [skill-name]",
4179
Short: "Install Databricks skills for detected coding agents",
4280
Long: `Install Databricks skills to all detected coding agents.
@@ -47,15 +85,48 @@ and symlinked to each agent to avoid duplication.
4785
4886
Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity`,
4987
RunE: func(cmd *cobra.Command, args []string) error {
50-
return runSkillsInstall(cmd.Context(), args)
88+
return runSkillsInstall(cmd.Context(), args, includeExperimental)
5189
},
5290
}
91+
92+
cmd.Flags().BoolVar(&includeExperimental, "experimental", false, "Include experimental skills")
93+
return cmd
5394
}
5495

55-
func runSkillsInstall(ctx context.Context, args []string) error {
96+
func runSkillsInstall(ctx context.Context, args []string, includeExperimental bool) error {
97+
detected := agents.DetectInstalled(ctx)
98+
if len(detected) == 0 {
99+
cmdio.LogString(ctx, color.YellowString("No supported coding agents detected."))
100+
cmdio.LogString(ctx, "")
101+
cmdio.LogString(ctx, "Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity")
102+
cmdio.LogString(ctx, "Please install at least one coding agent first.")
103+
return nil
104+
}
105+
106+
var targetAgents []*agents.Agent
107+
switch {
108+
case len(detected) == 1:
109+
targetAgents = detected
110+
case cmdio.IsPromptSupported(ctx):
111+
var err error
112+
targetAgents, err = promptAgentSelection(ctx, detected)
113+
if err != nil {
114+
return err
115+
}
116+
default:
117+
// Non-interactive: install for all detected agents.
118+
targetAgents = detected
119+
}
120+
121+
installer.PrintInstallingFor(ctx, targetAgents)
122+
123+
opts := installer.InstallOptions{
124+
IncludeExperimental: includeExperimental,
125+
}
56126
if len(args) > 0 {
57-
return installSkill(ctx, args[0])
127+
opts.SpecificSkills = []string{args[0]}
58128
}
59129

60-
return installAllSkills(ctx)
130+
src := &installer.GitHubManifestSource{}
131+
return installSkillsForAgentsFn(ctx, src, targetAgents, opts)
61132
}

0 commit comments

Comments
 (0)