From f8f0e15e7c2b60c000c00fbb7db2aec8ff99302d Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Wed, 20 May 2026 15:38:04 +0000 Subject: [PATCH] aitools list: emit JSON via --output json Teaches list to render as a structured {release, skills[...], summary{}} document when --output json is passed. Text rendering is unchanged. Stacked-on-#5234 rebased onto main now that #5234 has merged. The branch state was carrying stale rehashes of the scope-flag work; squashed onto current main to keep only the JSON-output delta. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + cmd/aitools/list.go | 224 ++++++++++++++++++++++++++------------- cmd/aitools/list_test.go | 204 +++++++++++++++++++++++++++++++++++ 3 files changed, 357 insertions(+), 72 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 935042bdf4..db88528e04 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -11,6 +11,7 @@ * Added `databricks aitools` command group for installing Databricks skills into your coding agents (Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity). Skills are fetched from [github.com/databricks/databricks-agent-skills](https://github.com/databricks/databricks-agent-skills) and either symlinked into each agent's skills directory or copied into the current project. Use `databricks aitools install` to set up, `update` to pull newer versions, `list` to see what's available, and `uninstall` to remove them. Pick where they go with `--scope=project|global` (`--scope=both` is accepted on `update` and `list`). * `[__settings__].default_profile` is now consulted as a fallback by `databricks api`, `databricks auth token`, and bundle commands when neither `--profile` nor `DATABRICKS_CONFIG_PROFILE` is set. `databricks auth token` continues to give precedence to `DATABRICKS_HOST` over `default_profile`. For bundle commands, `default_profile` only applies when the bundle does not pin its own `workspace.host`. * `databricks postgres create-role --help` now documents the `--json` body shape and rejects the common mistake of wrapping the body in `{"role": ...}` client-side with a hint pointing at the correct shape ([#5111](https://github.com/databricks/cli/pull/5111)). +* `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 ([#5233](https://github.com/databricks/cli/pull/5233)). ### Bundles * Make sure warnings asking for approval are understood by agents ([#5239](https://github.com/databricks/cli/pull/5239)) diff --git a/cmd/aitools/list.go b/cmd/aitools/list.go index 62d17f3664..961ff4c366 100644 --- a/cmd/aitools/list.go +++ b/cmd/aitools/list.go @@ -1,15 +1,20 @@ package aitools import ( + "context" + "encoding/json" "errors" "fmt" + "io" "maps" "slices" "strings" "text/tabwriter" + "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/aitools/installer" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/flags" "github.com/databricks/cli/libs/log" "github.com/spf13/cobra" ) @@ -58,128 +63,181 @@ 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"` + + // loaded preserves text rendering semantics without changing the JSON contract. + loaded bool +} + func defaultListSkills(cmd *cobra.Command, scope string) error { ctx := cmd.Context() - ref, explicit, err := installer.GetSkillsRef(ctx) + out, err := buildListOutput(ctx, scope) if err != nil { return err } - src := &installer.GitHubManifestSource{} - manifest, ref, err := installer.FetchSkillsManifestWithFallback(ctx, src, ref, !explicit) - if err != nil { - return fmt.Errorf("failed to fetch manifest: %w", err) + switch root.OutputType(cmd) { + case flags.OutputJSON: + return renderListJSON(cmd.OutOrStdout(), out) + default: + renderListText(ctx, out, scope) + return nil } +} - // 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) - } - } +// 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, explicit, err := installer.GetSkillsRef(ctx) + if err != nil { + return listOutput{}, 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) - } - } + src := &installer.GitHubManifestSource{} + manifest, ref, err := installer.FetchSkillsManifestWithFallback(ctx, src, ref, !explicit) + if err != nil { + return listOutput{}, fmt.Errorf("failed to fetch manifest: %w", err) } - // 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, "") + globalState := loadStateForScope(ctx, scope, installer.ScopeProject, installer.GlobalSkillsDir, "global") + projectState := loadStateForScope(ctx, scope, installer.ScopeGlobal, installer.ProjectSkillsDir, "project") - var buf strings.Builder - tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) - fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED") + names := slices.Sorted(maps.Keys(manifest.Skills)) - 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), loaded: globalState != nil} + } + if scope != installer.ScopeGlobal { + out.Summary[installer.ScopeProject] = scopeSummary{Installed: projectCount, Total: len(names), loaded: projectState != nil} } - 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 } -// 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 renderListJSON(w io.Writer, out listOutput) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(out) +} - if globalState != nil { - globalVer = globalState.Skills[name] - } - if projectState != nil { - projectVer = projectState.Skills[name] +func renderListText(ctx context.Context, out listOutput, scope string) { + cmdio.LogString(ctx, "Available skills (v"+out.Release+"):") + cmdio.LogString(ctx, "") + + bothScopes := scope == "" && + out.Summary[installer.ScopeGlobal].loaded && + out.Summary[installer.ScopeProject].loaded + + 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)) } + tw.Flush() + cmdio.LogString(ctx, buf.String()) + + cmdio.LogString(ctx, summaryLine(out, scope)) +} + +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)" } @@ -193,3 +251,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 g.loaded && p.loaded { + return fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", g.Installed, g.Total, p.Installed, p.Total) + } + if p.loaded { + 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/cmd/aitools/list_test.go b/cmd/aitools/list_test.go index 5260ad5169..e8ed690ca5 100644 --- a/cmd/aitools/list_test.go +++ b/cmd/aitools/list_test.go @@ -1,6 +1,8 @@ package aitools import ( + "bytes" + "encoding/json" "testing" "github.com/databricks/cli/libs/aitools/installer" @@ -46,6 +48,208 @@ 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 TestSummaryLinePreservesStatePresence(t *testing.T) { + tests := []struct { + name string + out listOutput + want string + }{ + { + name: "both state files loaded even with no installs", + out: listOutput{ + Skills: []skillEntry{ + {Name: "databricks-jobs", LatestVersion: "1.0.0", Installed: map[string]string{}}, + }, + Summary: map[string]scopeSummary{ + installer.ScopeGlobal: {Installed: 0, Total: 1, loaded: true}, + installer.ScopeProject: {Installed: 0, Total: 1, loaded: true}, + }, + }, + want: "0/1 skills installed (global), 0/1 (project)", + }, + { + name: "only project state loaded", + out: listOutput{ + Skills: []skillEntry{ + {Name: "databricks-jobs", LatestVersion: "1.0.0", Installed: map[string]string{}}, + }, + Summary: map[string]scopeSummary{ + installer.ScopeGlobal: {Installed: 0, Total: 1}, + installer.ScopeProject: {Installed: 0, Total: 1, loaded: true}, + }, + }, + want: "0/1 skills installed (project)", + }, + { + name: "only global state loaded", + out: listOutput{ + Skills: []skillEntry{ + {Name: "databricks-jobs", LatestVersion: "1.0.0", Installed: map[string]string{}}, + }, + Summary: map[string]scopeSummary{ + installer.ScopeGlobal: {Installed: 0, Total: 1, loaded: true}, + installer.ScopeProject: {Installed: 0, Total: 1}, + }, + }, + want: "0/1 skills installed (global)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, summaryLine(tt.out, "")) + }) + } +} + +func TestRenderListTextUsesLoadedStateForScopeLabels(t *testing.T) { + ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) + out := listOutput{ + Release: "0.1.0", + Skills: []skillEntry{ + { + Name: "databricks-jobs", + LatestVersion: "1.0.0", + Installed: map[string]string{ + installer.ScopeGlobal: "1.0.0", + }, + }, + }, + Summary: map[string]scopeSummary{ + installer.ScopeGlobal: {Installed: 1, Total: 1, loaded: true}, + installer.ScopeProject: {Installed: 0, Total: 1, loaded: true}, + }, + } + + renderListText(ctx, out, "") + + got := stderr.String() + assert.Contains(t, got, "v1.0.0 (up to date) (global)") + assert.Contains(t, got, "1/1 skills installed (global), 0/1 (project)") +} + func TestListScopeFlag(t *testing.T) { tests := []struct { name string