diff --git a/.agent/skills/pr-checklist/SKILL.md b/.agent/skills/pr-checklist/SKILL.md index 3eae258d6e7..349df461ea8 100644 --- a/.agent/skills/pr-checklist/SKILL.md +++ b/.agent/skills/pr-checklist/SKILL.md @@ -22,8 +22,8 @@ Before submitting a PR, run these commands to match what CI checks. CI uses the # 5. If you changed files in python/: ./task pydabs-codegen pydabs-test pydabs-lint pydabs-docs -# 6. If you changed experimental/aitools or experimental/ssh: -./task test-exp-aitools # only if aitools code changed +# 6. If you changed aitools/, experimental/aitools/, or experimental/ssh/: +./task test-exp-aitools # only if aitools code changed (top-level or experimental) ./task test-exp-ssh # only if ssh code changed ``` diff --git a/.github/OWNERS b/.github/OWNERS index 7cae525465a..d21de6c009b 100644 --- a/.github/OWNERS +++ b/.github/OWNERS @@ -59,5 +59,6 @@ # Internal /internal/ team:platform -# Experimental +# AI tools +/aitools/ team:eng-apps-devex @lennartkats-db /experimental/aitools/ team:eng-apps-devex @lennartkats-db diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 00152d550ea..5f7601a57cc 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -4,6 +4,8 @@ ### CLI +* Promote the aitools skills-management surface (`install`, `update`, `uninstall`, `list`, `version`) from `databricks experimental aitools` to top-level `databricks aitools`. The old paths under `databricks experimental aitools` continue to work as silent backward-compat aliases. The `tools` subtree (`query`, `discover-schema`, `get-default-warehouse`, `statement …`) and the `skills` alias group remain under `databricks experimental aitools`. + ### Bundles ### Dependency updates diff --git a/Taskfile.yml b/Taskfile.yml index 912b3f666a3..11ebe3261ee 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -606,8 +606,9 @@ tasks: # generic `test` target (the catch-all) instead. test-exp-aitools: - desc: Run experimental aitools unit and acceptance tests + desc: Run aitools (top-level + experimental) unit and acceptance tests sources: + - aitools/** - experimental/aitools/** - acceptance/apps/** - "{{.EMBED_SOURCES}}" @@ -616,7 +617,7 @@ tasks: {{.GO_TOOL}} gotestsum \ --format ${GOTESTSUM_FORMAT:-pkgname-and-test-fails} \ --no-summary=skipped \ - --packages ./experimental/aitools/... \ + --packages "./aitools/... ./experimental/aitools/..." \ -- -timeout=${LOCAL_TIMEOUT:-30m} - | {{.GO_TOOL}} gotestsum \ diff --git a/acceptance/help/output.txt b/acceptance/help/output.txt index d9f379f5bbf..159eca916b3 100644 --- a/acceptance/help/output.txt +++ b/acceptance/help/output.txt @@ -167,6 +167,7 @@ Developer Tools Additional Commands: account Databricks Account Commands + aitools Databricks AI Tools for coding agents api Perform Databricks API call auth Authentication related commands cache Local cache related commands diff --git a/aitools/README.md b/aitools/README.md new file mode 100644 index 00000000000..7ac633ebbfb --- /dev/null +++ b/aitools/README.md @@ -0,0 +1,15 @@ +# Databricks AI Tools + +`databricks aitools` installs and manages Databricks skills for detected coding agents. + +## Commands + +- `databricks aitools install [skill-name]` (or `--skills [,...]` for multiple) +- `databricks aitools update` +- `databricks aitools uninstall` +- `databricks aitools list` +- `databricks aitools version` + +Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity. + +The `tools` subtree (`query`, `discover-schema`, `get-default-warehouse`, `statement …`) and the `skills` alias group remain under `databricks experimental aitools` while their stability story is still in flux. diff --git a/aitools/cmd/aitools.go b/aitools/cmd/aitools.go new file mode 100644 index 00000000000..22fe0ad94fd --- /dev/null +++ b/aitools/cmd/aitools.go @@ -0,0 +1,25 @@ +package aitools + +import ( + "github.com/spf13/cobra" +) + +func NewAitoolsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "aitools", + Short: "Databricks AI Tools for coding agents", + Long: `Manage Databricks AI Tools. + +Provides commands to install, update, and manage Databricks skills for +detected coding agents (Claude Code, Cursor, Codex CLI, OpenCode, GitHub +Copilot, Antigravity).`, + } + + cmd.AddCommand(NewInstallCmd()) + cmd.AddCommand(NewUpdateCmd()) + cmd.AddCommand(NewUninstallCmd()) + cmd.AddCommand(NewListCmd()) + cmd.AddCommand(NewVersionCmd()) + + return cmd +} diff --git a/experimental/aitools/cmd/flags.go b/aitools/cmd/flags.go similarity index 100% rename from experimental/aitools/cmd/flags.go rename to aitools/cmd/flags.go diff --git a/experimental/aitools/cmd/flags_test.go b/aitools/cmd/flags_test.go similarity index 100% rename from experimental/aitools/cmd/flags_test.go rename to aitools/cmd/flags_test.go diff --git a/experimental/aitools/cmd/install.go b/aitools/cmd/install.go similarity index 69% rename from experimental/aitools/cmd/install.go rename to aitools/cmd/install.go index b6e87d68b1e..8380264b87e 100644 --- a/experimental/aitools/cmd/install.go +++ b/aitools/cmd/install.go @@ -6,20 +6,59 @@ import ( "fmt" "strings" - "github.com/databricks/cli/experimental/aitools/lib/agents" - "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/charmbracelet/huh" + "github.com/databricks/cli/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" "github.com/fatih/color" "github.com/spf13/cobra" ) -func newInstallCmd() *cobra.Command { +// PromptAgentSelection and InstallSkillsForAgentsFn are package-level for +// testability. They are exported so wrappers in other packages +// (experimental/aitools/cmd/skills.go) can override them in tests. +var ( + PromptAgentSelection = defaultPromptAgentSelection + InstallSkillsForAgentsFn = installer.InstallSkillsForAgents +) + +func defaultPromptAgentSelection(ctx context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { + options := make([]huh.Option[string], 0, len(detected)) + agentsByName := make(map[string]*agents.Agent, len(detected)) + for _, a := range detected { + options = append(options, huh.NewOption(a.DisplayName, a.Name).Selected(true)) + agentsByName[a.Name] = a + } + + var selected []string + err := huh.NewMultiSelect[string](). + Title("Select coding agents to install skills for"). + Description("space to toggle, enter to confirm"). + Options(options...). + Value(&selected). + Run() + if err != nil { + return nil, err + } + + if len(selected) == 0 { + return nil, errors.New("at least one agent must be selected") + } + + result := make([]*agents.Agent, 0, len(selected)) + for _, name := range selected { + result = append(result, agentsByName[name]) + } + return result, nil +} + +func NewInstallCmd() *cobra.Command { var skillsFlag, agentsFlag string var includeExperimental bool var projectFlag, globalFlag bool cmd := &cobra.Command{ - Use: "install", + Use: "install [skill-name]", Short: "Install AI skills for coding agents", Long: `Install Databricks AI skills for detected coding agents. @@ -28,11 +67,21 @@ 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. +Pass a single skill name as a positional argument to install just that skill, +or use --skills name1,name2 for multiple. The two forms are mutually exclusive. + Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity`, - Args: cobra.NoArgs, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + if len(args) == 1 { + if skillsFlag != "" { + return errors.New("cannot use positional [skill-name] together with --skills; pick one") + } + skillsFlag = args[0] + } + // Resolve scope. scope, err := resolveScopeWithPrompt(ctx, projectFlag, globalFlag) if err != nil { @@ -65,7 +114,7 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti case len(detected) == 1: targetAgents = detected case cmdio.IsPromptSupported(ctx): - targetAgents, err = promptAgentSelection(ctx, detected) + targetAgents, err = PromptAgentSelection(ctx, detected) if err != nil { return err } @@ -84,7 +133,7 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti installer.PrintInstallingFor(ctx, targetAgents) src := &installer.GitHubManifestSource{} - return installSkillsForAgentsFn(ctx, src, targetAgents, opts) + return InstallSkillsForAgentsFn(ctx, src, targetAgents, opts) }, } diff --git a/experimental/aitools/cmd/install_test.go b/aitools/cmd/install_test.go similarity index 76% rename from experimental/aitools/cmd/install_test.go rename to aitools/cmd/install_test.go index 38639705ea4..867f7f4ea0b 100644 --- a/experimental/aitools/cmd/install_test.go +++ b/aitools/cmd/install_test.go @@ -7,8 +7,8 @@ import ( "path/filepath" "testing" - "github.com/databricks/cli/experimental/aitools/lib/agents" - "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,11 +16,11 @@ import ( func setupInstallMock(t *testing.T) *[]installCall { t.Helper() - orig := installSkillsForAgentsFn - t.Cleanup(func() { installSkillsForAgentsFn = orig }) + orig := InstallSkillsForAgentsFn + t.Cleanup(func() { InstallSkillsForAgentsFn = orig }) var calls []installCall - installSkillsForAgentsFn = func(_ context.Context, _ installer.ManifestSource, targetAgents []*agents.Agent, opts installer.InstallOptions) error { + InstallSkillsForAgentsFn = func(_ context.Context, _ installer.ManifestSource, targetAgents []*agents.Agent, opts installer.InstallOptions) error { names := make([]string, len(targetAgents)) for i, a := range targetAgents { names[i] = a.Name @@ -64,7 +64,7 @@ func TestInstallAllSkillsForAllAgents(t *testing.T) { calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) err := cmd.RunE(cmd, nil) @@ -80,7 +80,7 @@ func TestInstallSpecificSkills(t *testing.T) { calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--skills", "databricks,databricks-apps"}) @@ -96,7 +96,7 @@ func TestInstallSingleSkill(t *testing.T) { calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--skills", "databricks"}) @@ -112,7 +112,7 @@ func TestInstallSpecificAgents(t *testing.T) { calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--agents", "claude-code"}) @@ -128,7 +128,7 @@ func TestInstallUnknownAgentErrors(t *testing.T) { setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--agents", "invalid-agent"}) cmd.SilenceErrors = true @@ -145,7 +145,7 @@ func TestInstallIncludeExperimental(t *testing.T) { calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--experimental"}) @@ -161,11 +161,11 @@ func TestInstallInteractivePrompt(t *testing.T) { calls := setupInstallMock(t) setupScopeMock(t, installer.ScopeGlobal) - origPrompt := promptAgentSelection - t.Cleanup(func() { promptAgentSelection = origPrompt }) + origPrompt := PromptAgentSelection + t.Cleanup(func() { PromptAgentSelection = origPrompt }) promptCalled := false - promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { + PromptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { promptCalled = true return detected[:1], nil } @@ -185,7 +185,7 @@ func TestInstallInteractivePrompt(t *testing.T) { go drain(test.Stdout) go drain(test.Stderr) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) err := cmd.RunE(cmd, nil) @@ -200,17 +200,17 @@ func TestInstallNonInteractiveUsesAllAgents(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - origPrompt := promptAgentSelection - t.Cleanup(func() { promptAgentSelection = origPrompt }) + origPrompt := PromptAgentSelection + t.Cleanup(func() { PromptAgentSelection = origPrompt }) promptCalled := false - promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { + PromptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { promptCalled = true return detected, nil } ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) err := cmd.RunE(cmd, nil) @@ -228,7 +228,7 @@ func TestInstallNoAgentsDetected(t *testing.T) { calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) err := cmd.RunE(cmd, nil) @@ -240,17 +240,17 @@ func TestInstallAgentsFlagSkipsPrompt(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - origPrompt := promptAgentSelection - t.Cleanup(func() { promptAgentSelection = origPrompt }) + origPrompt := PromptAgentSelection + t.Cleanup(func() { PromptAgentSelection = origPrompt }) promptCalled := false - promptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { + PromptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { promptCalled = true return detected, nil } ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--agents", "claude-code,cursor"}) @@ -262,114 +262,50 @@ func TestInstallAgentsFlagSkipsPrompt(t *testing.T) { assert.Equal(t, []string{"claude-code", "cursor"}, (*calls)[0].agents) } -func TestSkillsInstallDelegatesToInstall(t *testing.T) { +func TestInstallPositionalSkillName(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newSkillsInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) + cmd.SetArgs([]string{"databricks-jobs"}) - 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.NoError(t, cmd.Execute()) require.Len(t, *calls, 1) - assert.Equal(t, []string{"databricks"}, (*calls)[0].opts.SpecificSkills) + assert.Equal(t, []string{"databricks-jobs"}, (*calls)[0].opts.SpecificSkills) } -func TestSkillsInstallForwardsExperimental(t *testing.T) { +func TestInstallPositionalAndSkillsFlagConflict(t *testing.T) { setupTestAgents(t) - calls := setupInstallMock(t) - - ctx := cmdio.MockDiscard(t.Context()) - cmd := newSkillsInstallCmd() - 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, "--experimental should be forwarded") -} + setupInstallMock(t) -func TestSkillsInstallExecuteRejectsTwoArgs(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) - cmd := newSkillsInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) - cmd.SetArgs([]string{"a", "b"}) + cmd.SetArgs([]string{"databricks-jobs", "--skills", "databricks-pipelines"}) cmd.SilenceErrors = true cmd.SilenceUsage = true err := cmd.Execute() require.Error(t, err) - assert.Contains(t, err.Error(), "accepts at most 1 arg") + assert.Contains(t, err.Error(), "cannot use positional") } -func TestInstallRejectsPositionalArgs(t *testing.T) { +func TestInstallRejectsTwoPositionalArgs(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) - cmd.SetArgs([]string{"databricks"}) + cmd.SetArgs([]string{"databricks-jobs", "databricks-pipelines"}) cmd.SilenceErrors = true cmd.SilenceUsage = true - err := cmd.Execute() - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown command") + require.Error(t, cmd.Execute()) } func TestUpdateRejectsPositionalArgs(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) - cmd := newUpdateCmd() + cmd := NewUpdateCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"databricks"}) cmd.SilenceErrors = true @@ -382,7 +318,7 @@ func TestUpdateRejectsPositionalArgs(t *testing.T) { func TestUninstallRejectsPositionalArgs(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) - cmd := newUninstallCmd() + cmd := NewUninstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"databricks"}) cmd.SilenceErrors = true @@ -395,7 +331,7 @@ func TestUninstallRejectsPositionalArgs(t *testing.T) { func TestListRejectsPositionalArgs(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) - cmd := newListCmd() + cmd := NewListCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"databricks"}) cmd.SilenceErrors = true @@ -408,7 +344,7 @@ func TestListRejectsPositionalArgs(t *testing.T) { func TestVersionRejectsPositionalArgs(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) - cmd := newVersionCmd() + cmd := NewVersionCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"databricks"}) cmd.SilenceErrors = true @@ -458,7 +394,7 @@ func TestInstallProjectFlag(t *testing.T) { calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--project"}) @@ -474,7 +410,7 @@ func TestInstallGlobalFlag(t *testing.T) { calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--global"}) @@ -490,7 +426,7 @@ func TestInstallGlobalAndProjectErrors(t *testing.T) { setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--global", "--project"}) cmd.SilenceErrors = true @@ -506,7 +442,7 @@ func TestInstallNoFlagNonInteractiveUsesGlobal(t *testing.T) { calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) err := cmd.RunE(cmd, nil) @@ -522,9 +458,9 @@ func TestInstallNoFlagInteractiveShowsScopePrompt(t *testing.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) { + origPrompt := PromptAgentSelection + t.Cleanup(func() { PromptAgentSelection = origPrompt }) + PromptAgentSelection = func(_ context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { return detected, nil } @@ -543,7 +479,7 @@ func TestInstallNoFlagInteractiveShowsScopePrompt(t *testing.T) { go drain(test.Stdout) go drain(test.Stderr) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) err := cmd.RunE(cmd, nil) diff --git a/experimental/aitools/cmd/list.go b/aitools/cmd/list.go similarity index 95% rename from experimental/aitools/cmd/list.go rename to aitools/cmd/list.go index 1be1538c9a0..2d236c688dc 100644 --- a/experimental/aitools/cmd/list.go +++ b/aitools/cmd/list.go @@ -8,17 +8,17 @@ import ( "strings" "text/tabwriter" - "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/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. +// 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 +var ListSkillsFn = defaultListSkills -func newListCmd() *cobra.Command { +func NewListCmd() *cobra.Command { var projectFlag, globalFlag bool cmd := &cobra.Command{ @@ -36,7 +36,7 @@ func newListCmd() *cobra.Command { } else if globalFlag { scope = installer.ScopeGlobal } - return listSkillsFn(cmd, scope) + return ListSkillsFn(cmd, scope) }, } diff --git a/experimental/aitools/cmd/list_test.go b/aitools/cmd/list_test.go similarity index 55% rename from experimental/aitools/cmd/list_test.go rename to aitools/cmd/list_test.go index 31390110c01..f564d4c8bce 100644 --- a/experimental/aitools/cmd/list_test.go +++ b/aitools/cmd/list_test.go @@ -10,22 +10,22 @@ import ( ) func TestListCommandExists(t *testing.T) { - cmd := newListCmd() + cmd := NewListCmd() assert.Equal(t, "list", cmd.Use) } func TestListCommandCallsListFn(t *testing.T) { - orig := listSkillsFn - t.Cleanup(func() { listSkillsFn = orig }) + orig := ListSkillsFn + t.Cleanup(func() { ListSkillsFn = orig }) called := false - listSkillsFn = func(cmd *cobra.Command, scope string) error { + ListSkillsFn = func(cmd *cobra.Command, scope string) error { called = true return nil } ctx := cmdio.MockDiscard(t.Context()) - cmd := newListCmd() + cmd := NewListCmd() cmd.SetContext(ctx) err := cmd.RunE(cmd, nil) @@ -34,28 +34,9 @@ func TestListCommandCallsListFn(t *testing.T) { } func TestListCommandHasScopeFlags(t *testing.T) { - cmd := newListCmd() + cmd := NewListCmd() f := cmd.Flags().Lookup("project") require.NotNil(t, f, "--project flag should exist") f = cmd.Flags().Lookup("global") require.NotNil(t, f, "--global flag should exist") } - -func TestSkillsListDelegatesToListFn(t *testing.T) { - orig := listSkillsFn - t.Cleanup(func() { listSkillsFn = orig }) - - called := false - listSkillsFn = func(cmd *cobra.Command, scope string) 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/scope.go b/aitools/cmd/scope.go similarity index 97% rename from experimental/aitools/cmd/scope.go rename to aitools/cmd/scope.go index 8c6ce0f0130..acd012135a3 100644 --- a/experimental/aitools/cmd/scope.go +++ b/aitools/cmd/scope.go @@ -8,7 +8,7 @@ import ( "path/filepath" "github.com/charmbracelet/huh" - "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/env" ) @@ -230,10 +230,10 @@ func scopeNotInstalledError(scope, verb, projectDir string, hasGlobal, hasProjec "no project-scoped skills found in the current directory.\n\n"+ "Project-scoped skills are detected based on your working directory.\n"+ "Make sure you are in the project root where you originally ran\n"+ - "'databricks experimental aitools install --project'.\n\n"+ + "'databricks aitools install --project'.\n\n"+ "Expected location: %s/", expectedPath) } else { - msg = "no globally-scoped skills installed. Run 'databricks experimental aitools install --global' to install" + msg = "no globally-scoped skills installed. Run 'databricks aitools install --global' to install" } hint := crossScopeHint(scope, verb, hasGlobal, hasProject) diff --git a/experimental/aitools/cmd/scope_test.go b/aitools/cmd/scope_test.go similarity index 99% rename from experimental/aitools/cmd/scope_test.go rename to aitools/cmd/scope_test.go index ecda25faade..80e5a976a9e 100644 --- a/experimental/aitools/cmd/scope_test.go +++ b/aitools/cmd/scope_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/experimental/aitools/cmd/uninstall.go b/aitools/cmd/uninstall.go similarity index 92% rename from experimental/aitools/cmd/uninstall.go rename to aitools/cmd/uninstall.go index 3eda84cfbc9..e450f48b930 100644 --- a/experimental/aitools/cmd/uninstall.go +++ b/aitools/cmd/uninstall.go @@ -1,11 +1,11 @@ package aitools import ( - "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/aitools/lib/installer" "github.com/spf13/cobra" ) -func newUninstallCmd() *cobra.Command { +func NewUninstallCmd() *cobra.Command { var skillsFlag string var projectFlag, globalFlag bool diff --git a/experimental/aitools/cmd/update.go b/aitools/cmd/update.go similarity index 93% rename from experimental/aitools/cmd/update.go rename to aitools/cmd/update.go index c5072d1fb19..127dd0f7748 100644 --- a/experimental/aitools/cmd/update.go +++ b/aitools/cmd/update.go @@ -3,13 +3,13 @@ package aitools import ( "fmt" - "github.com/databricks/cli/experimental/aitools/lib/agents" - "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" "github.com/spf13/cobra" ) -func newUpdateCmd() *cobra.Command { +func NewUpdateCmd() *cobra.Command { var check, force, noNew bool var skillsFlag string var projectFlag, globalFlag bool diff --git a/experimental/aitools/cmd/version.go b/aitools/cmd/version.go similarity index 90% rename from experimental/aitools/cmd/version.go rename to aitools/cmd/version.go index 67c38fec42a..3877eb69377 100644 --- a/experimental/aitools/cmd/version.go +++ b/aitools/cmd/version.go @@ -5,12 +5,12 @@ import ( "fmt" "strings" - "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" "github.com/spf13/cobra" ) -func newVersionCmd() *cobra.Command { +func NewVersionCmd() *cobra.Command { cmd := &cobra.Command{ Use: "version", Short: "Show installed AI skills version", @@ -40,7 +40,7 @@ func newVersionCmd() *cobra.Command { if globalState == nil && projectState == nil { cmdio.LogString(ctx, "No Databricks AI Tools components installed.") cmdio.LogString(ctx, "") - cmdio.LogString(ctx, "Run 'databricks experimental aitools install' to get started.") + cmdio.LogString(ctx, "Run 'databricks aitools install' to get started.") return nil } @@ -89,6 +89,6 @@ func printVersionLine(ctx context.Context, label string, state *installer.Instal cmdio.LogString(ctx, " Update available: v"+latestVersion) cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02")) cmdio.LogString(ctx, "") - cmdio.LogString(ctx, "Run 'databricks experimental aitools update' to update.") + cmdio.LogString(ctx, "Run 'databricks aitools update' to update.") } } diff --git a/experimental/aitools/cmd/version_test.go b/aitools/cmd/version_test.go similarity index 96% rename from experimental/aitools/cmd/version_test.go rename to aitools/cmd/version_test.go index d24f7e99f81..f1980465e39 100644 --- a/experimental/aitools/cmd/version_test.go +++ b/aitools/cmd/version_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/databricks/cli/experimental/aitools/lib/installer" + "github.com/databricks/cli/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -51,7 +51,7 @@ func TestVersionShowsBothScopes(t *testing.T) { require.NoError(t, installer.SaveState(projectSkillsDir, projectState)) ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) - cmd := newVersionCmd() + cmd := NewVersionCmd() cmd.SetContext(ctx) err := cmd.RunE(cmd, nil) @@ -90,7 +90,7 @@ func TestVersionShowsSingleScopeWithoutQualifier(t *testing.T) { t.Chdir(projectDir) ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) - cmd := newVersionCmd() + cmd := NewVersionCmd() cmd.SetContext(ctx) err := cmd.RunE(cmd, nil) diff --git a/experimental/aitools/lib/agents/agents.go b/aitools/lib/agents/agents.go similarity index 100% rename from experimental/aitools/lib/agents/agents.go rename to aitools/lib/agents/agents.go diff --git a/experimental/aitools/lib/agents/recommend.go b/aitools/lib/agents/recommend.go similarity index 90% rename from experimental/aitools/lib/agents/recommend.go rename to aitools/lib/agents/recommend.go index bf10c67bfd9..de906752316 100644 --- a/experimental/aitools/lib/agents/recommend.go +++ b/aitools/lib/agents/recommend.go @@ -15,7 +15,7 @@ func RecommendSkillsInstall(ctx context.Context, installFn func(context.Context) } if !cmdio.IsPromptSupported(ctx) { - cmdio.LogString(ctx, "Tip: coding agents detected without Databricks skills. Run 'databricks experimental aitools skills install' to install them.") + cmdio.LogString(ctx, "Tip: coding agents detected without Databricks skills. Run 'databricks aitools install' to install them.") return nil } diff --git a/experimental/aitools/lib/agents/recommend_test.go b/aitools/lib/agents/recommend_test.go similarity index 96% rename from experimental/aitools/lib/agents/recommend_test.go rename to aitools/lib/agents/recommend_test.go index c2c27699212..247a91da80b 100644 --- a/experimental/aitools/lib/agents/recommend_test.go +++ b/aitools/lib/agents/recommend_test.go @@ -67,7 +67,7 @@ func TestRecommendSkillsInstallNonInteractive(t *testing.T) { ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) err := RecommendSkillsInstall(ctx, noopInstall) require.NoError(t, err) - assert.Contains(t, stderr.String(), "databricks experimental aitools skills install") + assert.Contains(t, stderr.String(), "databricks aitools install") } func TestRecommendSkillsInstallInteractiveDecline(t *testing.T) { diff --git a/experimental/aitools/lib/agents/skills.go b/aitools/lib/agents/skills.go similarity index 100% rename from experimental/aitools/lib/agents/skills.go rename to aitools/lib/agents/skills.go diff --git a/experimental/aitools/lib/agents/skills_test.go b/aitools/lib/agents/skills_test.go similarity index 100% rename from experimental/aitools/lib/agents/skills_test.go rename to aitools/lib/agents/skills_test.go diff --git a/experimental/aitools/lib/installer/SKILLS_VERSION b/aitools/lib/installer/SKILLS_VERSION similarity index 100% rename from experimental/aitools/lib/installer/SKILLS_VERSION rename to aitools/lib/installer/SKILLS_VERSION diff --git a/experimental/aitools/lib/installer/installer.go b/aitools/lib/installer/installer.go similarity index 98% rename from experimental/aitools/lib/installer/installer.go rename to aitools/lib/installer/installer.go index 982df0c1631..5db3b151c7d 100644 --- a/experimental/aitools/lib/installer/installer.go +++ b/aitools/lib/installer/installer.go @@ -14,7 +14,7 @@ import ( "strings" "time" - "github.com/databricks/cli/experimental/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/agents" "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/env" @@ -135,7 +135,7 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent if state == nil && scope == ScopeGlobal { isLegacy := checkLegacyInstall(ctx, baseDir) if isLegacy && len(opts.SpecificSkills) > 0 { - return errors.New("legacy install detected without state tracking; run 'databricks experimental aitools install' (without a skill name) first to rebuild state") + return errors.New("legacy install detected without state tracking; run 'databricks aitools install' (without a skill name) first to rebuild state") } } @@ -314,7 +314,7 @@ func printNoAgentsDetected(ctx context.Context) { // Returns true if a legacy install was detected. func checkLegacyInstall(ctx context.Context, globalDir string) bool { if hasSkillsOnDisk(globalDir) { - cmdio.LogString(ctx, "Found skills installed before state tracking was added. Run 'databricks experimental aitools install' to refresh.") + cmdio.LogString(ctx, "Found skills installed before state tracking was added. Run 'databricks aitools install' to refresh.") return true } homeDir, err := env.UserHomeDir(ctx) @@ -323,7 +323,7 @@ func checkLegacyInstall(ctx context.Context, globalDir string) bool { } legacyDir := filepath.Join(homeDir, ".databricks", "agent-skills") if hasSkillsOnDisk(legacyDir) { - cmdio.LogString(ctx, "Found skills installed before state tracking was added. Run 'databricks experimental aitools install' to refresh.") + cmdio.LogString(ctx, "Found skills installed before state tracking was added. Run 'databricks aitools install' to refresh.") return true } return false diff --git a/experimental/aitools/lib/installer/installer_test.go b/aitools/lib/installer/installer_test.go similarity index 99% rename from experimental/aitools/lib/installer/installer_test.go rename to aitools/lib/installer/installer_test.go index b769143906d..8bd926637e2 100644 --- a/experimental/aitools/lib/installer/installer_test.go +++ b/aitools/lib/installer/installer_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/databricks/cli/experimental/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/agents" "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" diff --git a/experimental/aitools/lib/installer/source.go b/aitools/lib/installer/source.go similarity index 100% rename from experimental/aitools/lib/installer/source.go rename to aitools/lib/installer/source.go diff --git a/experimental/aitools/lib/installer/state.go b/aitools/lib/installer/state.go similarity index 100% rename from experimental/aitools/lib/installer/state.go rename to aitools/lib/installer/state.go diff --git a/experimental/aitools/lib/installer/state_test.go b/aitools/lib/installer/state_test.go similarity index 100% rename from experimental/aitools/lib/installer/state_test.go rename to aitools/lib/installer/state_test.go diff --git a/experimental/aitools/lib/installer/uninstall.go b/aitools/lib/installer/uninstall.go similarity index 97% rename from experimental/aitools/lib/installer/uninstall.go rename to aitools/lib/installer/uninstall.go index 1ad9f58511c..92c69f7ec91 100644 --- a/experimental/aitools/lib/installer/uninstall.go +++ b/aitools/lib/installer/uninstall.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "github.com/databricks/cli/experimental/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/agents" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" ) @@ -54,7 +54,7 @@ func UninstallSkillsOpts(ctx context.Context, opts UninstallOptions) error { if state == nil { if scope == ScopeGlobal && hasLegacyInstall(ctx, baseDir) { - return errors.New("found skills from a previous install without state tracking; run 'databricks experimental aitools install' first, then uninstall") + return errors.New("found skills from a previous install without state tracking; run 'databricks aitools install' first, then uninstall") } return errors.New("no skills installed") } diff --git a/experimental/aitools/lib/installer/uninstall_test.go b/aitools/lib/installer/uninstall_test.go similarity index 99% rename from experimental/aitools/lib/installer/uninstall_test.go rename to aitools/lib/installer/uninstall_test.go index 6c7589f6f29..cf7ada3a9a7 100644 --- a/experimental/aitools/lib/installer/uninstall_test.go +++ b/aitools/lib/installer/uninstall_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/databricks/cli/experimental/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/agents" "github.com/databricks/cli/libs/cmdio" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/experimental/aitools/lib/installer/update.go b/aitools/lib/installer/update.go similarity index 96% rename from experimental/aitools/lib/installer/update.go rename to aitools/lib/installer/update.go index 663ad5e908e..87b62e76ed5 100644 --- a/experimental/aitools/lib/installer/update.go +++ b/aitools/lib/installer/update.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/databricks/cli/experimental/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/agents" "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/env" @@ -76,9 +76,9 @@ func UpdateSkills(ctx context.Context, src ManifestSource, targetAgents []*agent if state == nil { if scope == ScopeGlobal && hasLegacyInstall(ctx, baseDir) { - return nil, errors.New("found skills from a previous install without state tracking; run 'databricks experimental aitools install' to refresh before updating") + return nil, errors.New("found skills from a previous install without state tracking; run 'databricks aitools install' to refresh before updating") } - return nil, errors.New("no skills installed. Run 'databricks experimental aitools install' to install") + return nil, errors.New("no skills installed. Run 'databricks aitools install' to install") } latestTag := GetSkillsRef(ctx) diff --git a/experimental/aitools/lib/installer/update_test.go b/aitools/lib/installer/update_test.go similarity index 98% rename from experimental/aitools/lib/installer/update_test.go rename to aitools/lib/installer/update_test.go index 97e3014be65..4339ddead44 100644 --- a/experimental/aitools/lib/installer/update_test.go +++ b/aitools/lib/installer/update_test.go @@ -8,7 +8,7 @@ import ( "path/filepath" "testing" - "github.com/databricks/cli/experimental/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/agents" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" "github.com/stretchr/testify/assert" @@ -24,7 +24,7 @@ func TestUpdateNoStateReturnsInstallHint(t *testing.T) { _, err := UpdateSkills(ctx, src, nil, UpdateOptions{}) require.Error(t, err) assert.Contains(t, err.Error(), "no skills installed") - assert.Contains(t, err.Error(), "databricks experimental aitools install") + assert.Contains(t, err.Error(), "databricks aitools install") } func TestUpdateLegacyInstallDetected(t *testing.T) { diff --git a/experimental/aitools/lib/installer/version.go b/aitools/lib/installer/version.go similarity index 100% rename from experimental/aitools/lib/installer/version.go rename to aitools/lib/installer/version.go diff --git a/cmd/apps/init.go b/cmd/apps/init.go index 6f7269e6fd0..09e0d9be89a 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -16,9 +16,9 @@ import ( "text/template" "github.com/charmbracelet/huh" + "github.com/databricks/cli/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/installer" "github.com/databricks/cli/cmd/root" - "github.com/databricks/cli/experimental/aitools/lib/agents" - "github.com/databricks/cli/experimental/aitools/lib/installer" "github.com/databricks/cli/libs/apps/generator" "github.com/databricks/cli/libs/apps/initializer" "github.com/databricks/cli/libs/apps/manifest" @@ -1146,7 +1146,7 @@ func runCreate(ctx context.Context, opts createOptions) error { // In flags mode, only print a hint — never prompt interactively. if flagsMode { if !agents.HasDatabricksSkillsInstalled(ctx) { - cmdio.LogString(ctx, "Tip: coding agents detected without Databricks skills. Run 'databricks experimental aitools skills install' to install them.") + cmdio.LogString(ctx, "Tip: coding agents detected without Databricks skills. Run 'databricks aitools install' to install them.") } } else if err := agents.RecommendSkillsInstall(ctx, installer.InstallAllSkills); err != nil { log.Warnf(ctx, "Skills recommendation failed: %v", err) diff --git a/cmd/cmd.go b/cmd/cmd.go index 014471f7638..10209bd6400 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -4,6 +4,7 @@ import ( "context" "strings" + aitoolscmd "github.com/databricks/cli/aitools/cmd" "github.com/databricks/cli/cmd/psql" ssh "github.com/databricks/cli/experimental/ssh/cmd" @@ -93,6 +94,7 @@ func New(ctx context.Context) *cobra.Command { } // Add other subcommands. + cli.AddCommand(aitoolscmd.NewAitoolsCmd()) cli.AddCommand(api.New()) cli.AddCommand(auth.New()) cli.AddCommand(completion.New()) diff --git a/experimental/aitools/README.md b/experimental/aitools/README.md index ec12ed10f7c..4921280f044 100644 --- a/experimental/aitools/README.md +++ b/experimental/aitools/README.md @@ -2,11 +2,12 @@ `databricks experimental aitools` is the remaining experimental surface for coding-agent workflows. +The skills-management commands (`install`, `update`, `uninstall`, `list`, `version`) have been promoted to top-level `databricks aitools`. The old paths under `databricks experimental aitools` keep working as silent backward-compat aliases. + Current commands: - `databricks experimental aitools skills list` - `databricks experimental aitools skills install [skill-name]` -- `databricks experimental aitools install [skill-name]` - `databricks experimental aitools tools query` - `databricks experimental aitools tools discover-schema` - `databricks experimental aitools tools get-default-warehouse` @@ -17,8 +18,7 @@ Current commands: Current behavior: -- `skills install` installs Databricks skills for detected coding agents. -- `install` is a compatibility alias for `skills install`. +- `skills install` installs Databricks skills for detected coding agents (delegates to `databricks aitools install`). - `tools` exposes a small set of AI-oriented workspace helpers. - `tools query` accepts a single SQL or multiple SQLs in one invocation. Pass several positional arguments and/or repeat `--file` to run them in parallel diff --git a/experimental/aitools/cmd/aitools.go b/experimental/aitools/cmd/aitools.go index f037ac1a22e..1168e270d91 100644 --- a/experimental/aitools/cmd/aitools.go +++ b/experimental/aitools/cmd/aitools.go @@ -1,6 +1,7 @@ package aitools import ( + aitoolscmd "github.com/databricks/cli/aitools/cmd" "github.com/spf13/cobra" ) @@ -9,19 +10,25 @@ func NewAitoolsCmd() *cobra.Command { Use: "aitools", Hidden: true, Short: "Databricks AI Tools for coding agents", - Long: `Manage Databricks AI Tools. + Long: `Experimental coding-agent helpers. Skills management is at "databricks aitools".`, + } -Provides commands to: -- Install the AI tools in coding agents (install) -- Manage skills (skills) -- Access tools directly (tools)`, + // Hidden silent backward-compatibility aliases for the skills-management + // commands. They now live at top-level `databricks aitools `; the old + // paths under `databricks experimental aitools ` keep working but are + // hidden so the canonical path is what shows in --help. + for _, mk := range []func() *cobra.Command{ + aitoolscmd.NewInstallCmd, + aitoolscmd.NewUpdateCmd, + aitoolscmd.NewUninstallCmd, + aitoolscmd.NewListCmd, + aitoolscmd.NewVersionCmd, + } { + sub := mk() + sub.Hidden = true + cmd.AddCommand(sub) } - cmd.AddCommand(newInstallCmd()) - cmd.AddCommand(newUpdateCmd()) - cmd.AddCommand(newUninstallCmd()) - cmd.AddCommand(newListCmd()) - cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newSkillsCmd()) cmd.AddCommand(newToolsCmd()) diff --git a/experimental/aitools/cmd/skills.go b/experimental/aitools/cmd/skills.go index 9995ff72a07..a8c6f566ec9 100644 --- a/experimental/aitools/cmd/skills.go +++ b/experimental/aitools/cmd/skills.go @@ -1,51 +1,10 @@ package aitools import ( - "context" - "errors" - - "github.com/charmbracelet/huh" - "github.com/databricks/cli/experimental/aitools/lib/agents" - "github.com/databricks/cli/experimental/aitools/lib/installer" + aitoolscmd "github.com/databricks/cli/aitools/cmd" "github.com/spf13/cobra" ) -// Package-level vars for testability. -var ( - promptAgentSelection = defaultPromptAgentSelection - installSkillsForAgentsFn = installer.InstallSkillsForAgents -) - -func defaultPromptAgentSelection(ctx context.Context, detected []*agents.Agent) ([]*agents.Agent, error) { - options := make([]huh.Option[string], 0, len(detected)) - agentsByName := make(map[string]*agents.Agent, len(detected)) - for _, a := range detected { - options = append(options, huh.NewOption(a.DisplayName, a.Name).Selected(true)) - agentsByName[a.Name] = a - } - - var selected []string - err := huh.NewMultiSelect[string](). - Title("Select coding agents to install skills for"). - Description("space to toggle, enter to confirm"). - Options(options...). - Value(&selected). - Run() - if err != nil { - return nil, err - } - - if len(selected) == 0 { - return nil, errors.New("at least one agent must be selected") - } - - result := make([]*agents.Agent, 0, len(selected)) - for _, name := range selected { - result = append(result, agentsByName[name]) - } - return result, nil -} - func newSkillsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "skills", @@ -54,7 +13,7 @@ func newSkillsCmd() *cobra.Command { Long: `Manage Databricks skills that extend coding agents with Databricks-specific capabilities.`, } - // Subcommands delegate to the flat top-level commands. + // Subcommands delegate cross-package to the canonical top-level commands. cmd.AddCommand(newSkillsListCmd()) cmd.AddCommand(newSkillsInstallCmd()) @@ -67,7 +26,7 @@ func newSkillsListCmd() *cobra.Command { Short: "List available skills", RunE: func(cmd *cobra.Command, args []string) error { // Default to showing all scopes (empty scope = both). - return listSkillsFn(cmd, "") + return aitoolscmd.ListSkillsFn(cmd, "") }, } } @@ -80,8 +39,8 @@ func newSkillsInstallCmd() *cobra.Command { Short: "Install Databricks skills for detected coding agents", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Delegate to the flat install command's logic. - installCmd := newInstallCmd() + // Delegate to the flat top-level install command. + installCmd := aitoolscmd.NewInstallCmd() installCmd.SetContext(cmd.Context()) var delegateArgs []string diff --git a/experimental/aitools/cmd/skills_test.go b/experimental/aitools/cmd/skills_test.go new file mode 100644 index 00000000000..954061d45de --- /dev/null +++ b/experimental/aitools/cmd/skills_test.go @@ -0,0 +1,158 @@ +package aitools + +import ( + "context" + "os" + "path/filepath" + "testing" + + aitoolscmd "github.com/databricks/cli/aitools/cmd" + "github.com/databricks/cli/aitools/lib/agents" + "github.com/databricks/cli/aitools/lib/installer" + "github.com/databricks/cli/libs/cmdio" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type installCall struct { + agents []string + opts installer.InstallOptions +} + +func setupInstallMock(t *testing.T) *[]installCall { + t.Helper() + orig := aitoolscmd.InstallSkillsForAgentsFn + t.Cleanup(func() { aitoolscmd.InstallSkillsForAgentsFn = orig }) + + var calls []installCall + aitoolscmd.InstallSkillsForAgentsFn = func(_ context.Context, _ installer.ManifestSource, targetAgents []*agents.Agent, opts installer.InstallOptions) error { + names := make([]string, len(targetAgents)) + for i, a := range targetAgents { + names[i] = a.Name + } + calls = append(calls, installCall{agents: names, opts: opts}) + return nil + } + return &calls +} + +func setupTestAgents(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".claude"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".cursor"), 0o755)) + return tmp +} + +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 TestSkillsInstallForwardsExperimental(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newSkillsInstallCmd() + 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, "--experimental should be forwarded") +} + +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 TestSkillsListDelegatesToListFn(t *testing.T) { + orig := aitoolscmd.ListSkillsFn + t.Cleanup(func() { aitoolscmd.ListSkillsFn = orig }) + + called := false + aitoolscmd.ListSkillsFn = func(cmd *cobra.Command, scope string) 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) +}