From e395d48ca380ef2810d0732cd6fd8fed4d28b127 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 9414bd2c7d..9725ae076a 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -4,7 +4,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. * `databricks api` now works against unified hosts. Adds `--account` to scope a call to the account API and `--workspace-id` to override the workspace routing identifier per call. A `?o=` query parameter on the path (the SPOG URL convention used by the Databricks UI) is also recognized as a per-call workspace override, so URLs pasted from the browser route correctly. * JSON output for single objects now uses standard `"key": "value"` spacing (matching list output and `encoding/json` defaults). * `auth login` no longer falls back to plaintext when the OS keyring is reachable but locked. The unlock prompt shown by the probe now runs in parallel with the OAuth flow, and the token is stored in the keyring once the user has typed their password. 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 ab7a746d0a37bd6d8e8397d57f79a29903fb917e 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 a7e3d032e8a1e952a605fabc53baabcd46840b6b 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