Skip to content

Commit 2cefbcc

Browse files
aitools: add --scope flag, deprecate --project/--global (#5234)
## Summary Split out from #4917. While that PR keeps responsibility for *moving* the aitools skills-management surface out of `experimental/`, this PR makes the user-facing interface changes that should land at the same moment: - New `--scope=project|global` flag on `install`/`update`/`uninstall`/`list`, with `--scope=both` accepted by `update` and `list`. - `--project` and `--global` are marked deprecated via cobra's `Deprecated` property: hidden from `--help`, emit a stderr deprecation warning when used, continue to function so existing scripts don't break. They're slated for removal in a later release. - `--scope` combined with `--project`/`--global` is rejected up front with an actionable error. - `install`'s `--help` now documents the non-interactive `--agents` auto-detect contract so callers know what gets picked. **Stacked on #4917.** Base will rebase to `main` once that lands. Splitting because (a) #4917 is otherwise a pure file move and reviewers asked to keep it that way, and (b) the interface change has its own product question (boolean pair vs. enum) worth landing as a discrete unit. ## Why land this with the rename aitools is being declared a stable top-level surface in #4917. This is the cheapest moment to fix the two-boolean shape before external scripts depend on it. An enum is also better for agent-driven invocations than two booleans with implicit precedence: `--scope=project|global|both` is one flag with valid values, not two flags with order-dependent semantics. ## Surface ``` databricks aitools install --scope=project|global (--scope=both rejected) databricks aitools uninstall --scope=project|global (--scope=both rejected) databricks aitools update --scope=project|global|both databricks aitools list --scope=project|global|both (default: both) databricks aitools install --project # warns: use --scope=project databricks aitools install --global # warns: use --scope=global ``` ## Test plan - [ ] `databricks aitools install --scope=project` and `--scope=global` go to the right destination - [ ] `databricks aitools install --scope=both` errors with a clear message - [ ] `databricks aitools install --project` still works and prints the deprecation warning to stderr - [ ] `databricks aitools install --scope=global --project` errors with the conflict message - [ ] `databricks aitools list --scope=both` shows both scopes (equivalent to no flag) - [ ] `databricks aitools install --help` no longer shows `--project`/`--global`; `--scope` is documented; `--agents` auto-detect behavior is described - [ ] Unit: `TestParseScopeFlag` (table-driven on the translation), `TestInstallScopeFlag`, `TestListScopeFlag` — all green This pull request was AI-assisted by Isaac. --------- Co-authored-by: simon <4305831+simonfaltum@users.noreply.github.com> Co-authored-by: simon <simon.faltum@databricks.com>
1 parent 7fccd48 commit 2cefbcc

11 files changed

Lines changed: 416 additions & 33 deletions

File tree

NEXT_CHANGELOG.md

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

99
### CLI
1010

11-
* 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.
11+
* 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`).
1212
* `[__settings__].default_profile` is now consulted as a fallback by `databricks api`, `databricks auth token`, and bundle commands when neither `--profile` nor `DATABRICKS_CONFIG_PROFILE` is set. `databricks auth token` continues to give precedence to `DATABRICKS_HOST` over `default_profile`. For bundle commands, `default_profile` only applies when the bundle does not pin its own `workspace.host`.
1313

1414
### Bundles

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: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,42 @@ import (
1919
var listSkillsFn = defaultListSkills
2020

2121
func NewListCmd() *cobra.Command {
22+
var scopeFlag string
2223
var projectFlag, globalFlag bool
2324

2425
cmd := &cobra.Command{
2526
Use: "list",
2627
Short: "List installed AI tools components",
2728
Args: cobra.NoArgs,
2829
RunE: func(cmd *cobra.Command, args []string) error {
29-
if projectFlag && globalFlag {
30+
// Reject the legacy --project --global combination here so it
31+
// doesn't silently degrade to --scope=both. Users who want both
32+
// scopes should use --scope=both (the new explicit spelling).
33+
if projectFlag && globalFlag && scopeFlag == "" {
3034
return errors.New("cannot use --global and --project together")
3135
}
32-
// For list: no flag = show both scopes (empty string).
36+
37+
projectFlag, globalFlag, err := parseScopeFlag(scopeFlag, projectFlag, globalFlag, true)
38+
if err != nil {
39+
return err
40+
}
41+
42+
// list: empty scope = show both. --scope=both also lands here.
3343
var scope string
34-
if projectFlag {
44+
switch {
45+
case projectFlag && !globalFlag:
3546
scope = installer.ScopeProject
36-
} else if globalFlag {
47+
case globalFlag && !projectFlag:
3748
scope = installer.ScopeGlobal
3849
}
3950
return listSkillsFn(cmd, scope)
4051
},
4152
}
4253

54+
cmd.Flags().StringVar(&scopeFlag, "scope", "", "Scope to show: project, global, or both (default: both)")
4355
cmd.Flags().BoolVar(&projectFlag, "project", false, "Show only project-scoped skills")
4456
cmd.Flags().BoolVar(&globalFlag, "global", false, "Show only globally-scoped skills")
57+
markScopeBoolsDeprecated(cmd)
4558
return cmd
4659
}
4760

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 together rejected", args: []string{"--project", "--global"}, wantErr: "cannot use --global and --project together"},
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: 55 additions & 7 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,53 @@ 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+
// (including update's supported `--project --global` "both scopes" path). Errors
101+
// if --scope is combined with --project or --global. When allowBoth is false,
102+
// --scope=both is rejected up front so install and uninstall don't have to
103+
// special-case it.
104+
//
105+
// Note: install/list/uninstall reject the legacy `--project --global` combination
106+
// at their own RunE / resolveScope layer; update intentionally accepts it as the
107+
// "both scopes" path until those flags are removed.
108+
func parseScopeFlag(scopeFlag string, projectFlag, globalFlag, allowBoth bool) (proj, glob bool, err error) {
109+
if scopeFlag == "" {
110+
return projectFlag, globalFlag, nil
111+
}
112+
if projectFlag || globalFlag {
113+
return false, false, errors.New("cannot use --scope with --project or --global; --project and --global are deprecated aliases for --scope")
114+
}
115+
switch scopeFlag {
116+
case installer.ScopeProject:
117+
return true, false, nil
118+
case installer.ScopeGlobal:
119+
return false, true, nil
120+
case scopeBoth:
121+
if !allowBoth {
122+
return false, false, errors.New("--scope=both is not supported for this command; use 'project' or 'global'")
123+
}
124+
return true, true, nil
125+
default:
126+
if allowBoth {
127+
return false, false, fmt.Errorf("invalid --scope %q: must be one of project, global, both", scopeFlag)
128+
}
129+
return false, false, fmt.Errorf("invalid --scope %q: must be one of project, global", scopeFlag)
130+
}
131+
}
132+
85133
// detectInstalledScopes checks which scopes have a .state.json file present.
86134
func detectInstalledScopes(globalDir, projectDir string) (global, project bool, err error) {
87135
globalState, err := installer.LoadState(globalDir)
@@ -132,7 +180,7 @@ func resolveScopeForUpdate(ctx context.Context, projectFlag, globalFlag bool, gl
132180
switch {
133181
case hasGlobal && hasProject:
134182
if !cmdio.IsPromptSupported(ctx) {
135-
return nil, errors.New("skills are installed in both global and project scopes; use --global, --project, or both flags to specify which to update")
183+
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")
136184
}
137185
scopes, err := promptUpdateScopeSelection(ctx)
138186
if err != nil {
@@ -158,7 +206,7 @@ func resolveScopeForUpdate(ctx context.Context, projectFlag, globalFlag bool, gl
158206
// Unlike update, uninstall never allows "both" scopes at once.
159207
func resolveScopeForUninstall(ctx context.Context, projectFlag, globalFlag bool, globalDir, projectDir string) (string, error) {
160208
if projectFlag && globalFlag {
161-
return "", errors.New("cannot uninstall both scopes at once; run uninstall separately for --global and --project")
209+
return "", errors.New("cannot uninstall both scopes at once; run uninstall separately with --scope=global and --scope=project")
162210
}
163211

164212
hasGlobal, hasProject, err := detectInstalledScopes(globalDir, projectDir)
@@ -182,7 +230,7 @@ func resolveScopeForUninstall(ctx context.Context, projectFlag, globalFlag bool,
182230
switch {
183231
case hasGlobal && hasProject:
184232
if !cmdio.IsPromptSupported(ctx) {
185-
return "", errors.New("skills are installed in both global and project scopes; use --global or --project to specify which to uninstall")
233+
return "", errors.New("skills are installed in both global and project scopes; use --scope=global or --scope=project to specify which to uninstall")
186234
}
187235
scope, err := promptUninstallScopeSelection(ctx)
188236
if err != nil {
@@ -230,10 +278,10 @@ func scopeNotInstalledError(scope, verb, projectDir string, hasGlobal, hasProjec
230278
"no project-scoped skills found in the current directory.\n\n"+
231279
"Project-scoped skills are detected based on your working directory.\n"+
232280
"Make sure you are in the project root where you originally ran\n"+
233-
"'databricks aitools install --project'.\n\n"+
281+
"'databricks aitools install --scope=project'.\n\n"+
234282
"Expected location: %s/", expectedPath)
235283
} else {
236-
msg = "no globally-scoped skills installed. Run 'databricks aitools install --global' to install"
284+
msg = "no globally-scoped skills installed. Run 'databricks aitools install --scope=global' to install"
237285
}
238286

239287
hint := crossScopeHint(scope, verb, hasGlobal, hasProject)
@@ -248,10 +296,10 @@ func scopeNotInstalledError(scope, verb, projectDir string, hasGlobal, hasProjec
248296
// The verb parameter (e.g. "update", "uninstall") controls the action in the hint message.
249297
func crossScopeHint(requestedScope, verb string, hasGlobal, hasProject bool) string {
250298
if requestedScope == installer.ScopeProject && hasGlobal {
251-
return fmt.Sprintf("Global skills are installed. Run without --project to %s those.", verb)
299+
return fmt.Sprintf("Global skills are installed. Run with --scope=global to %s those.", verb)
252300
}
253301
if requestedScope == installer.ScopeGlobal && hasProject {
254-
return fmt.Sprintf("Project-scoped skills are installed. Run without --global to %s those.", verb)
302+
return fmt.Sprintf("Project-scoped skills are installed. Run with --scope=project to %s those.", verb)
255303
}
256304
return ""
257305
}

0 commit comments

Comments
 (0)