From 37b1a3026966e29a111837b8aaca221c4b2ef6ad Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Mon, 11 May 2026 14:28:37 +0000 Subject: [PATCH 1/3] aitools: add --scope flag, deprecate --project and --global Replace the two-boolean --project/--global pair on install/update/uninstall/list with --scope=project|global (and --scope=both on update/list). The old booleans keep working with a cobra deprecation warning so existing scripts continue to run; they'll be removed in a later release. Why now: aitools just left experimental in this PR, so this is the cheapest moment to fix the interface before external scripts start to depend on the two-boolean shape. An enum is friendlier for agent-driven invocations than a pair of booleans with implicit precedence. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 +- aitools/cmd/install.go | 11 ++++++-- aitools/cmd/install_test.go | 39 ++++++++++++++++++++++++++ aitools/cmd/list.go | 17 +++++++---- aitools/cmd/list_test.go | 56 +++++++++++++++++++++++++++++++++++-- aitools/cmd/scope.go | 40 ++++++++++++++++++++++++++ aitools/cmd/scope_test.go | 41 +++++++++++++++++++++++++++ aitools/cmd/uninstall.go | 9 +++++- aitools/cmd/update.go | 9 +++++- 9 files changed, 211 insertions(+), 13 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 1dd109b89a..4e929a4617 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -6,7 +6,7 @@ ### 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`. +* 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. ### Bundles diff --git a/aitools/cmd/install.go b/aitools/cmd/install.go index c0145a836c..26388c147b 100644 --- a/aitools/cmd/install.go +++ b/aitools/cmd/install.go @@ -52,7 +52,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 @@ -62,7 +62,7 @@ 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. @@ -73,6 +73,11 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti 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 { @@ -131,8 +136,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 } diff --git a/aitools/cmd/install_test.go b/aitools/cmd/install_test.go index be992d07f4..5dcd4116cf 100644 --- a/aitools/cmd/install_test.go +++ b/aitools/cmd/install_test.go @@ -409,6 +409,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) diff --git a/aitools/cmd/list.go b/aitools/cmd/list.go index b6a2012200..59539a8ff4 100644 --- a/aitools/cmd/list.go +++ b/aitools/cmd/list.go @@ -1,7 +1,6 @@ package aitools import ( - "errors" "fmt" "maps" "slices" @@ -19,6 +18,7 @@ import ( var ListSkillsFn = defaultListSkills func NewListCmd() *cobra.Command { + var scopeFlag string var projectFlag, globalFlag bool cmd := &cobra.Command{ @@ -26,22 +26,27 @@ func NewListCmd() *cobra.Command { 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. 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 } diff --git a/aitools/cmd/list_test.go b/aitools/cmd/list_test.go index f564d4c8bc..42d8087f32 100644 --- a/aitools/cmd/list_test.go +++ b/aitools/cmd/list_test.go @@ -3,6 +3,7 @@ package aitools import ( "testing" + "github.com/databricks/cli/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -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 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/aitools/cmd/scope.go b/aitools/cmd/scope.go index acd012135a..c405c721f4 100644 --- a/aitools/cmd/scope.go +++ b/aitools/cmd/scope.go @@ -11,6 +11,7 @@ import ( "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) diff --git a/aitools/cmd/scope_test.go b/aitools/cmd/scope_test.go index 80e5a976a9..4f8c3c5688 100644 --- a/aitools/cmd/scope_test.go +++ b/aitools/cmd/scope_test.go @@ -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/aitools/cmd/uninstall.go b/aitools/cmd/uninstall.go index e450f48b93..741b3c63a3 100644 --- a/aitools/cmd/uninstall.go +++ b/aitools/cmd/uninstall.go @@ -6,7 +6,7 @@ import ( ) func NewUninstallCmd() *cobra.Command { - var skillsFlag string + 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/aitools/cmd/update.go b/aitools/cmd/update.go index 127dd0f774..2167ea997d 100644 --- a/aitools/cmd/update.go +++ b/aitools/cmd/update.go @@ -11,7 +11,7 @@ import ( 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 } From 876d0bdf03e02e4ff87df6ab1d75137c7c3c2e52 Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Mon, 11 May 2026 14:40:19 +0000 Subject: [PATCH 2/3] aitools install: document --agents auto-detection behavior Pin down the contract for non-interactive callers (CI, agents) by documenting that an unset --agents flag means "install for every detected agent" outside a TTY. The selected list is already logged to stderr via PrintInstallingFor before the install runs, so callers can verify what was picked. Co-authored-by: Isaac --- aitools/cmd/install.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/aitools/cmd/install.go b/aitools/cmd/install.go index 26388c147b..fd05a1d748 100644 --- a/aitools/cmd/install.go +++ b/aitools/cmd/install.go @@ -68,6 +68,14 @@ 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 { From 37cf784180ab27ac0f7c31474b67a602a44df493 Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Mon, 11 May 2026 15:45:55 +0000 Subject: [PATCH 3/3] ci: retrigger Integration Tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior run reported "Report generation failed" — known recurring flake on databricks/cli's deco-tests bridge (PRs #5227, #5228, #5229 all merged with the same red check on their head). Retriggering via empty commit to see whether this run lands clean. Co-authored-by: Isaac