Skip to content

Commit 7666312

Browse files
aitools: add --scope flag, deprecate --project/--global
Adds --scope=project|global|both as the single source of truth for scope selection on install/update/uninstall/list. Keeps --project and --global working via cobra.Deprecated (hidden from --help, emit stderr warning). Mixing --scope with --project/--global is rejected up front. Also documents the --agents auto-detect contract in install --help. Stacked-on-#4917 base rewritten onto current main (16 noisy merge/cherry-pick commits collapsed into this single change; underlying diff unchanged at 9 files / +219 / -13). Co-authored-by: Isaac
1 parent 50ce8c2 commit 7666312

9 files changed

Lines changed: 219 additions & 13 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
### CLI
88

9-
* 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.
9+
* 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`).
1010

1111
### Bundles
1212
* Make sure warnings asking for approval are understood by agents ([#5239](https://github.com/databricks/cli/pull/5239))

cmd/aitools/install.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func defaultPromptAgentSelection(ctx context.Context, detected []*agents.Agent)
5151
}
5252

5353
func NewInstallCmd() *cobra.Command {
54-
var skillsFlag, agentsFlag string
54+
var skillsFlag, agentsFlag, scopeFlag string
5555
var includeExperimental bool
5656
var projectFlag, globalFlag bool
5757

@@ -61,17 +61,30 @@ func NewInstallCmd() *cobra.Command {
6161
Long: `Install Databricks AI skills for detected coding agents.
6262
6363
By default, skills are installed globally to each agent's skills directory.
64-
Use --project to install to the current project directory instead.
64+
Use --scope=project to install to the current project directory instead.
6565
When multiple agents are detected, skills are stored in a canonical location
6666
and symlinked to each agent to avoid duplication.
6767
6868
Use --skills name1,name2 to install specific skills.
6969
70+
Agent selection:
71+
--agents <name>[,<name>...] Install only for the named agents.
72+
(unset, interactive) Multi-select prompt over detected agents.
73+
(unset, non-interactive) Install for every detected agent.
74+
75+
The list of agents the command will act on is always logged to stderr before
76+
the install runs, so callers can verify what was picked.
77+
7078
Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity`,
7179
Args: cobra.NoArgs,
7280
RunE: func(cmd *cobra.Command, args []string) error {
7381
ctx := cmd.Context()
7482

83+
projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, false)
84+
if err != nil {
85+
return err
86+
}
87+
7588
// Resolve scope.
7689
scope, err := resolveScopeWithPrompt(ctx, projectFlag, globalFlag)
7790
if err != nil {
@@ -130,8 +143,10 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti
130143
cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to install (comma-separated)")
131144
cmd.Flags().StringVar(&agentsFlag, "agents", "", "Agents to install for (comma-separated, e.g. claude-code,cursor)")
132145
cmd.Flags().BoolVar(&includeExperimental, "experimental", false, "Include experimental skills")
146+
cmd.Flags().StringVar(&scopeFlag, "scope", "", "Install scope: project or global (default: global, or prompt when interactive)")
133147
cmd.Flags().BoolVar(&projectFlag, "project", false, "Install to project directory (cwd)")
134148
cmd.Flags().BoolVar(&globalFlag, "global", false, "Install globally (default)")
149+
markScopeBoolsDeprecated(cmd)
135150
return cmd
136151
}
137152

cmd/aitools/install_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,45 @@ func TestInstallGlobalAndProjectErrors(t *testing.T) {
411411
assert.Contains(t, err.Error(), "cannot use --global and --project together")
412412
}
413413

414+
func TestInstallScopeFlag(t *testing.T) {
415+
tests := []struct {
416+
name string
417+
args []string
418+
wantScope string
419+
wantErr string
420+
}{
421+
{name: "scope project", args: []string{"--scope", "project"}, wantScope: installer.ScopeProject},
422+
{name: "scope global", args: []string{"--scope", "global"}, wantScope: installer.ScopeGlobal},
423+
{name: "scope both rejected", args: []string{"--scope", "both"}, wantErr: "--scope=both is not supported"},
424+
{name: "scope invalid value", args: []string{"--scope", "all"}, wantErr: `invalid --scope "all"`},
425+
{name: "scope conflicts with legacy", args: []string{"--scope", "global", "--project"}, wantErr: "cannot use --scope with --project or --global"},
426+
}
427+
428+
for _, tt := range tests {
429+
t.Run(tt.name, func(t *testing.T) {
430+
setupTestAgents(t)
431+
calls := setupInstallMock(t)
432+
433+
ctx := cmdio.MockDiscard(t.Context())
434+
cmd := NewInstallCmd()
435+
cmd.SetContext(ctx)
436+
cmd.SetArgs(tt.args)
437+
cmd.SilenceErrors = true
438+
cmd.SilenceUsage = true
439+
440+
err := cmd.Execute()
441+
if tt.wantErr != "" {
442+
require.Error(t, err)
443+
assert.Contains(t, err.Error(), tt.wantErr)
444+
return
445+
}
446+
require.NoError(t, err)
447+
require.Len(t, *calls, 1)
448+
assert.Equal(t, tt.wantScope, (*calls)[0].opts.Scope)
449+
})
450+
}
451+
}
452+
414453
func TestInstallNoFlagNonInteractiveUsesGlobal(t *testing.T) {
415454
setupTestAgents(t)
416455
calls := setupInstallMock(t)

cmd/aitools/list.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package aitools
22

33
import (
4-
"errors"
54
"fmt"
65
"maps"
76
"slices"
@@ -19,29 +18,35 @@ import (
1918
var listSkillsFn = defaultListSkills
2019

2120
func NewListCmd() *cobra.Command {
21+
var scopeFlag string
2222
var projectFlag, globalFlag bool
2323

2424
cmd := &cobra.Command{
2525
Use: "list",
2626
Short: "List installed AI tools components",
2727
Args: cobra.NoArgs,
2828
RunE: func(cmd *cobra.Command, args []string) error {
29-
if projectFlag && globalFlag {
30-
return errors.New("cannot use --global and --project together")
29+
projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, true)
30+
if err != nil {
31+
return err
3132
}
32-
// For list: no flag = show both scopes (empty string).
33+
34+
// list: empty scope = show both. Both flags set is equivalent.
3335
var scope string
34-
if projectFlag {
36+
switch {
37+
case projectFlag && !globalFlag:
3538
scope = installer.ScopeProject
36-
} else if globalFlag {
39+
case globalFlag && !projectFlag:
3740
scope = installer.ScopeGlobal
3841
}
3942
return listSkillsFn(cmd, scope)
4043
},
4144
}
4245

46+
cmd.Flags().StringVar(&scopeFlag, "scope", "", "Scope to show: project, global, or both (default: both)")
4347
cmd.Flags().BoolVar(&projectFlag, "project", false, "Show only project-scoped skills")
4448
cmd.Flags().BoolVar(&globalFlag, "global", false, "Show only globally-scoped skills")
49+
markScopeBoolsDeprecated(cmd)
4550
return cmd
4651
}
4752

cmd/aitools/list_test.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package aitools
33
import (
44
"testing"
55

6+
"github.com/databricks/cli/libs/aitools/installer"
67
"github.com/databricks/cli/libs/cmdio"
78
"github.com/spf13/cobra"
89
"github.com/stretchr/testify/assert"
@@ -36,7 +37,58 @@ func TestListCommandCallsListFn(t *testing.T) {
3637
func TestListCommandHasScopeFlags(t *testing.T) {
3738
cmd := NewListCmd()
3839
f := cmd.Flags().Lookup("project")
39-
require.NotNil(t, f, "--project flag should exist")
40+
require.NotNil(t, f, "--project flag should exist (deprecated alias)")
41+
assert.NotEmpty(t, f.Deprecated, "--project should be marked deprecated")
4042
f = cmd.Flags().Lookup("global")
41-
require.NotNil(t, f, "--global flag should exist")
43+
require.NotNil(t, f, "--global flag should exist (deprecated alias)")
44+
assert.NotEmpty(t, f.Deprecated, "--global should be marked deprecated")
45+
f = cmd.Flags().Lookup("scope")
46+
require.NotNil(t, f, "--scope flag should exist")
47+
}
48+
49+
func TestListScopeFlag(t *testing.T) {
50+
tests := []struct {
51+
name string
52+
args []string
53+
wantScope string
54+
wantErr string
55+
}{
56+
{name: "scope project", args: []string{"--scope", "project"}, wantScope: installer.ScopeProject},
57+
{name: "scope global", args: []string{"--scope", "global"}, wantScope: installer.ScopeGlobal},
58+
{name: "scope both shows both", args: []string{"--scope", "both"}, wantScope: ""},
59+
{name: "scope invalid", args: []string{"--scope", "all"}, wantErr: `invalid --scope "all"`},
60+
{name: "legacy both flags shows both", args: []string{"--project", "--global"}, wantScope: ""},
61+
}
62+
63+
for _, tt := range tests {
64+
t.Run(tt.name, func(t *testing.T) {
65+
orig := listSkillsFn
66+
t.Cleanup(func() { listSkillsFn = orig })
67+
68+
var gotScope string
69+
called := false
70+
listSkillsFn = func(_ *cobra.Command, scope string) error {
71+
called = true
72+
gotScope = scope
73+
return nil
74+
}
75+
76+
ctx := cmdio.MockDiscard(t.Context())
77+
cmd := NewListCmd()
78+
cmd.SetContext(ctx)
79+
cmd.SetArgs(tt.args)
80+
cmd.SilenceErrors = true
81+
cmd.SilenceUsage = true
82+
83+
err := cmd.Execute()
84+
if tt.wantErr != "" {
85+
require.Error(t, err)
86+
assert.Contains(t, err.Error(), tt.wantErr)
87+
return
88+
}
89+
require.NoError(t, err)
90+
assert.True(t, called)
91+
assert.Equal(t, tt.wantScope, gotScope)
92+
})
93+
}
4294
}

cmd/aitools/scope.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/databricks/cli/libs/aitools/installer"
1212
"github.com/databricks/cli/libs/cmdio"
1313
"github.com/databricks/cli/libs/env"
14+
"github.com/spf13/cobra"
1415
)
1516

1617
// 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) {
8283

8384
const scopeBoth = "both"
8485

86+
// markScopeBoolsDeprecated hides --project and --global from help and emits a
87+
// stderr warning pointing at --scope when they're used. The booleans are kept
88+
// so existing scripts and the experimental backward-compat aliases keep
89+
// working through the next release.
90+
func markScopeBoolsDeprecated(cmd *cobra.Command) {
91+
cmd.Flags().Lookup("project").Deprecated = "use --scope=project"
92+
cmd.Flags().Lookup("project").Hidden = true
93+
cmd.Flags().Lookup("global").Deprecated = "use --scope=global"
94+
cmd.Flags().Lookup("global").Hidden = true
95+
}
96+
97+
// parseScopeFlag translates --scope into the equivalent --project/--global bool pair.
98+
// Returns (projectFlag, globalFlag, nil) unchanged when --scope is empty so the
99+
// deprecated booleans can keep flowing through the existing resolveScope* helpers.
100+
// Errors if --scope is combined with --project or --global. When allowBoth is
101+
// false, --scope=both is rejected up front so install and uninstall don't have
102+
// to special-case it.
103+
func parseScopeFlag(scopeFlag string, projectFlag, globalFlag, allowBoth bool) (proj, glob bool, err error) {
104+
if scopeFlag == "" {
105+
return projectFlag, globalFlag, nil
106+
}
107+
if projectFlag || globalFlag {
108+
return false, false, errors.New("cannot use --scope with --project or --global; --project and --global are deprecated aliases for --scope")
109+
}
110+
switch scopeFlag {
111+
case installer.ScopeProject:
112+
return true, false, nil
113+
case installer.ScopeGlobal:
114+
return false, true, nil
115+
case scopeBoth:
116+
if !allowBoth {
117+
return false, false, errors.New("--scope=both is not supported for this command; use 'project' or 'global'")
118+
}
119+
return true, true, nil
120+
default:
121+
return false, false, fmt.Errorf("invalid --scope %q: must be one of project, global, both", scopeFlag)
122+
}
123+
}
124+
85125
// detectInstalledScopes checks which scopes have a .state.json file present.
86126
func detectInstalledScopes(globalDir, projectDir string) (global, project bool, err error) {
87127
globalState, err := installer.LoadState(globalDir)

cmd/aitools/scope_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,47 @@ func interactiveCtx(t *testing.T) (context.Context, func()) {
6767
return ctx, test.Done
6868
}
6969

70+
// --- parseScopeFlag tests ---
71+
72+
func TestParseScopeFlag(t *testing.T) {
73+
tests := []struct {
74+
name string
75+
scope string
76+
project bool
77+
global bool
78+
allowBoth bool
79+
wantProj bool
80+
wantGlob bool
81+
wantErr string
82+
}{
83+
{name: "unset", scope: ""},
84+
{name: "legacy project only", project: true, wantProj: true},
85+
{name: "legacy global only", global: true, wantGlob: true},
86+
{name: "legacy both passthrough", project: true, global: true, wantProj: true, wantGlob: true},
87+
{name: "scope project", scope: "project", wantProj: true},
88+
{name: "scope global", scope: "global", wantGlob: true},
89+
{name: "scope both allowed", scope: "both", allowBoth: true, wantProj: true, wantGlob: true},
90+
{name: "scope both disallowed", scope: "both", wantErr: "--scope=both is not supported"},
91+
{name: "scope invalid value", scope: "all", wantErr: `invalid --scope "all"`},
92+
{name: "scope conflicts with project", scope: "project", project: true, wantErr: "cannot use --scope with --project or --global"},
93+
{name: "scope conflicts with global", scope: "global", global: true, wantErr: "cannot use --scope with --project or --global"},
94+
}
95+
96+
for _, tt := range tests {
97+
t.Run(tt.name, func(t *testing.T) {
98+
proj, glob, err := parseScopeFlag(tt.scope, tt.project, tt.global, tt.allowBoth)
99+
if tt.wantErr != "" {
100+
require.Error(t, err)
101+
assert.Contains(t, err.Error(), tt.wantErr)
102+
return
103+
}
104+
require.NoError(t, err)
105+
assert.Equal(t, tt.wantProj, proj)
106+
assert.Equal(t, tt.wantGlob, glob)
107+
})
108+
}
109+
}
110+
70111
// --- detectInstalledScopes tests (table-driven) ---
71112

72113
func TestDetectInstalledScopes(t *testing.T) {

cmd/aitools/uninstall.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
)
77

88
func NewUninstallCmd() *cobra.Command {
9-
var skillsFlag string
9+
var skillsFlag, scopeFlag string
1010
var projectFlag, globalFlag bool
1111

1212
cmd := &cobra.Command{
@@ -19,6 +19,11 @@ By default, removes all skills. Use --skills to remove specific skills only.`,
1919
RunE: func(cmd *cobra.Command, args []string) error {
2020
ctx := cmd.Context()
2121

22+
projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, false)
23+
if err != nil {
24+
return err
25+
}
26+
2227
globalDir, err := installer.GlobalSkillsDir(ctx)
2328
if err != nil {
2429
return err
@@ -42,7 +47,9 @@ By default, removes all skills. Use --skills to remove specific skills only.`,
4247
}
4348

4449
cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to uninstall (comma-separated)")
50+
cmd.Flags().StringVar(&scopeFlag, "scope", "", "Uninstall scope: project or global")
4551
cmd.Flags().BoolVar(&projectFlag, "project", false, "Uninstall project-scoped skills")
4652
cmd.Flags().BoolVar(&globalFlag, "global", false, "Uninstall globally-scoped skills")
53+
markScopeBoolsDeprecated(cmd)
4754
return cmd
4855
}

cmd/aitools/update.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111

1212
func NewUpdateCmd() *cobra.Command {
1313
var check, force, noNew bool
14-
var skillsFlag string
14+
var skillsFlag, scopeFlag string
1515
var projectFlag, globalFlag bool
1616

1717
cmd := &cobra.Command{
@@ -35,6 +35,11 @@ preview what would change without downloading.`,
3535
return err
3636
}
3737

38+
projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, true)
39+
if err != nil {
40+
return err
41+
}
42+
3843
scopes, err := resolveScopeForUpdate(ctx, projectFlag, globalFlag, globalDir, projectDir)
3944
if err != nil {
4045
return err
@@ -73,7 +78,9 @@ preview what would change without downloading.`,
7378
cmd.Flags().BoolVar(&force, "force", false, "Re-download even if versions match")
7479
cmd.Flags().BoolVar(&noNew, "no-new", false, "Don't auto-install new skills from manifest")
7580
cmd.Flags().StringVar(&skillsFlag, "skills", "", "Specific skills to update (comma-separated)")
81+
cmd.Flags().StringVar(&scopeFlag, "scope", "", "Update scope: project, global, or both")
7682
cmd.Flags().BoolVar(&projectFlag, "project", false, "Update project-scoped skills")
7783
cmd.Flags().BoolVar(&globalFlag, "global", false, "Update globally-scoped skills")
84+
markScopeBoolsDeprecated(cmd)
7885
return cmd
7986
}

0 commit comments

Comments
 (0)