Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<workspace-id>` 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).
Expand Down
19 changes: 17 additions & 2 deletions aitools/cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <name>[,<name>...] 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 {
Expand Down Expand Up @@ -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
}

Expand Down
39 changes: 39 additions & 0 deletions aitools/cmd/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 11 additions & 6 deletions aitools/cmd/list.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package aitools

import (
"errors"
"fmt"
"maps"
"slices"
Expand All @@ -19,29 +18,35 @@ import (
var ListSkillsFn = defaultListSkills

func NewListCmd() *cobra.Command {
var scopeFlag string
var projectFlag, globalFlag bool

cmd := &cobra.Command{
Use: "list",
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
}

Expand Down
56 changes: 54 additions & 2 deletions aitools/cmd/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
})
}
}
40 changes: 40 additions & 0 deletions aitools/cmd/scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions aitools/cmd/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion aitools/cmd/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
)

func NewUninstallCmd() *cobra.Command {
var skillsFlag string
var skillsFlag, scopeFlag string
var projectFlag, globalFlag bool

cmd := &cobra.Command{
Expand All @@ -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
Expand All @@ -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
}
9 changes: 8 additions & 1 deletion aitools/cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Loading