From 9d22031e503fe43da922872496449184dc0c0c93 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 268ce95b23..d37394db6c 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..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 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 } From b3b6d1aad17b6443ee9427fc433b079c8c03f4c5 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 c02b45c292b470606fe24ac1625d96f3a104160d Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Mon, 11 May 2026 14:46:25 +0000 Subject: [PATCH 3/3] aitools list: emit JSON when --output json Refactor list rendering to build a structured listOutput first and dispatch on root.OutputType(cmd) for text vs json. JSON shape: { "release": "0.1.0", "skills": [{ "name": "...", "latest_version": "...", "experimental": false, "installed": { "global": "...", "project": "..." } }], "summary": { "global": { "installed": N, "total": M }, "project": { "installed": N, "total": M } } } The installed map omits scopes where the skill isn't present. The summary only includes scopes that were queried, so --scope=global narrows it. The text rendering path is byte-for-byte unchanged from the prior implementation. Why: aitools list is one of the surfaces an agent reaches for first (\"what's installed, what's available, what's stale\"). Scraping tabwriter columns from stderr is fragile; a stable JSON contract makes the command declarative for non-human callers. Depends on #4917. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + aitools/cmd/list.go | 227 +++++++++++++++++++++++++++------------ aitools/cmd/list_test.go | 124 +++++++++++++++++++++ 3 files changed, 282 insertions(+), 70 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index a7ee868ff6..fb170ad3c7 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -9,6 +9,7 @@ * 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). * Marked the default profile in the interactive pickers shown by `databricks auth switch`, `databricks auth logout`, `databricks auth token`, and `databricks auth login`, and moved it to the top of the list. `databricks auth login` and `databricks auth logout` now offer the same selectors as `databricks auth token` and `databricks auth switch` respectively. +* `databricks aitools list` honors `--output json`, emitting a structured `{release, skills[…], summary{}}` document so coding agents and CI can consume the skill/version/installation matrix without scraping the tabular text output. ### Bundles * Stop applying `presets.name_prefix` (and the dev-mode `[dev ]` rename) to `vector_search_endpoints` ([#5209](https://github.com/databricks/cli/pull/5209)). diff --git a/aitools/cmd/list.go b/aitools/cmd/list.go index 880bc3639e..83388c6755 100644 --- a/aitools/cmd/list.go +++ b/aitools/cmd/list.go @@ -1,14 +1,19 @@ package aitools import ( + "context" + "encoding/json" "fmt" + "io" "maps" "slices" "strings" "text/tabwriter" "github.com/databricks/cli/aitools/lib/installer" + "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/flags" "github.com/databricks/cli/libs/log" "github.com/spf13/cobra" ) @@ -50,125 +55,185 @@ func NewListCmd() *cobra.Command { return cmd } +// listOutput is the structured representation of `aitools list` used by both +// text rendering and `--output json` consumers. The JSON shape is part of +// the public CLI contract; do not break field names or types. +type listOutput struct { + Release string `json:"release"` + Skills []skillEntry `json:"skills"` + Summary map[string]scopeSummary `json:"summary"` +} + +type skillEntry struct { + Name string `json:"name"` + LatestVersion string `json:"latest_version"` + Experimental bool `json:"experimental"` + Installed map[string]string `json:"installed"` +} + +type scopeSummary struct { + Installed int `json:"installed"` + Total int `json:"total"` +} + func defaultListSkills(cmd *cobra.Command, scope string) error { ctx := cmd.Context() + out, err := buildListOutput(ctx, scope) + if err != nil { + return err + } + + switch root.OutputType(cmd) { + case flags.OutputJSON: + return renderListJSON(cmd.OutOrStdout(), out) + default: + renderListText(ctx, out, scope) + return nil + } +} + +// buildListOutput fetches the manifest and per-scope install state and +// returns the structured listOutput. scope=="" loads both scopes; "global" +// or "project" loads only that scope. +func buildListOutput(ctx context.Context, scope string) (listOutput, error) { ref := installer.GetSkillsRef(ctx) src := &installer.GitHubManifestSource{} manifest, err := src.FetchManifest(ctx, ref) if err != nil { - return fmt.Errorf("failed to fetch manifest: %w", err) - } - - // Load global state. - var globalState *installer.InstallState - if scope != installer.ScopeProject { - globalDir, gErr := installer.GlobalSkillsDir(ctx) - if gErr == nil { - globalState, err = installer.LoadState(globalDir) - if err != nil { - log.Debugf(ctx, "Could not load global install state: %v", err) - } - } + return listOutput{}, fmt.Errorf("failed to fetch manifest: %w", err) } - // Load project state. - var projectState *installer.InstallState - if scope != installer.ScopeGlobal { - projectDir, pErr := installer.ProjectSkillsDir(ctx) - if pErr == nil { - projectState, err = installer.LoadState(projectDir) - if err != nil { - log.Debugf(ctx, "Could not load project install state: %v", err) - } - } - } + globalState := loadStateForScope(ctx, scope, installer.ScopeProject, installer.GlobalSkillsDir, "global") + projectState := loadStateForScope(ctx, scope, installer.ScopeGlobal, installer.ProjectSkillsDir, "project") - // Build sorted list of skill names. names := slices.Sorted(maps.Keys(manifest.Skills)) - version := strings.TrimPrefix(ref, "v") - cmdio.LogString(ctx, "Available skills (v"+version+"):") - cmdio.LogString(ctx, "") - - var buf strings.Builder - tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) - fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED") - - bothScopes := globalState != nil && projectState != nil + out := listOutput{ + Release: strings.TrimPrefix(ref, "v"), + Skills: make([]skillEntry, 0, len(names)), + Summary: map[string]scopeSummary{}, + } - globalCount := 0 - projectCount := 0 + globalCount, projectCount := 0, 0 for _, name := range names { meta := manifest.Skills[name] - - tag := "" - if meta.Experimental { - tag = " [experimental]" + entry := skillEntry{ + Name: name, + LatestVersion: meta.Version, + Experimental: meta.Experimental, + Installed: map[string]string{}, } - - installedStr := installedStatus(name, meta.Version, globalState, projectState, bothScopes) if globalState != nil { - if _, ok := globalState.Skills[name]; ok { + if v, ok := globalState.Skills[name]; ok { + entry.Installed[installer.ScopeGlobal] = v globalCount++ } } if projectState != nil { - if _, ok := projectState.Skills[name]; ok { + if v, ok := projectState.Skills[name]; ok { + entry.Installed[installer.ScopeProject] = v projectCount++ } } + out.Skills = append(out.Skills, entry) + } - fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", name, tag, meta.Version, installedStr) + // Include a summary entry for every scope that was queried, even when the + // install state is missing — agents should see "0/N" rather than guess + // from the absence of a key. + if scope != installer.ScopeProject { + out.Summary[installer.ScopeGlobal] = scopeSummary{Installed: globalCount, Total: len(names)} + } + if scope != installer.ScopeGlobal { + out.Summary[installer.ScopeProject] = scopeSummary{Installed: projectCount, Total: len(names)} } - tw.Flush() - cmdio.LogString(ctx, buf.String()) - // Summary line. - switch { - case bothScopes: - cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", globalCount, len(names), projectCount, len(names))) - case projectState != nil: - cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", projectCount, len(names))) - case scope == installer.ScopeProject: - cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", 0, len(names))) - default: - cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", globalCount, len(names))) + return out, nil +} + +// loadStateForScope returns the install state for the named scope when the +// scope filter allows it. excludeScope is the scope value that means "skip +// loading this one" (so passing ScopeProject to the global loader skips +// global when --scope=project). +func loadStateForScope(ctx context.Context, scopeFilter, excludeScope string, dirFn func(context.Context) (string, error), label string) *installer.InstallState { + if scopeFilter == excludeScope { + return nil } - return nil + dir, err := dirFn(ctx) + if err != nil { + return nil + } + state, err := installer.LoadState(dir) + if err != nil { + log.Debugf(ctx, "Could not load %s install state: %v", label, err) + return nil + } + return state +} + +func renderListJSON(w io.Writer, out listOutput) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(out) } -// installedStatus returns the display string for a skill's installation status. -func installedStatus(name, latestVersion string, globalState, projectState *installer.InstallState, bothScopes bool) string { - globalVer := "" - projectVer := "" +func renderListText(ctx context.Context, out listOutput, scope string) { + cmdio.LogString(ctx, "Available skills (v"+out.Release+"):") + cmdio.LogString(ctx, "") + + bothScopes := scope == "" && len(out.Summary) == 2 && + out.Summary[installer.ScopeGlobal].Installed+out.Summary[installer.ScopeProject].Installed > 0 && + anyInstalled(out, installer.ScopeGlobal) && anyInstalled(out, installer.ScopeProject) - if globalState != nil { - globalVer = globalState.Skills[name] + var buf strings.Builder + tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED") + for _, s := range out.Skills { + tag := "" + if s.Experimental { + tag = " [experimental]" + } + fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", s.Name, tag, s.LatestVersion, installedStatusFromEntry(s, bothScopes)) } - if projectState != nil { - projectVer = projectState.Skills[name] + tw.Flush() + cmdio.LogString(ctx, buf.String()) + + cmdio.LogString(ctx, summaryLine(out, scope)) +} + +// anyInstalled reports whether at least one skill is installed in the named scope. +func anyInstalled(out listOutput, scope string) bool { + for _, s := range out.Skills { + if _, ok := s.Installed[scope]; ok { + return true + } } + return false +} + +func installedStatusFromEntry(s skillEntry, bothScopes bool) string { + globalVer := s.Installed[installer.ScopeGlobal] + projectVer := s.Installed[installer.ScopeProject] if globalVer == "" && projectVer == "" { return "not installed" } - // If both scopes have the skill, show the project version (takes precedence). if bothScopes && globalVer != "" && projectVer != "" { - return versionLabel(projectVer, latestVersion) + " (project, global)" + return versionLabel(projectVer, s.LatestVersion) + " (project, global)" } if projectVer != "" { - label := versionLabel(projectVer, latestVersion) + label := versionLabel(projectVer, s.LatestVersion) if bothScopes { return label + " (project)" } return label } - label := versionLabel(globalVer, latestVersion) + label := versionLabel(globalVer, s.LatestVersion) if bothScopes { return label + " (global)" } @@ -182,3 +247,25 @@ func versionLabel(installed, latest string) string { } return "v" + installed + " (update available)" } + +func summaryLine(out listOutput, scope string) string { + g, gOK := out.Summary[installer.ScopeGlobal] + p, pOK := out.Summary[installer.ScopeProject] + + switch { + case gOK && pOK: + // Mirror prior behavior: only print the dual-scope line when both + // scopes have a state file; otherwise only mention the one that does. + if anyInstalled(out, installer.ScopeGlobal) && anyInstalled(out, installer.ScopeProject) { + return fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", g.Installed, g.Total, p.Installed, p.Total) + } + if anyInstalled(out, installer.ScopeProject) { + return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total) + } + return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total) + case pOK: + return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total) + default: + return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total) + } +} diff --git a/aitools/cmd/list_test.go b/aitools/cmd/list_test.go index 42d8087f32..595998cf3f 100644 --- a/aitools/cmd/list_test.go +++ b/aitools/cmd/list_test.go @@ -1,6 +1,8 @@ package aitools import ( + "bytes" + "encoding/json" "testing" "github.com/databricks/cli/aitools/lib/installer" @@ -46,6 +48,128 @@ func TestListCommandHasScopeFlags(t *testing.T) { require.NotNil(t, f, "--scope flag should exist") } +func TestRenderListJSON(t *testing.T) { + out := listOutput{ + Release: "0.1.0", + Skills: []skillEntry{ + { + Name: "databricks-jobs", + LatestVersion: "1.0.0", + Experimental: false, + Installed: map[string]string{ + installer.ScopeGlobal: "1.0.0", + installer.ScopeProject: "0.9.0", + }, + }, + { + Name: "experimental-thing", + LatestVersion: "0.1.0", + Experimental: true, + Installed: map[string]string{}, + }, + }, + Summary: map[string]scopeSummary{ + installer.ScopeGlobal: {Installed: 1, Total: 2}, + installer.ScopeProject: {Installed: 1, Total: 2}, + }, + } + + var buf bytes.Buffer + require.NoError(t, renderListJSON(&buf, out)) + + var got listOutput + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, out, got) + + var raw map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &raw)) + assert.Contains(t, raw, "release") + assert.Contains(t, raw, "skills") + assert.Contains(t, raw, "summary") + + skills := raw["skills"].([]any) + first := skills[0].(map[string]any) + assert.Equal(t, "databricks-jobs", first["name"]) + assert.Equal(t, "1.0.0", first["latest_version"]) + assert.Equal(t, false, first["experimental"]) + + installed := first["installed"].(map[string]any) + assert.Equal(t, "1.0.0", installed["global"]) + assert.Equal(t, "0.9.0", installed["project"]) + + second := skills[1].(map[string]any) + assert.Equal(t, true, second["experimental"]) + assert.Empty(t, second["installed"]) +} + +func TestRenderListJSONScopeFiltersSummary(t *testing.T) { + out := listOutput{ + Release: "0.1.0", + Skills: []skillEntry{}, + Summary: map[string]scopeSummary{ + installer.ScopeGlobal: {Installed: 0, Total: 5}, + }, + } + + var buf bytes.Buffer + require.NoError(t, renderListJSON(&buf, out)) + + var raw map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &raw)) + summary := raw["summary"].(map[string]any) + assert.Contains(t, summary, "global") + assert.NotContains(t, summary, "project") +} + +func TestInstalledStatusFromEntry(t *testing.T) { + tests := []struct { + name string + entry skillEntry + bothScopes bool + want string + }{ + { + name: "not installed", + entry: skillEntry{LatestVersion: "1.0.0", Installed: map[string]string{}}, + want: "not installed", + }, + { + name: "global up to date", + entry: skillEntry{ + LatestVersion: "1.0.0", + Installed: map[string]string{installer.ScopeGlobal: "1.0.0"}, + }, + want: "v1.0.0 (up to date)", + }, + { + name: "project update available", + entry: skillEntry{ + LatestVersion: "1.0.0", + Installed: map[string]string{installer.ScopeProject: "0.9.0"}, + }, + want: "v0.9.0 (update available)", + }, + { + name: "both scopes installed", + entry: skillEntry{ + LatestVersion: "1.0.0", + Installed: map[string]string{ + installer.ScopeGlobal: "1.0.0", + installer.ScopeProject: "0.9.0", + }, + }, + bothScopes: true, + want: "v0.9.0 (update available) (project, global)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, installedStatusFromEntry(tt.entry, tt.bothScopes)) + }) + } +} + func TestListScopeFlag(t *testing.T) { tests := []struct { name string