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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
21 changes: 17 additions & 4 deletions cmd/aitools/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,42 @@ 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 {
// Reject the legacy --project --global combination here so it
// doesn't silently degrade to --scope=both. Users who want both
// scopes should use --scope=both (the new explicit spelling).
if projectFlag && globalFlag && scopeFlag == "" {
return errors.New("cannot use --global and --project together")
}
// For list: no flag = show both scopes (empty string).

projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, true)
if err != nil {
return err
}

// list: empty scope = show both. --scope=both also lands here.
var scope string
if projectFlag {
switch {
case projectFlag && !globalFlag:
scope = installer.ScopeProject
} else if globalFlag {
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
}

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)
})
}
}
62 changes: 55 additions & 7 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,53 @@ 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
// (including update's supported `--project --global` "both scopes" path). 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.
//
// Note: install/list/uninstall reject the legacy `--project --global` combination
// at their own RunE / resolveScope layer; update intentionally accepts it as the
// "both scopes" path until those flags are removed.
func parseScopeFlag(scopeFlag string, projectFlag, globalFlag, allowBoth bool) (proj, glob bool, err error) {
if scopeFlag == "" {
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 Expand Up @@ -132,7 +180,7 @@ func resolveScopeForUpdate(ctx context.Context, projectFlag, globalFlag bool, gl
switch {
case hasGlobal && hasProject:
if !cmdio.IsPromptSupported(ctx) {
return nil, errors.New("skills are installed in both global and project scopes; use --global, --project, or both flags to specify which to update")
return nil, errors.New("skills are installed in both global and project scopes; use --scope=global, --scope=project, or --scope=both to specify which to update")
}
scopes, err := promptUpdateScopeSelection(ctx)
if err != nil {
Expand All @@ -158,7 +206,7 @@ func resolveScopeForUpdate(ctx context.Context, projectFlag, globalFlag bool, gl
// Unlike update, uninstall never allows "both" scopes at once.
func resolveScopeForUninstall(ctx context.Context, projectFlag, globalFlag bool, globalDir, projectDir string) (string, error) {
if projectFlag && globalFlag {
return "", errors.New("cannot uninstall both scopes at once; run uninstall separately for --global and --project")
return "", errors.New("cannot uninstall both scopes at once; run uninstall separately with --scope=global and --scope=project")
}

hasGlobal, hasProject, err := detectInstalledScopes(globalDir, projectDir)
Expand All @@ -182,7 +230,7 @@ func resolveScopeForUninstall(ctx context.Context, projectFlag, globalFlag bool,
switch {
case hasGlobal && hasProject:
if !cmdio.IsPromptSupported(ctx) {
return "", errors.New("skills are installed in both global and project scopes; use --global or --project to specify which to uninstall")
return "", errors.New("skills are installed in both global and project scopes; use --scope=global or --scope=project to specify which to uninstall")
}
scope, err := promptUninstallScopeSelection(ctx)
if err != nil {
Expand Down Expand Up @@ -230,10 +278,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 aitools install --project'.\n\n"+
"'databricks aitools install --scope=project'.\n\n"+
"Expected location: %s/", expectedPath)
} else {
msg = "no globally-scoped skills installed. Run 'databricks aitools install --global' to install"
msg = "no globally-scoped skills installed. Run 'databricks aitools install --scope=global' to install"
}

hint := crossScopeHint(scope, verb, hasGlobal, hasProject)
Expand All @@ -248,10 +296,10 @@ func scopeNotInstalledError(scope, verb, projectDir string, hasGlobal, hasProjec
// The verb parameter (e.g. "update", "uninstall") controls the action in the hint message.
func crossScopeHint(requestedScope, verb string, hasGlobal, hasProject bool) string {
if requestedScope == installer.ScopeProject && hasGlobal {
return fmt.Sprintf("Global skills are installed. Run without --project to %s those.", verb)
return fmt.Sprintf("Global skills are installed. Run with --scope=global to %s those.", verb)
}
if requestedScope == installer.ScopeGlobal && hasProject {
return fmt.Sprintf("Project-scoped skills are installed. Run without --global to %s those.", verb)
return fmt.Sprintf("Project-scoped skills are installed. Run with --scope=project to %s those.", verb)
}
return ""
}
Expand Down
Loading