Skip to content
Open
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
15 changes: 12 additions & 3 deletions experimental/aitools/cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,31 @@ import (
func newInstallCmd() *cobra.Command {
var skillsFlag, agentsFlag string
var includeExperimental bool
var projectFlag, globalFlag bool

cmd := &cobra.Command{
Use: "install",
Short: "Install AI skills for coding agents",
Long: `Install Databricks AI skills for detected coding agents.

Skills are installed globally to each agent's skills directory.
By default, skills are installed globally to each agent's skills directory.
Use --project to install to the current project directory instead.
When multiple agents are detected, skills are stored in a canonical location
and symlinked to each agent to avoid duplication.

Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

// Resolve scope.
scope, err := resolveScopeWithPrompt(ctx, projectFlag, globalFlag)
if err != nil {
return err
}

// Resolve target agents.
var targetAgents []*agents.Agent
if agentsFlag != "" {
var err error
targetAgents, err = resolveAgentNames(ctx, agentsFlag)
if err != nil {
return err
Expand All @@ -49,7 +56,6 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
case len(detected) == 1:
targetAgents = detected
case cmdio.IsPromptSupported(ctx):
var err error
targetAgents, err = promptAgentSelection(ctx, detected)
if err != nil {
return err
Expand All @@ -62,6 +68,7 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
// Build install options.
opts := installer.InstallOptions{
IncludeExperimental: includeExperimental,
Scope: scope,
}
if skillsFlag != "" {
opts.SpecificSkills = strings.Split(skillsFlag, ",")
Expand All @@ -77,6 +84,8 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to install (comma-separated)")
cmd.Flags().StringVar(&agentsFlag, "agents", "", "Agents to install for (comma-separated, e.g. claude-code,cursor)")
cmd.Flags().BoolVar(&includeExperimental, "include-experimental", false, "Include experimental skills")
cmd.Flags().BoolVar(&projectFlag, "project", false, "Install to project directory (cwd)")
cmd.Flags().BoolVar(&globalFlag, "global", false, "Install globally (default)")
return cmd
}

Expand Down
145 changes: 145 additions & 0 deletions experimental/aitools/cmd/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ func setupInstallMock(t *testing.T) *[]installCall {
return &calls
}

func setupScopeMock(t *testing.T, scope string) *bool {
t.Helper()
orig := promptScopeSelection
t.Cleanup(func() { promptScopeSelection = orig })

called := false
promptScopeSelection = func(_ context.Context) (string, error) {
called = true
return scope, nil
}
return &called
}

type installCall struct {
agents []string
opts installer.InstallOptions
Expand Down Expand Up @@ -146,6 +159,7 @@ func TestInstallIncludeExperimental(t *testing.T) {
func TestInstallInteractivePrompt(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)
setupScopeMock(t, installer.ScopeGlobal)

origPrompt := promptAgentSelection
t.Cleanup(func() { promptAgentSelection = origPrompt })
Expand Down Expand Up @@ -347,3 +361,134 @@ func TestResolveAgentNamesEmpty(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "no agents specified")
}

// --- Scope flag tests ---

func TestInstallProjectFlag(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
cmd := newInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--project"})

err := cmd.Execute()
require.NoError(t, err)

require.Len(t, *calls, 1)
assert.Equal(t, installer.ScopeProject, (*calls)[0].opts.Scope)
}

func TestInstallGlobalFlag(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
cmd := newInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--global"})

err := cmd.Execute()
require.NoError(t, err)

require.Len(t, *calls, 1)
assert.Equal(t, installer.ScopeGlobal, (*calls)[0].opts.Scope)
}

func TestInstallGlobalAndProjectErrors(t *testing.T) {
setupTestAgents(t)
setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
cmd := newInstallCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--global", "--project"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true

err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot use --global and --project together")
}

func TestInstallNoFlagNonInteractiveUsesGlobal(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)

ctx := cmdio.MockDiscard(t.Context())
cmd := newInstallCmd()
cmd.SetContext(ctx)

err := cmd.RunE(cmd, nil)
require.NoError(t, err)

require.Len(t, *calls, 1)
assert.Equal(t, installer.ScopeGlobal, (*calls)[0].opts.Scope)
}

func TestInstallNoFlagInteractiveShowsScopePrompt(t *testing.T) {
setupTestAgents(t)
calls := setupInstallMock(t)
scopePromptCalled := setupScopeMock(t, installer.ScopeProject)

// Also mock agent prompt since interactive mode triggers it.
origPrompt := promptAgentSelection
t.Cleanup(func() { promptAgentSelection = origPrompt })
promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) {
return detected, nil
}

ctx, test := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true})
defer test.Done()

drain := func(r *bufio.Reader) {
buf := make([]byte, 4096)
for {
_, err := r.Read(buf)
if err != nil {
return
}
}
}
go drain(test.Stdout)
go drain(test.Stderr)

cmd := newInstallCmd()
cmd.SetContext(ctx)

err := cmd.RunE(cmd, nil)
require.NoError(t, err)

assert.True(t, *scopePromptCalled, "scope prompt should be called in interactive mode")
require.Len(t, *calls, 1)
assert.Equal(t, installer.ScopeProject, (*calls)[0].opts.Scope)
}

func TestResolveScopeValidation(t *testing.T) {
tests := []struct {
name string
project bool
global bool
want string
wantErr string
}{
{name: "neither", want: installer.ScopeGlobal},
{name: "global only", global: true, want: installer.ScopeGlobal},
{name: "project only", project: true, want: installer.ScopeProject},
{name: "both", project: true, global: true, wantErr: "cannot use --global and --project together"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveScope(tc.project, tc.global)
if tc.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
} else {
require.NoError(t, err)
assert.Equal(t, tc.want, got)
}
})
}
}
Loading
Loading