diff --git a/experimental/aitools/cmd/aitools.go b/experimental/aitools/cmd/aitools.go index 3ce43a1073..f037ac1a22 100644 --- a/experimental/aitools/cmd/aitools.go +++ b/experimental/aitools/cmd/aitools.go @@ -18,11 +18,12 @@ Provides commands to: } cmd.AddCommand(newInstallCmd()) - cmd.AddCommand(newSkillsCmd()) - cmd.AddCommand(newToolsCmd()) cmd.AddCommand(newUpdateCmd()) cmd.AddCommand(newUninstallCmd()) + cmd.AddCommand(newListCmd()) cmd.AddCommand(newVersionCmd()) + cmd.AddCommand(newSkillsCmd()) + cmd.AddCommand(newToolsCmd()) return cmd } diff --git a/experimental/aitools/cmd/install.go b/experimental/aitools/cmd/install.go index 56f1f034a8..86516abe04 100644 --- a/experimental/aitools/cmd/install.go +++ b/experimental/aitools/cmd/install.go @@ -1,23 +1,119 @@ package aitools import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/databricks/cli/experimental/aitools/lib/agents" + "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/libs/cmdio" + "github.com/fatih/color" "github.com/spf13/cobra" ) func newInstallCmd() *cobra.Command { + var skillsFlag, agentsFlag string var includeExperimental bool cmd := &cobra.Command{ - Use: "install [skill-name]", - Short: "Alias for skills install", - Long: `Alias for "databricks experimental aitools skills install". + 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. +When multiple agents are detected, skills are stored in a canonical location +and symlinked to each agent to avoid duplication. -Installs Databricks skills for detected coding agents.`, +Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity`, RunE: func(cmd *cobra.Command, args []string) error { - return runSkillsInstall(cmd.Context(), args, includeExperimental) + ctx := cmd.Context() + + // Resolve target agents. + var targetAgents []*agents.Agent + if agentsFlag != "" { + var err error + targetAgents, err = resolveAgentNames(ctx, agentsFlag) + if err != nil { + return err + } + } else { + detected := agents.DetectInstalled(ctx) + if len(detected) == 0 { + printNoAgentsMessage(ctx) + return nil + } + + switch { + case len(detected) == 1: + targetAgents = detected + case cmdio.IsPromptSupported(ctx): + var err error + targetAgents, err = promptAgentSelection(ctx, detected) + if err != nil { + return err + } + default: + targetAgents = detected + } + } + + // Build install options. + opts := installer.InstallOptions{ + IncludeExperimental: includeExperimental, + } + if skillsFlag != "" { + opts.SpecificSkills = strings.Split(skillsFlag, ",") + } + + installer.PrintInstallingFor(ctx, targetAgents) + + src := &installer.GitHubManifestSource{} + return installSkillsForAgentsFn(ctx, src, targetAgents, opts) }, } + 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, "experimental", false, "Include experimental skills") return cmd } + +// resolveAgentNames parses a comma-separated list of agent names and validates +// them against the registry. Returns an error for unrecognized names. +func resolveAgentNames(ctx context.Context, names string) ([]*agents.Agent, error) { + available := make(map[string]*agents.Agent, len(agents.Registry)) + var availableNames []string + for i := range agents.Registry { + a := &agents.Registry[i] + available[a.Name] = a + availableNames = append(availableNames, a.Name) + } + + var result []*agents.Agent + for _, name := range strings.Split(names, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + agent, ok := available[name] + if !ok { + return nil, fmt.Errorf("unknown agent %q. Available agents: %s", name, strings.Join(availableNames, ", ")) + } + result = append(result, agent) + } + + if len(result) == 0 { + return nil, errors.New("no agents specified") + } + return result, nil +} + +// printNoAgentsMessage prints the "no agents detected" message. +func printNoAgentsMessage(ctx context.Context) { + cmdio.LogString(ctx, color.YellowString("No supported coding agents detected.")) + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity") + cmdio.LogString(ctx, "Please install at least one coding agent first.") +} diff --git a/experimental/aitools/cmd/install_test.go b/experimental/aitools/cmd/install_test.go index bab41a2040..2cffb8f0c9 100644 --- a/experimental/aitools/cmd/install_test.go +++ b/experimental/aitools/cmd/install_test.go @@ -10,7 +10,6 @@ import ( "github.com/databricks/cli/experimental/aitools/lib/agents" "github.com/databricks/cli/experimental/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" - "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -47,82 +46,104 @@ func setupTestAgents(t *testing.T) string { return tmp } -func TestInstallCommandsDelegateToSkillsInstall(t *testing.T) { +func TestInstallAllSkillsForAllAgents(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - tests := []struct { - name string - newCmd func() *cobra.Command - args []string - flags []string - wantAgents int - wantSkills []string - wantExperimental bool - }{ - { - name: "skills install installs all skills for all agents", - newCmd: newSkillsInstallCmd, - wantAgents: 2, - }, - { - name: "skills install forwards skill name", - newCmd: newSkillsInstallCmd, - args: []string{"bundle/review"}, - wantAgents: 2, - wantSkills: []string{"bundle/review"}, - }, - { - name: "skills install with --experimental", - newCmd: newSkillsInstallCmd, - flags: []string{"--experimental"}, - wantAgents: 2, - wantExperimental: true, - }, - { - name: "top level install installs all skills", - newCmd: newInstallCmd, - wantAgents: 2, - }, - { - name: "top level install forwards skill name", - newCmd: newInstallCmd, - args: []string{"bundle/review"}, - wantAgents: 2, - wantSkills: []string{"bundle/review"}, - }, - { - name: "top level install with --experimental", - newCmd: newInstallCmd, - flags: []string{"--experimental"}, - wantAgents: 2, - wantExperimental: true, - }, - } + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - *calls = nil + err := cmd.RunE(cmd, nil) + require.NoError(t, err) - ctx := cmdio.MockDiscard(t.Context()) - cmd := tt.newCmd() - cmd.SetContext(ctx) - if len(tt.flags) > 0 { - require.NoError(t, cmd.ParseFlags(tt.flags)) - } + require.Len(t, *calls, 1) + assert.Len(t, (*calls)[0].agents, 2) + assert.Nil(t, (*calls)[0].opts.SpecificSkills) +} + +func TestInstallSpecificSkills(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) - err := cmd.RunE(cmd, tt.args) - require.NoError(t, err) + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--skills", "databricks,databricks-apps"}) - require.Len(t, *calls, 1) - assert.Len(t, (*calls)[0].agents, tt.wantAgents) - assert.Equal(t, tt.wantSkills, (*calls)[0].opts.SpecificSkills) - assert.Equal(t, tt.wantExperimental, (*calls)[0].opts.IncludeExperimental) - }) - } + err := cmd.Execute() + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Equal(t, []string{"databricks", "databricks-apps"}, (*calls)[0].opts.SpecificSkills) } -func TestRunSkillsInstallInteractivePrompt(t *testing.T) { +func TestInstallSingleSkill(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--skills", "databricks"}) + + err := cmd.Execute() + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Equal(t, []string{"databricks"}, (*calls)[0].opts.SpecificSkills) +} + +func TestInstallSpecificAgents(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--agents", "claude-code"}) + + err := cmd.Execute() + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Equal(t, []string{"claude-code"}, (*calls)[0].agents) +} + +func TestInstallUnknownAgentErrors(t *testing.T) { + setupTestAgents(t) + setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--agents", "invalid-agent"}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown agent") + assert.Contains(t, err.Error(), "Available agents:") +} + +func TestInstallIncludeExperimental(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--experimental"}) + + err := cmd.Execute() + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.True(t, (*calls)[0].opts.IncludeExperimental) +} + +func TestInstallInteractivePrompt(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) @@ -132,15 +153,12 @@ func TestRunSkillsInstallInteractivePrompt(t *testing.T) { promptCalled := false promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { promptCalled = true - // Return only the first agent. return detected[:1], nil } - // Use SetupTest with PromptSupported=true to simulate interactive terminal. ctx, test := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true}) defer test.Done() - // Drain both pipes in background to prevent blocking. drain := func(r *bufio.Reader) { buf := make([]byte, 4096) for { @@ -153,7 +171,10 @@ func TestRunSkillsInstallInteractivePrompt(t *testing.T) { go drain(test.Stdout) go drain(test.Stderr) - err := runSkillsInstall(ctx, nil, false) + cmd := newInstallCmd() + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, nil) require.NoError(t, err) assert.True(t, promptCalled, "prompt should be called when 2+ agents detected and interactive") @@ -161,7 +182,7 @@ func TestRunSkillsInstallInteractivePrompt(t *testing.T) { assert.Len(t, (*calls)[0].agents, 1, "only the selected agent should be passed") } -func TestRunSkillsInstallNonInteractiveUsesAllAgents(t *testing.T) { +func TestInstallNonInteractiveUsesAllAgents(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) @@ -174,10 +195,11 @@ func TestRunSkillsInstallNonInteractiveUsesAllAgents(t *testing.T) { return detected, nil } - // MockDiscard gives a non-interactive context. ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) - err := runSkillsInstall(ctx, nil, false) + err := cmd.RunE(cmd, nil) require.NoError(t, err) assert.False(t, promptCalled, "prompt should not be called in non-interactive mode") @@ -185,15 +207,143 @@ func TestRunSkillsInstallNonInteractiveUsesAllAgents(t *testing.T) { assert.Len(t, (*calls)[0].agents, 2, "all detected agents should be used") } -func TestRunSkillsInstallNoAgents(t *testing.T) { - // Set HOME to empty dir so no agents are detected. +func TestInstallNoAgentsDetected(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - err := runSkillsInstall(ctx, nil, false) + cmd := newInstallCmd() + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, nil) require.NoError(t, err) assert.Empty(t, *calls, "install should not be called when no agents detected") } + +func TestInstallAgentsFlagSkipsPrompt(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + origPrompt := promptAgentSelection + t.Cleanup(func() { promptAgentSelection = origPrompt }) + + promptCalled := false + promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { + promptCalled = true + return detected, nil + } + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--agents", "claude-code,cursor"}) + + err := cmd.Execute() + require.NoError(t, err) + + assert.False(t, promptCalled, "prompt should not be called when --agents is specified") + require.Len(t, *calls, 1) + assert.Equal(t, []string{"claude-code", "cursor"}, (*calls)[0].agents) +} + +func TestSkillsInstallDelegatesToInstall(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newSkillsInstallCmd() + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, nil) + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Len(t, (*calls)[0].agents, 2) +} + +func TestSkillsInstallForwardsSkillName(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newSkillsInstallCmd() + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, []string{"databricks"}) + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Equal(t, []string{"databricks"}, (*calls)[0].opts.SpecificSkills) +} + +func TestSkillsInstallExecuteNoArgs(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newSkillsInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Len(t, (*calls)[0].agents, 2) + assert.Nil(t, (*calls)[0].opts.SpecificSkills) +} + +func TestSkillsInstallExecuteWithSkillName(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newSkillsInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"databricks"}) + + err := cmd.Execute() + require.NoError(t, err) + + require.Len(t, *calls, 1) + assert.Equal(t, []string{"databricks"}, (*calls)[0].opts.SpecificSkills) +} + +func TestSkillsInstallExecuteRejectsTwoArgs(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + cmd := newSkillsInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs([]string{"a", "b"}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts at most 1 arg") +} + +func TestResolveAgentNamesValid(t *testing.T) { + ctx := t.Context() + result, err := resolveAgentNames(ctx, "claude-code,cursor") + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "claude-code", result[0].Name) + assert.Equal(t, "cursor", result[1].Name) +} + +func TestResolveAgentNamesUnknown(t *testing.T) { + ctx := t.Context() + _, err := resolveAgentNames(ctx, "claude-code,invalid-agent") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown agent") + assert.Contains(t, err.Error(), "invalid-agent") +} + +func TestResolveAgentNamesEmpty(t *testing.T) { + ctx := t.Context() + _, err := resolveAgentNames(ctx, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "no agents specified") +} diff --git a/experimental/aitools/cmd/list.go b/experimental/aitools/cmd/list.go new file mode 100644 index 0000000000..4ff9714c9f --- /dev/null +++ b/experimental/aitools/cmd/list.go @@ -0,0 +1,104 @@ +package aitools + +import ( + "fmt" + "sort" + "strings" + "text/tabwriter" + + "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" + "github.com/spf13/cobra" +) + +// listSkillsFn is the function used to render the skills list. +// It is a package-level var so tests can replace the data-fetching layer. +var listSkillsFn = defaultListSkills + +func newListCmd() *cobra.Command { + // --skills is accepted for forward-compat (future component types) + // but currently skills is the only component, so the output is the same. + var showSkills bool + + cmd := &cobra.Command{ + Use: "list", + Short: "List installed AI tools components", + RunE: func(cmd *cobra.Command, args []string) error { + _ = showSkills + return listSkillsFn(cmd) + }, + } + + cmd.Flags().BoolVar(&showSkills, "skills", false, "Show detailed skills information") + return cmd +} + +func defaultListSkills(cmd *cobra.Command) error { + ctx := cmd.Context() + + src := &installer.GitHubManifestSource{} + latestTag, _, err := src.FetchLatestRelease(ctx) + if err != nil { + return fmt.Errorf("failed to fetch latest release: %w", err) + } + + manifest, err := src.FetchManifest(ctx, latestTag) + if err != nil { + return fmt.Errorf("failed to fetch manifest: %w", err) + } + + globalDir, err := installer.GlobalSkillsDir(ctx) + if err != nil { + return err + } + + state, err := installer.LoadState(globalDir) + if err != nil { + log.Debugf(ctx, "Could not load install state: %v", err) + } + + // Build sorted list of skill names. + names := make([]string, 0, len(manifest.Skills)) + for name := range manifest.Skills { + names = append(names, name) + } + sort.Strings(names) + + version := strings.TrimPrefix(latestTag, "v") + cmdio.LogString(ctx, "Available skills (v"+version+"):") + cmdio.LogString(ctx, "") + + var buf strings.Builder + tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED") + + installedCount := 0 + for _, name := range names { + meta := manifest.Skills[name] + + tag := "" + if meta.Experimental { + tag = " [experimental]" + } + + installedStr := "not installed" + if state != nil { + if v, ok := state.Skills[name]; ok { + installedCount++ + if v == meta.Version { + installedStr = "v" + v + " (up to date)" + } else { + installedStr = "v" + v + " (update available)" + } + } + } + + fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", name, tag, meta.Version, installedStr) + } + tw.Flush() + cmdio.LogString(ctx, buf.String()) + + cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", installedCount, len(names))) + return nil +} diff --git a/experimental/aitools/cmd/list_test.go b/experimental/aitools/cmd/list_test.go new file mode 100644 index 0000000000..81a304740d --- /dev/null +++ b/experimental/aitools/cmd/list_test.go @@ -0,0 +1,60 @@ +package aitools + +import ( + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListCommandExists(t *testing.T) { + cmd := newListCmd() + assert.Equal(t, "list", cmd.Use) +} + +func TestListCommandCallsListFn(t *testing.T) { + orig := listSkillsFn + t.Cleanup(func() { listSkillsFn = orig }) + + called := false + listSkillsFn = func(cmd *cobra.Command) error { + called = true + return nil + } + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newListCmd() + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, nil) + require.NoError(t, err) + assert.True(t, called) +} + +func TestListCommandHasSkillsFlag(t *testing.T) { + cmd := newListCmd() + f := cmd.Flags().Lookup("skills") + require.NotNil(t, f, "--skills flag should exist") + assert.Equal(t, "false", f.DefValue) +} + +func TestSkillsListDelegatesToListFn(t *testing.T) { + orig := listSkillsFn + t.Cleanup(func() { listSkillsFn = orig }) + + called := false + listSkillsFn = func(cmd *cobra.Command) error { + called = true + return nil + } + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newSkillsListCmd() + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, nil) + require.NoError(t, err) + assert.True(t, called) +} diff --git a/experimental/aitools/cmd/skills.go b/experimental/aitools/cmd/skills.go index 2161cf04ca..7f906f8b70 100644 --- a/experimental/aitools/cmd/skills.go +++ b/experimental/aitools/cmd/skills.go @@ -7,8 +7,6 @@ import ( "github.com/charmbracelet/huh" "github.com/databricks/cli/experimental/aitools/lib/agents" "github.com/databricks/cli/experimental/aitools/lib/installer" - "github.com/databricks/cli/libs/cmdio" - "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -50,11 +48,13 @@ func defaultPromptAgentSelection(ctx context.Context, detected []*agents.Agent) func newSkillsCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "skills", - Short: "Manage Databricks skills for coding agents", - Long: `Manage Databricks skills that extend coding agents with Databricks-specific capabilities.`, + Use: "skills", + Hidden: true, + Short: "Manage Databricks skills for coding agents", + Long: `Manage Databricks skills that extend coding agents with Databricks-specific capabilities.`, } + // Subcommands delegate to the flat top-level commands. cmd.AddCommand(newSkillsListCmd()) cmd.AddCommand(newSkillsInstallCmd()) @@ -66,67 +66,29 @@ func newSkillsListCmd() *cobra.Command { Use: "list", Short: "List available skills", RunE: func(cmd *cobra.Command, args []string) error { - return installer.ListSkills(cmd.Context()) + return listSkillsFn(cmd) }, } } func newSkillsInstallCmd() *cobra.Command { - var includeExperimental bool - cmd := &cobra.Command{ Use: "install [skill-name]", Short: "Install Databricks skills for detected coding agents", - Long: `Install Databricks skills to all detected coding agents. - -Skills are installed globally to each agent's skills directory. -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`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runSkillsInstall(cmd.Context(), args, includeExperimental) + // Delegate to the flat install command's logic. + installCmd := newInstallCmd() + installCmd.SetContext(cmd.Context()) + if len(args) > 0 { + // Pass the skill name as a --skills flag. + installCmd.SetArgs([]string{"--skills", args[0]}) + } else { + installCmd.SetArgs([]string{}) + } + return installCmd.Execute() }, } - cmd.Flags().BoolVar(&includeExperimental, "experimental", false, "Include experimental skills") return cmd } - -func runSkillsInstall(ctx context.Context, args []string, includeExperimental bool) error { - detected := agents.DetectInstalled(ctx) - if len(detected) == 0 { - cmdio.LogString(ctx, color.YellowString("No supported coding agents detected.")) - cmdio.LogString(ctx, "") - cmdio.LogString(ctx, "Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity") - cmdio.LogString(ctx, "Please install at least one coding agent first.") - return nil - } - - var targetAgents []*agents.Agent - switch { - case len(detected) == 1: - targetAgents = detected - case cmdio.IsPromptSupported(ctx): - var err error - targetAgents, err = promptAgentSelection(ctx, detected) - if err != nil { - return err - } - default: - // Non-interactive: install for all detected agents. - targetAgents = detected - } - - installer.PrintInstallingFor(ctx, targetAgents) - - opts := installer.InstallOptions{ - IncludeExperimental: includeExperimental, - } - if len(args) > 0 { - opts.SpecificSkills = []string{args[0]} - } - - src := &installer.GitHubManifestSource{} - return installSkillsForAgentsFn(ctx, src, targetAgents, opts) -} diff --git a/experimental/aitools/cmd/uninstall.go b/experimental/aitools/cmd/uninstall.go index 9edd360100..663d305506 100644 --- a/experimental/aitools/cmd/uninstall.go +++ b/experimental/aitools/cmd/uninstall.go @@ -1,19 +1,30 @@ package aitools import ( + "strings" + "github.com/databricks/cli/experimental/aitools/lib/installer" "github.com/spf13/cobra" ) func newUninstallCmd() *cobra.Command { - return &cobra.Command{ + var skillsFlag string + + cmd := &cobra.Command{ Use: "uninstall", - Short: "Uninstall all AI skills", - Long: `Remove all installed Databricks AI skills from all coding agents. + Short: "Uninstall AI skills", + Long: `Remove installed Databricks AI skills from all coding agents. -Removes skill directories, symlinks, and the state file.`, +By default, removes all skills. Use --skills to remove specific skills only.`, RunE: func(cmd *cobra.Command, args []string) error { - return installer.UninstallSkills(cmd.Context()) + opts := installer.UninstallOptions{} + if skillsFlag != "" { + opts.Skills = strings.Split(skillsFlag, ",") + } + return installer.UninstallSkillsOpts(cmd.Context(), opts) }, } + + cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to uninstall (comma-separated)") + return cmd } diff --git a/experimental/aitools/cmd/update.go b/experimental/aitools/cmd/update.go index 67dd2916cf..46d2d92b4c 100644 --- a/experimental/aitools/cmd/update.go +++ b/experimental/aitools/cmd/update.go @@ -1,6 +1,8 @@ package aitools import ( + "strings" + "github.com/databricks/cli/experimental/aitools/lib/agents" "github.com/databricks/cli/experimental/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" @@ -9,6 +11,7 @@ import ( func newUpdateCmd() *cobra.Command { var check, force, noNew bool + var skillsFlag string cmd := &cobra.Command{ Use: "update", @@ -22,11 +25,17 @@ preview what would change without downloading.`, ctx := cmd.Context() installed := agents.DetectInstalled(ctx) src := &installer.GitHubManifestSource{} - result, err := installer.UpdateSkills(ctx, src, installed, installer.UpdateOptions{ + + opts := installer.UpdateOptions{ Check: check, Force: force, NoNew: noNew, - }) + } + if skillsFlag != "" { + opts.Skills = strings.Split(skillsFlag, ",") + } + + result, err := installer.UpdateSkills(ctx, src, installed, opts) if err != nil { return err } @@ -40,5 +49,6 @@ preview what would change without downloading.`, cmd.Flags().BoolVar(&check, "check", false, "Show what would be updated without downloading") cmd.Flags().BoolVar(&force, "force", false, "Re-download even if versions match") cmd.Flags().BoolVar(&noNew, "no-new", false, "Don't auto-install new skills from manifest") + cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to update (comma-separated)") return cmd } diff --git a/experimental/aitools/cmd/version.go b/experimental/aitools/cmd/version.go index 4dc91beb3b..1857905036 100644 --- a/experimental/aitools/cmd/version.go +++ b/experimental/aitools/cmd/version.go @@ -12,10 +12,15 @@ import ( ) func newVersionCmd() *cobra.Command { - return &cobra.Command{ + var showSkills bool + + cmd := &cobra.Command{ Use: "version", Short: "Show installed AI skills version", RunE: func(cmd *cobra.Command, args []string) error { + // --skills is accepted for forward-compat (future component types) + // but currently skills is the only component, so the output is the same. + _ = showSkills ctx := cmd.Context() globalDir, err := installer.GlobalSkillsDir(ctx) @@ -81,4 +86,7 @@ func newVersionCmd() *cobra.Command { return nil }, } + + cmd.Flags().BoolVar(&showSkills, "skills", false, "Show detailed skills version information") + return cmd } diff --git a/experimental/aitools/lib/installer/installer.go b/experimental/aitools/lib/installer/installer.go index bfce08d6fa..3a5208be21 100644 --- a/experimental/aitools/lib/installer/installer.go +++ b/experimental/aitools/lib/installer/installer.go @@ -92,26 +92,6 @@ func fetchSkillFile(ctx context.Context, ref, skillName, filePath string) ([]byt return io.ReadAll(resp.Body) } -// ListSkills fetches and prints available skills. -func ListSkills(ctx context.Context) error { - manifest, err := FetchManifest(ctx) - if err != nil { - return err - } - - cmdio.LogString(ctx, "Available skills:") - cmdio.LogString(ctx, "") - - for name, meta := range manifest.Skills { - cmdio.LogString(ctx, fmt.Sprintf(" %s (v%s)", name, meta.Version)) - } - - cmdio.LogString(ctx, "") - cmdio.LogString(ctx, "Install all with: databricks experimental aitools skills install") - cmdio.LogString(ctx, "Install one with: databricks experimental aitools skills install ") - return nil -} - // InstallSkillsForAgents fetches the manifest and installs skills for the given agents. // This is the core installation function. Callers are responsible for agent detection, // prompting, and printing the "Installing..." header. diff --git a/experimental/aitools/lib/installer/uninstall.go b/experimental/aitools/lib/installer/uninstall.go index 1253cb8cfb..e0d9a63083 100644 --- a/experimental/aitools/lib/installer/uninstall.go +++ b/experimental/aitools/lib/installer/uninstall.go @@ -13,8 +13,20 @@ import ( "github.com/databricks/cli/libs/log" ) +// UninstallOptions controls the behavior of UninstallSkillsOpts. +type UninstallOptions struct { + Skills []string // empty = all +} + // UninstallSkills removes all installed skills, their symlinks, and the state file. func UninstallSkills(ctx context.Context) error { + return UninstallSkillsOpts(ctx, UninstallOptions{}) +} + +// UninstallSkillsOpts removes installed skills based on options. +// When opts.Skills is empty, all skills are removed (same as UninstallSkills). +// When opts.Skills is non-empty, only the named skills are removed. +func UninstallSkillsOpts(ctx context.Context, opts UninstallOptions) error { globalDir, err := GlobalSkillsDir(ctx) if err != nil { return err @@ -32,35 +44,52 @@ func UninstallSkills(ctx context.Context) error { return errors.New("no skills installed") } - skillCount := len(state.Skills) + // Determine which skills to remove. + var toRemove []string + if len(opts.Skills) > 0 { + for _, name := range opts.Skills { + if _, ok := state.Skills[name]; !ok { + return fmt.Errorf("skill %q is not installed", name) + } + toRemove = append(toRemove, name) + } + } else { + for name := range state.Skills { + toRemove = append(toRemove, name) + } + } + + removeAll := len(toRemove) == len(state.Skills) - // Remove skill directories and symlinks for each skill in state. - for name := range state.Skills { + // Remove skill directories and symlinks for each skill. + for _, name := range toRemove { canonicalDir := filepath.Join(globalDir, name) - - // Remove symlinks from agent directories (only symlinks pointing to canonical dir). removeSymlinksFromAgents(ctx, name, canonicalDir) - - // Remove canonical skill directory. if err := os.RemoveAll(canonicalDir); err != nil { log.Warnf(ctx, "Failed to remove %s: %v", canonicalDir, err) } + delete(state.Skills, name) } - // Clean up orphaned symlinks pointing into the canonical dir. - cleanOrphanedSymlinks(ctx, globalDir) - - // Delete state file. - stateFile := filepath.Join(globalDir, stateFileName) - if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove state file: %w", err) + if removeAll { + // Clean up orphaned symlinks and delete state file. + cleanOrphanedSymlinks(ctx, globalDir) + stateFile := filepath.Join(globalDir, stateFileName) + if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove state file: %w", err) + } + } else { + // Update state to reflect remaining skills. + if err := SaveState(globalDir, state); err != nil { + return err + } } noun := "skills" - if skillCount == 1 { + if len(toRemove) == 1 { noun = "skill" } - cmdio.LogString(ctx, fmt.Sprintf("Uninstalled %d %s.", skillCount, noun)) + cmdio.LogString(ctx, fmt.Sprintf("Uninstalled %d %s.", len(toRemove), noun)) return nil } diff --git a/experimental/aitools/lib/installer/uninstall_test.go b/experimental/aitools/lib/installer/uninstall_test.go index 8b9e09c360..fe52daf37a 100644 --- a/experimental/aitools/lib/installer/uninstall_test.go +++ b/experimental/aitools/lib/installer/uninstall_test.go @@ -255,3 +255,52 @@ func TestUninstallLeavesNonSymlinkDirectories(t *testing.T) { assert.NoError(t, err, "regular directory should not be removed") assert.Contains(t, stderr.String(), "Uninstalled 1 skill.") } + +func TestUninstallSelectiveRemovesOnlyNamedSkills(t *testing.T) { + tmp := setupTestHome(t) + globalDir := installTestSkills(t, tmp) + + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + err := UninstallSkillsOpts(ctx, UninstallOptions{Skills: []string{"databricks-sql"}}) + require.NoError(t, err) + + // databricks-sql should be gone. + _, err = os.Stat(filepath.Join(globalDir, "databricks-sql")) + assert.True(t, os.IsNotExist(err)) + + // databricks-jobs should still exist. + _, err = os.Stat(filepath.Join(globalDir, "databricks-jobs")) + assert.NoError(t, err) + + // State should still exist with remaining skill. + state, err := LoadState(globalDir) + require.NoError(t, err) + require.NotNil(t, state) + assert.Len(t, state.Skills, 1) + assert.Contains(t, state.Skills, "databricks-jobs") + + assert.Contains(t, stderr.String(), "Uninstalled 1 skill.") +} + +func TestUninstallSelectiveUnknownSkillErrors(t *testing.T) { + tmp := setupTestHome(t) + installTestSkills(t, tmp) + + ctx := cmdio.MockDiscard(t.Context()) + err := UninstallSkillsOpts(ctx, UninstallOptions{Skills: []string{"nonexistent"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not installed") +} + +func TestUninstallSelectiveAllRemovesStateFile(t *testing.T) { + tmp := setupTestHome(t) + globalDir := installTestSkills(t, tmp) + + ctx, _ := cmdio.NewTestContextWithStderr(t.Context()) + err := UninstallSkillsOpts(ctx, UninstallOptions{Skills: []string{"databricks-sql", "databricks-jobs"}}) + require.NoError(t, err) + + // State file should be gone since all skills were removed. + _, err = os.Stat(filepath.Join(globalDir, ".state.json")) + assert.True(t, os.IsNotExist(err)) +}