diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 6a1eb99275..a7ee868ff6 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). * `databricks auth describe` now reports where U2M (`databricks-cli`) tokens are stored: `plaintext` (`~/.databricks/token-cache.json`) or `secure` (OS keyring), and the source of the choice (env var, config setting, or default). diff --git a/aitools/cmd/install.go b/aitools/cmd/install.go index c0145a836c..fd05a1d748 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,17 +62,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 [,...] 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 { @@ -131,8 +144,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 2d236c688d..880bc3639e 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 }