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 b6bdb4d965b..fb170ad3c79 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -4,8 +4,12 @@ ### 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`. The `--project` and `--global` flags on `install`, `update`, `uninstall`, and `list` are deprecated in favor of `--scope=project|global` (with `--scope=both` accepted by `update` and `list`); the booleans keep working with a stderr deprecation warning and will be removed in a later release. +* `databricks api` now works against unified hosts. Adds `--account` to scope a call to the account API and `--workspace-id` to override the workspace routing identifier per call. A `?o=` query parameter on the path (the SPOG URL convention used by the Databricks UI) is also recognized as a per-call workspace override, so URLs pasted from the browser route correctly. +* JSON output for single objects now uses standard `"key": "value"` spacing (matching list output and `encoding/json` defaults). * `databricks auth describe` now reports where U2M (`databricks-cli`) tokens are stored: `plaintext` (`~/.databricks/token-cache.json`) or `secure` (OS keyring), and the source of the choice (env var, config setting, or default). * Marked the default profile in the interactive pickers shown by `databricks auth switch`, `databricks auth logout`, `databricks auth token`, and `databricks auth login`, and moved it to the top of the list. `databricks auth login` and `databricks auth logout` now offer the same selectors as `databricks auth token` and `databricks auth switch` respectively. +* `databricks aitools list` honors `--output json`, emitting a structured `{release, skills[…], summary{}}` document so coding agents and CI can consume the skill/version/installation matrix without scraping the tabular text output. ### Bundles * Stop applying `presets.name_prefix` (and the dev-mode `[dev ]` rename) to `vector_search_endpoints` ([#5209](https://github.com/databricks/cli/pull/5209)). diff --git a/Taskfile.yml b/Taskfile.yml index 38f690bd56a..3bd29b539b5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -609,8 +609,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}}" @@ -619,7 +620,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 64% rename from experimental/aitools/cmd/install.go rename to aitools/cmd/install.go index 8e95e511cf5..fd05a1d7486 100644 --- a/experimental/aitools/cmd/install.go +++ b/aitools/cmd/install.go @@ -6,14 +6,53 @@ 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/spf13/cobra" ) -func newInstallCmd() *cobra.Command { - var skillsFlag, agentsFlag string +// 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, scopeFlag string var includeExperimental bool var projectFlag, globalFlag bool @@ -23,15 +62,30 @@ func newInstallCmd() *cobra.Command { Long: `Install Databricks AI skills for detected coding agents. By default, skills are installed globally to each agent's skills directory. -Use --project to install to the current project directory instead. +Use --scope=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. +Use --skills name1,name2 to install specific skills. + +Agent selection: + --agents [,...] Install only for the named agents. + (unset, interactive) Multi-select prompt over detected agents. + (unset, non-interactive) Install for every detected agent. + +The list of agents the command will act on is always logged to stderr before +the install runs, so callers can verify what was picked. + Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, false) + if err != nil { + return err + } + // Resolve scope. scope, err := resolveScopeWithPrompt(ctx, projectFlag, globalFlag) if err != nil { @@ -64,7 +118,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 } @@ -83,15 +137,17 @@ 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) }, } 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") + cmd.Flags().StringVar(&scopeFlag, "scope", "", "Install scope: project or global (default: global, or prompt when interactive)") cmd.Flags().BoolVar(&projectFlag, "project", false, "Install to project directory (cwd)") cmd.Flags().BoolVar(&globalFlag, "global", false, "Install globally (default)") + markScopeBoolsDeprecated(cmd) return cmd } diff --git a/experimental/aitools/cmd/install_test.go b/aitools/cmd/install_test.go similarity index 75% rename from experimental/aitools/cmd/install_test.go rename to aitools/cmd/install_test.go index 38639705ea4..5dcd4116cfd 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,103 +262,11 @@ func TestInstallAgentsFlagSkipsPrompt(t *testing.T) { 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 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 TestInstallRejectsPositionalArgs(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) - cmd.SetArgs([]string{"databricks"}) + cmd.SetArgs([]string{"databricks-jobs"}) cmd.SilenceErrors = true cmd.SilenceUsage = true @@ -369,7 +277,7 @@ func TestInstallRejectsPositionalArgs(t *testing.T) { 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 +290,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 +303,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 +316,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 +366,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 +382,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 +398,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 @@ -501,12 +409,51 @@ func TestInstallGlobalAndProjectErrors(t *testing.T) { assert.Contains(t, err.Error(), "cannot use --global and --project together") } +func TestInstallScopeFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantScope string + wantErr string + }{ + {name: "scope project", args: []string{"--scope", "project"}, wantScope: installer.ScopeProject}, + {name: "scope global", args: []string{"--scope", "global"}, wantScope: installer.ScopeGlobal}, + {name: "scope both rejected", args: []string{"--scope", "both"}, wantErr: "--scope=both is not supported"}, + {name: "scope invalid value", args: []string{"--scope", "all"}, wantErr: `invalid --scope "all"`}, + {name: "scope conflicts with legacy", args: []string{"--scope", "global", "--project"}, wantErr: "cannot use --scope with --project or --global"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupTestAgents(t) + calls := setupInstallMock(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := NewInstallCmd() + cmd.SetContext(ctx) + cmd.SetArgs(tt.args) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.Len(t, *calls, 1) + assert.Equal(t, tt.wantScope, (*calls)[0].opts.Scope) + }) + } +} + func TestInstallNoFlagNonInteractiveUsesGlobal(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) ctx := cmdio.MockDiscard(t.Context()) - cmd := newInstallCmd() + cmd := NewInstallCmd() cmd.SetContext(ctx) err := cmd.RunE(cmd, nil) @@ -522,9 +469,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 +490,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/aitools/cmd/list.go b/aitools/cmd/list.go new file mode 100644 index 00000000000..83388c6755f --- /dev/null +++ b/aitools/cmd/list.go @@ -0,0 +1,271 @@ +package aitools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "maps" + "slices" + "strings" + "text/tabwriter" + + "github.com/databricks/cli/aitools/lib/installer" + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/flags" + "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 { + var scopeFlag string + var projectFlag, globalFlag bool + + cmd := &cobra.Command{ + Use: "list", + Short: "List installed AI tools components", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, true) + if err != nil { + return err + } + + // list: empty scope = show both. Both flags set is equivalent. + var scope string + switch { + case projectFlag && !globalFlag: + scope = installer.ScopeProject + case globalFlag && !projectFlag: + scope = installer.ScopeGlobal + } + return ListSkillsFn(cmd, scope) + }, + } + + cmd.Flags().StringVar(&scopeFlag, "scope", "", "Scope to show: project, global, or both (default: both)") + cmd.Flags().BoolVar(&projectFlag, "project", false, "Show only project-scoped skills") + cmd.Flags().BoolVar(&globalFlag, "global", false, "Show only globally-scoped skills") + markScopeBoolsDeprecated(cmd) + return cmd +} + +// listOutput is the structured representation of `aitools list` used by both +// text rendering and `--output json` consumers. The JSON shape is part of +// the public CLI contract; do not break field names or types. +type listOutput struct { + Release string `json:"release"` + Skills []skillEntry `json:"skills"` + Summary map[string]scopeSummary `json:"summary"` +} + +type skillEntry struct { + Name string `json:"name"` + LatestVersion string `json:"latest_version"` + Experimental bool `json:"experimental"` + Installed map[string]string `json:"installed"` +} + +type scopeSummary struct { + Installed int `json:"installed"` + Total int `json:"total"` +} + +func defaultListSkills(cmd *cobra.Command, scope string) error { + ctx := cmd.Context() + + out, err := buildListOutput(ctx, scope) + if err != nil { + return err + } + + switch root.OutputType(cmd) { + case flags.OutputJSON: + return renderListJSON(cmd.OutOrStdout(), out) + default: + renderListText(ctx, out, scope) + return nil + } +} + +// buildListOutput fetches the manifest and per-scope install state and +// returns the structured listOutput. scope=="" loads both scopes; "global" +// or "project" loads only that scope. +func buildListOutput(ctx context.Context, scope string) (listOutput, error) { + ref := installer.GetSkillsRef(ctx) + + src := &installer.GitHubManifestSource{} + manifest, err := src.FetchManifest(ctx, ref) + if err != nil { + return listOutput{}, fmt.Errorf("failed to fetch manifest: %w", err) + } + + globalState := loadStateForScope(ctx, scope, installer.ScopeProject, installer.GlobalSkillsDir, "global") + projectState := loadStateForScope(ctx, scope, installer.ScopeGlobal, installer.ProjectSkillsDir, "project") + + names := slices.Sorted(maps.Keys(manifest.Skills)) + + out := listOutput{ + Release: strings.TrimPrefix(ref, "v"), + Skills: make([]skillEntry, 0, len(names)), + Summary: map[string]scopeSummary{}, + } + + globalCount, projectCount := 0, 0 + for _, name := range names { + meta := manifest.Skills[name] + entry := skillEntry{ + Name: name, + LatestVersion: meta.Version, + Experimental: meta.Experimental, + Installed: map[string]string{}, + } + if globalState != nil { + if v, ok := globalState.Skills[name]; ok { + entry.Installed[installer.ScopeGlobal] = v + globalCount++ + } + } + if projectState != nil { + if v, ok := projectState.Skills[name]; ok { + entry.Installed[installer.ScopeProject] = v + projectCount++ + } + } + out.Skills = append(out.Skills, entry) + } + + // Include a summary entry for every scope that was queried, even when the + // install state is missing — agents should see "0/N" rather than guess + // from the absence of a key. + if scope != installer.ScopeProject { + out.Summary[installer.ScopeGlobal] = scopeSummary{Installed: globalCount, Total: len(names)} + } + if scope != installer.ScopeGlobal { + out.Summary[installer.ScopeProject] = scopeSummary{Installed: projectCount, Total: len(names)} + } + + return out, nil +} + +// loadStateForScope returns the install state for the named scope when the +// scope filter allows it. excludeScope is the scope value that means "skip +// loading this one" (so passing ScopeProject to the global loader skips +// global when --scope=project). +func loadStateForScope(ctx context.Context, scopeFilter, excludeScope string, dirFn func(context.Context) (string, error), label string) *installer.InstallState { + if scopeFilter == excludeScope { + return nil + } + dir, err := dirFn(ctx) + if err != nil { + return nil + } + state, err := installer.LoadState(dir) + if err != nil { + log.Debugf(ctx, "Could not load %s install state: %v", label, err) + return nil + } + return state +} + +func renderListJSON(w io.Writer, out listOutput) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +func renderListText(ctx context.Context, out listOutput, scope string) { + cmdio.LogString(ctx, "Available skills (v"+out.Release+"):") + cmdio.LogString(ctx, "") + + bothScopes := scope == "" && len(out.Summary) == 2 && + out.Summary[installer.ScopeGlobal].Installed+out.Summary[installer.ScopeProject].Installed > 0 && + anyInstalled(out, installer.ScopeGlobal) && anyInstalled(out, installer.ScopeProject) + + var buf strings.Builder + tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED") + for _, s := range out.Skills { + tag := "" + if s.Experimental { + tag = " [experimental]" + } + fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", s.Name, tag, s.LatestVersion, installedStatusFromEntry(s, bothScopes)) + } + tw.Flush() + cmdio.LogString(ctx, buf.String()) + + cmdio.LogString(ctx, summaryLine(out, scope)) +} + +// anyInstalled reports whether at least one skill is installed in the named scope. +func anyInstalled(out listOutput, scope string) bool { + for _, s := range out.Skills { + if _, ok := s.Installed[scope]; ok { + return true + } + } + return false +} + +func installedStatusFromEntry(s skillEntry, bothScopes bool) string { + globalVer := s.Installed[installer.ScopeGlobal] + projectVer := s.Installed[installer.ScopeProject] + + if globalVer == "" && projectVer == "" { + return "not installed" + } + + if bothScopes && globalVer != "" && projectVer != "" { + return versionLabel(projectVer, s.LatestVersion) + " (project, global)" + } + + if projectVer != "" { + label := versionLabel(projectVer, s.LatestVersion) + if bothScopes { + return label + " (project)" + } + return label + } + + label := versionLabel(globalVer, s.LatestVersion) + if bothScopes { + return label + " (global)" + } + return label +} + +// versionLabel formats version with update status. +func versionLabel(installed, latest string) string { + if installed == latest { + return "v" + installed + " (up to date)" + } + return "v" + installed + " (update available)" +} + +func summaryLine(out listOutput, scope string) string { + g, gOK := out.Summary[installer.ScopeGlobal] + p, pOK := out.Summary[installer.ScopeProject] + + switch { + case gOK && pOK: + // Mirror prior behavior: only print the dual-scope line when both + // scopes have a state file; otherwise only mention the one that does. + if anyInstalled(out, installer.ScopeGlobal) && anyInstalled(out, installer.ScopeProject) { + return fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", g.Installed, g.Total, p.Installed, p.Total) + } + if anyInstalled(out, installer.ScopeProject) { + return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total) + } + return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total) + case pOK: + return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total) + default: + return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total) + } +} diff --git a/aitools/cmd/list_test.go b/aitools/cmd/list_test.go new file mode 100644 index 00000000000..595998cf3f7 --- /dev/null +++ b/aitools/cmd/list_test.go @@ -0,0 +1,218 @@ +package aitools + +import ( + "bytes" + "encoding/json" + "testing" + + "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" +) + +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, scope string) 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 TestListCommandHasScopeFlags(t *testing.T) { + cmd := NewListCmd() + f := cmd.Flags().Lookup("project") + require.NotNil(t, f, "--project flag should exist (deprecated alias)") + assert.NotEmpty(t, f.Deprecated, "--project should be marked deprecated") + f = cmd.Flags().Lookup("global") + require.NotNil(t, f, "--global flag should exist (deprecated alias)") + assert.NotEmpty(t, f.Deprecated, "--global should be marked deprecated") + f = cmd.Flags().Lookup("scope") + require.NotNil(t, f, "--scope flag should exist") +} + +func TestRenderListJSON(t *testing.T) { + out := listOutput{ + Release: "0.1.0", + Skills: []skillEntry{ + { + Name: "databricks-jobs", + LatestVersion: "1.0.0", + Experimental: false, + Installed: map[string]string{ + installer.ScopeGlobal: "1.0.0", + installer.ScopeProject: "0.9.0", + }, + }, + { + Name: "experimental-thing", + LatestVersion: "0.1.0", + Experimental: true, + Installed: map[string]string{}, + }, + }, + Summary: map[string]scopeSummary{ + installer.ScopeGlobal: {Installed: 1, Total: 2}, + installer.ScopeProject: {Installed: 1, Total: 2}, + }, + } + + var buf bytes.Buffer + require.NoError(t, renderListJSON(&buf, out)) + + var got listOutput + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, out, got) + + var raw map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &raw)) + assert.Contains(t, raw, "release") + assert.Contains(t, raw, "skills") + assert.Contains(t, raw, "summary") + + skills := raw["skills"].([]any) + first := skills[0].(map[string]any) + assert.Equal(t, "databricks-jobs", first["name"]) + assert.Equal(t, "1.0.0", first["latest_version"]) + assert.Equal(t, false, first["experimental"]) + + installed := first["installed"].(map[string]any) + assert.Equal(t, "1.0.0", installed["global"]) + assert.Equal(t, "0.9.0", installed["project"]) + + second := skills[1].(map[string]any) + assert.Equal(t, true, second["experimental"]) + assert.Empty(t, second["installed"]) +} + +func TestRenderListJSONScopeFiltersSummary(t *testing.T) { + out := listOutput{ + Release: "0.1.0", + Skills: []skillEntry{}, + Summary: map[string]scopeSummary{ + installer.ScopeGlobal: {Installed: 0, Total: 5}, + }, + } + + var buf bytes.Buffer + require.NoError(t, renderListJSON(&buf, out)) + + var raw map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &raw)) + summary := raw["summary"].(map[string]any) + assert.Contains(t, summary, "global") + assert.NotContains(t, summary, "project") +} + +func TestInstalledStatusFromEntry(t *testing.T) { + tests := []struct { + name string + entry skillEntry + bothScopes bool + want string + }{ + { + name: "not installed", + entry: skillEntry{LatestVersion: "1.0.0", Installed: map[string]string{}}, + want: "not installed", + }, + { + name: "global up to date", + entry: skillEntry{ + LatestVersion: "1.0.0", + Installed: map[string]string{installer.ScopeGlobal: "1.0.0"}, + }, + want: "v1.0.0 (up to date)", + }, + { + name: "project update available", + entry: skillEntry{ + LatestVersion: "1.0.0", + Installed: map[string]string{installer.ScopeProject: "0.9.0"}, + }, + want: "v0.9.0 (update available)", + }, + { + name: "both scopes installed", + entry: skillEntry{ + LatestVersion: "1.0.0", + Installed: map[string]string{ + installer.ScopeGlobal: "1.0.0", + installer.ScopeProject: "0.9.0", + }, + }, + bothScopes: true, + want: "v0.9.0 (update available) (project, global)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, installedStatusFromEntry(tt.entry, tt.bothScopes)) + }) + } +} + +func TestListScopeFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantScope string + wantErr string + }{ + {name: "scope project", args: []string{"--scope", "project"}, wantScope: installer.ScopeProject}, + {name: "scope global", args: []string{"--scope", "global"}, wantScope: installer.ScopeGlobal}, + {name: "scope both shows both", args: []string{"--scope", "both"}, wantScope: ""}, + {name: "scope invalid", args: []string{"--scope", "all"}, wantErr: `invalid --scope "all"`}, + {name: "legacy both flags shows both", args: []string{"--project", "--global"}, wantScope: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + orig := ListSkillsFn + t.Cleanup(func() { ListSkillsFn = orig }) + + var gotScope string + called := false + ListSkillsFn = func(_ *cobra.Command, scope string) error { + called = true + gotScope = scope + return nil + } + + ctx := cmdio.MockDiscard(t.Context()) + cmd := NewListCmd() + cmd.SetContext(ctx) + cmd.SetArgs(tt.args) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.True(t, called) + assert.Equal(t, tt.wantScope, gotScope) + }) + } +} diff --git a/experimental/aitools/cmd/scope.go b/aitools/cmd/scope.go similarity index 83% rename from experimental/aitools/cmd/scope.go rename to aitools/cmd/scope.go index 8c6ce0f0130..c405c721f42 100644 --- a/experimental/aitools/cmd/scope.go +++ b/aitools/cmd/scope.go @@ -8,9 +8,10 @@ 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" + "github.com/spf13/cobra" ) // promptScopeSelection is a package-level var so tests can replace it with a mock. @@ -82,6 +83,45 @@ func defaultPromptScopeSelection(ctx context.Context) (string, error) { const scopeBoth = "both" +// markScopeBoolsDeprecated hides --project and --global from help and emits a +// stderr warning pointing at --scope when they're used. The booleans are kept +// so existing scripts and the experimental backward-compat aliases keep +// working through the next release. +func markScopeBoolsDeprecated(cmd *cobra.Command) { + cmd.Flags().Lookup("project").Deprecated = "use --scope=project" + cmd.Flags().Lookup("project").Hidden = true + cmd.Flags().Lookup("global").Deprecated = "use --scope=global" + cmd.Flags().Lookup("global").Hidden = true +} + +// parseScopeFlag translates --scope into the equivalent --project/--global bool pair. +// Returns (projectFlag, globalFlag, nil) unchanged when --scope is empty so the +// deprecated booleans can keep flowing through the existing resolveScope* helpers. +// Errors if --scope is combined with --project or --global. When allowBoth is +// false, --scope=both is rejected up front so install and uninstall don't have +// to special-case it. +func parseScopeFlag(scopeFlag string, projectFlag, globalFlag, allowBoth bool) (proj, glob bool, err error) { + if scopeFlag == "" { + return projectFlag, globalFlag, nil + } + if projectFlag || globalFlag { + return false, false, errors.New("cannot use --scope with --project or --global; --project and --global are deprecated aliases for --scope") + } + switch scopeFlag { + case installer.ScopeProject: + return true, false, nil + case installer.ScopeGlobal: + return false, true, nil + case scopeBoth: + if !allowBoth { + return false, false, errors.New("--scope=both is not supported for this command; use 'project' or 'global'") + } + return true, true, nil + default: + return false, false, fmt.Errorf("invalid --scope %q: must be one of project, global, both", scopeFlag) + } +} + // detectInstalledScopes checks which scopes have a .state.json file present. func detectInstalledScopes(globalDir, projectDir string) (global, project bool, err error) { globalState, err := installer.LoadState(globalDir) @@ -230,10 +270,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 92% rename from experimental/aitools/cmd/scope_test.go rename to aitools/cmd/scope_test.go index ecda25faade..4f8c3c5688e 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" @@ -67,6 +67,47 @@ func interactiveCtx(t *testing.T) (context.Context, func()) { return ctx, test.Done } +// --- parseScopeFlag tests --- + +func TestParseScopeFlag(t *testing.T) { + tests := []struct { + name string + scope string + project bool + global bool + allowBoth bool + wantProj bool + wantGlob bool + wantErr string + }{ + {name: "unset", scope: ""}, + {name: "legacy project only", project: true, wantProj: true}, + {name: "legacy global only", global: true, wantGlob: true}, + {name: "legacy both passthrough", project: true, global: true, wantProj: true, wantGlob: true}, + {name: "scope project", scope: "project", wantProj: true}, + {name: "scope global", scope: "global", wantGlob: true}, + {name: "scope both allowed", scope: "both", allowBoth: true, wantProj: true, wantGlob: true}, + {name: "scope both disallowed", scope: "both", wantErr: "--scope=both is not supported"}, + {name: "scope invalid value", scope: "all", wantErr: `invalid --scope "all"`}, + {name: "scope conflicts with project", scope: "project", project: true, wantErr: "cannot use --scope with --project or --global"}, + {name: "scope conflicts with global", scope: "global", global: true, wantErr: "cannot use --scope with --project or --global"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proj, glob, err := parseScopeFlag(tt.scope, tt.project, tt.global, tt.allowBoth) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantProj, proj) + assert.Equal(t, tt.wantGlob, glob) + }) + } +} + // --- detectInstalledScopes tests (table-driven) --- func TestDetectInstalledScopes(t *testing.T) { diff --git a/experimental/aitools/cmd/uninstall.go b/aitools/cmd/uninstall.go similarity index 76% rename from experimental/aitools/cmd/uninstall.go rename to aitools/cmd/uninstall.go index 3eda84cfbc9..741b3c63a3a 100644 --- a/experimental/aitools/cmd/uninstall.go +++ b/aitools/cmd/uninstall.go @@ -1,12 +1,12 @@ 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 { - var skillsFlag string +func NewUninstallCmd() *cobra.Command { + var skillsFlag, scopeFlag string var projectFlag, globalFlag bool cmd := &cobra.Command{ @@ -19,6 +19,11 @@ By default, removes all skills. Use --skills to remove specific skills only.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, false) + if err != nil { + return err + } + globalDir, err := installer.GlobalSkillsDir(ctx) if err != nil { return err @@ -42,7 +47,9 @@ By default, removes all skills. Use --skills to remove specific skills only.`, } cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to uninstall (comma-separated)") + cmd.Flags().StringVar(&scopeFlag, "scope", "", "Uninstall scope: project or global") cmd.Flags().BoolVar(&projectFlag, "project", false, "Uninstall project-scoped skills") cmd.Flags().BoolVar(&globalFlag, "global", false, "Uninstall globally-scoped skills") + markScopeBoolsDeprecated(cmd) return cmd } diff --git a/experimental/aitools/cmd/update.go b/aitools/cmd/update.go similarity index 83% rename from experimental/aitools/cmd/update.go rename to aitools/cmd/update.go index c5072d1fb19..2167ea997d8 100644 --- a/experimental/aitools/cmd/update.go +++ b/aitools/cmd/update.go @@ -3,15 +3,15 @@ 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 skillsFlag, scopeFlag string var projectFlag, globalFlag bool cmd := &cobra.Command{ @@ -35,6 +35,11 @@ preview what would change without downloading.`, return err } + projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, true) + if err != nil { + return err + } + scopes, err := resolveScopeForUpdate(ctx, projectFlag, globalFlag, globalDir, projectDir) if err != nil { return err @@ -73,7 +78,9 @@ preview what would change 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)") + cmd.Flags().StringVar(&scopeFlag, "scope", "", "Update scope: project, global, or both") cmd.Flags().BoolVar(&projectFlag, "project", false, "Update project-scoped skills") cmd.Flags().BoolVar(&globalFlag, "global", false, "Update globally-scoped skills") + markScopeBoolsDeprecated(cmd) return cmd } 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 53285f6ffc9..bce22e6bd06 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" @@ -134,7 +134,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") } } @@ -313,7 +313,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) @@ -322,7 +322,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 2f1da99bb71..ad7960158ea 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" @@ -1155,7 +1155,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..a8732f3f1e8 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/list.go b/experimental/aitools/cmd/list.go deleted file mode 100644 index 1be1538c9a0..00000000000 --- a/experimental/aitools/cmd/list.go +++ /dev/null @@ -1,179 +0,0 @@ -package aitools - -import ( - "errors" - "fmt" - "maps" - "slices" - "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 { - var projectFlag, globalFlag bool - - cmd := &cobra.Command{ - Use: "list", - Short: "List installed AI tools components", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - if projectFlag && globalFlag { - return errors.New("cannot use --global and --project together") - } - // For list: no flag = show both scopes (empty string). - var scope string - if projectFlag { - scope = installer.ScopeProject - } else if globalFlag { - scope = installer.ScopeGlobal - } - return listSkillsFn(cmd, scope) - }, - } - - cmd.Flags().BoolVar(&projectFlag, "project", false, "Show only project-scoped skills") - cmd.Flags().BoolVar(&globalFlag, "global", false, "Show only globally-scoped skills") - return cmd -} - -func defaultListSkills(cmd *cobra.Command, scope string) error { - ctx := cmd.Context() - - ref := installer.GetSkillsRef(ctx) - - src := &installer.GitHubManifestSource{} - manifest, err := src.FetchManifest(ctx, ref) - if err != nil { - return fmt.Errorf("failed to fetch manifest: %w", err) - } - - // Load global state. - var globalState *installer.InstallState - if scope != installer.ScopeProject { - globalDir, gErr := installer.GlobalSkillsDir(ctx) - if gErr == nil { - globalState, err = installer.LoadState(globalDir) - if err != nil { - log.Debugf(ctx, "Could not load global install state: %v", err) - } - } - } - - // Load project state. - var projectState *installer.InstallState - if scope != installer.ScopeGlobal { - projectDir, pErr := installer.ProjectSkillsDir(ctx) - if pErr == nil { - projectState, err = installer.LoadState(projectDir) - if err != nil { - log.Debugf(ctx, "Could not load project install state: %v", err) - } - } - } - - // Build sorted list of skill names. - names := slices.Sorted(maps.Keys(manifest.Skills)) - - version := strings.TrimPrefix(ref, "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") - - bothScopes := globalState != nil && projectState != nil - - globalCount := 0 - projectCount := 0 - for _, name := range names { - meta := manifest.Skills[name] - - tag := "" - if meta.Experimental { - tag = " [experimental]" - } - - installedStr := installedStatus(name, meta.Version, globalState, projectState, bothScopes) - if globalState != nil { - if _, ok := globalState.Skills[name]; ok { - globalCount++ - } - } - if projectState != nil { - if _, ok := projectState.Skills[name]; ok { - projectCount++ - } - } - - fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", name, tag, meta.Version, installedStr) - } - tw.Flush() - cmdio.LogString(ctx, buf.String()) - - // Summary line. - switch { - case bothScopes: - cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", globalCount, len(names), projectCount, len(names))) - case projectState != nil: - cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", projectCount, len(names))) - case scope == installer.ScopeProject: - cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", 0, len(names))) - default: - cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", globalCount, len(names))) - } - return nil -} - -// installedStatus returns the display string for a skill's installation status. -func installedStatus(name, latestVersion string, globalState, projectState *installer.InstallState, bothScopes bool) string { - globalVer := "" - projectVer := "" - - if globalState != nil { - globalVer = globalState.Skills[name] - } - if projectState != nil { - projectVer = projectState.Skills[name] - } - - if globalVer == "" && projectVer == "" { - return "not installed" - } - - // If both scopes have the skill, show the project version (takes precedence). - if bothScopes && globalVer != "" && projectVer != "" { - return versionLabel(projectVer, latestVersion) + " (project, global)" - } - - if projectVer != "" { - label := versionLabel(projectVer, latestVersion) - if bothScopes { - return label + " (project)" - } - return label - } - - label := versionLabel(globalVer, latestVersion) - if bothScopes { - return label + " (global)" - } - return label -} - -// versionLabel formats version with update status. -func versionLabel(installed, latest string) string { - if installed == latest { - return "v" + installed + " (up to date)" - } - return "v" + installed + " (update available)" -} diff --git a/experimental/aitools/cmd/list_test.go b/experimental/aitools/cmd/list_test.go deleted file mode 100644 index 31390110c01..00000000000 --- a/experimental/aitools/cmd/list_test.go +++ /dev/null @@ -1,61 +0,0 @@ -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, scope string) 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 TestListCommandHasScopeFlags(t *testing.T) { - 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/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) +}