Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

### CLI

* Added `databricks aitools` command group for installing Databricks skills into your coding agents (Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity). Skills are fetched from [github.com/databricks/databricks-agent-skills](https://github.com/databricks/databricks-agent-skills) and either symlinked into each agent's skills directory or copied into the current project. Use `databricks aitools install` to set up, `update` to pull newer versions, `list` to see what's available, and `uninstall` to remove them.
* Added `databricks aitools` command group for installing Databricks skills into your coding agents (Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity). Skills are fetched from [github.com/databricks/databricks-agent-skills](https://github.com/databricks/databricks-agent-skills) and either symlinked into each agent's skills directory or copied into the current project. Use `databricks aitools install` to set up, `update` to pull newer versions, `list` to see what's available, and `uninstall` to remove them. Pick where they go with `--scope=project|global` (`--scope=both` is accepted on `update` and `list`).

### Bundles
* Make sure warnings asking for approval are understood by agents ([#5239](https://github.com/databricks/cli/pull/5239))
Expand Down
19 changes: 17 additions & 2 deletions cmd/aitools/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func defaultPromptAgentSelection(ctx context.Context, detected []*agents.Agent)
}

func NewInstallCmd() *cobra.Command {
var skillsFlag, agentsFlag string
var skillsFlag, agentsFlag, scopeFlag string
var includeExperimental bool
var projectFlag, globalFlag bool

Expand All @@ -61,17 +61,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 <name>[,<name>...] 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 {
Expand Down Expand Up @@ -130,8 +143,10 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to install (comma-separated)")
cmd.Flags().StringVar(&agentsFlag, "agents", "", "Agents to install for (comma-separated, e.g. claude-code,cursor)")
cmd.Flags().BoolVar(&includeExperimental, "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
}

Expand Down
39 changes: 39 additions & 0 deletions cmd/aitools/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,45 @@ 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)
Expand Down
17 changes: 11 additions & 6 deletions cmd/aitools/list.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package aitools

import (
"errors"
"fmt"
"maps"
"slices"
Expand All @@ -19,29 +18,35 @@ import (
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 {
if projectFlag && globalFlag {
return errors.New("cannot use --global and --project together")
projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, true)
if err != nil {
return err
}
// For list: no flag = show both scopes (empty string).

// list: empty scope = show both. Both flags set is equivalent.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this comment is stale after the fixup. Both deprecated flags are now rejected by parseScopeFlag; the "both" behavior is only the empty/default scope or explicit --scope=both.

var scope string
if projectFlag {
switch {
case projectFlag && !globalFlag:
scope = installer.ScopeProject
} else if globalFlag {
case globalFlag && !projectFlag:
scope = installer.ScopeGlobal
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix: this changes list --project --global from an error on main into a successful "both scopes" invocation. Since --scope=both is the new explicit spelling, I would keep the old validation for the deprecated flags so accidental invalid invocations don't start succeeding silently.

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
}

Expand Down
56 changes: 54 additions & 2 deletions cmd/aitools/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package aitools
import (
"testing"

"github.com/databricks/cli/libs/aitools/installer"
"github.com/databricks/cli/libs/cmdio"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -36,7 +37,58 @@ func TestListCommandCallsListFn(t *testing.T) {
func TestListCommandHasScopeFlags(t *testing.T) {
cmd := NewListCmd()
f := cmd.Flags().Lookup("project")
require.NotNil(t, f, "--project flag should exist")
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")
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 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 together rejected", args: []string{"--project", "--global"}, wantErr: "cannot use --global and --project together"},
}

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)
})
}
}
50 changes: 50 additions & 0 deletions cmd/aitools/scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/databricks/cli/libs/aitools/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.
Expand Down Expand Up @@ -82,6 +83,55 @@ 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
}
Comment on lines +86 to +95
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this hides --project and --global, the remaining user-facing guidance should stop recommending those hidden deprecated flags. The auto-detect and not-installed errors below still say things like use --global, --project, or both flags, install --project, install --global, and Run without --project. Those should point users at --scope=project, --scope=global, or --scope=both instead, so the error messages match the new public surface.


// 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, or if both deprecated
// flags are set together (matching the pre-refactor validation). 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 == "" {
// Preserve the pre-refactor behavior: combining the two deprecated flags
// is always wrong, regardless of allowBoth. Users who want both scopes
// should use --scope=both (where supported).
if projectFlag && globalFlag {
return false, false, errors.New("cannot use --global and --project together")
}
return projectFlag, globalFlag, nil
Comment on lines +109 to +110
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix: this creates a new regression for aitools update --project --global. On main, resolveScopeForUpdate(ctx, true, true, ...) is the supported "update both scopes" path, and the existing resolver tests still cover that behavior. With this shared rejection in front of resolveScopeForUpdate, existing update scripts using the two deprecated flags now fail, even though the PR says the deprecated flags continue to function. Can we preserve legacy both-flag behavior for update until those flags are removed, while still rejecting it for install/list/uninstall?

}
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:
if allowBoth {
return false, false, fmt.Errorf("invalid --scope %q: must be one of project, global, both", scopeFlag)
}
return false, false, fmt.Errorf("invalid --scope %q: must be one of project, global", 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)
Expand Down
51 changes: 51 additions & 0 deletions cmd/aitools/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,57 @@ 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 flags together rejected", project: true, global: true, wantErr: "cannot use --global and --project together"},
{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 with allowBoth", scope: "all", allowBoth: true, wantErr: `invalid --scope "all": must be one of project, global, both`},
{name: "scope invalid value without allowBoth omits both from error", scope: "all", wantErr: `invalid --scope "all": must be one of project, global`},
{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)
})
}

// Stronger check that the without-allowBoth invalid-value branch omits
// "both" from the error message (the table assertion uses Contains which
// can't distinguish a substring shared with the allowBoth variant).
t.Run("invalid scope error message without allowBoth does not mention both", func(t *testing.T) {
_, _, err := parseScopeFlag("all", false, false, false)
require.Error(t, err)
assert.NotContains(t, err.Error(), "both")
})
}

// --- detectInstalledScopes tests (table-driven) ---

func TestDetectInstalledScopes(t *testing.T) {
Expand Down
18 changes: 16 additions & 2 deletions cmd/aitools/uninstall.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package aitools

import (
"context"

"github.com/databricks/cli/libs/aitools/installer"
"github.com/spf13/cobra"
)

// Package-level for testability. Tests override via uninstall_test.go.
var uninstallSkillsFn = func(ctx context.Context, opts installer.UninstallOptions) error {
return installer.UninstallSkillsOpts(ctx, opts)
}

func NewUninstallCmd() *cobra.Command {
var skillsFlag string
var skillsFlag, scopeFlag string
var projectFlag, globalFlag bool

cmd := &cobra.Command{
Expand All @@ -19,6 +26,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
Expand All @@ -37,12 +49,14 @@ By default, removes all skills. Use --skills to remove specific skills only.`,
Scope: scope,
}
opts.Skills = splitAndTrim(skillsFlag)
return installer.UninstallSkillsOpts(ctx, opts)
return uninstallSkillsFn(ctx, opts)
},
}

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
}
Loading
Loading