From e71f268df1082a70c065590db682f8b2136bfa0d Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Mon, 18 May 2026 21:57:24 +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 jb/aitools-interface (#5234). Original branch was rebased onto current main + that PR's tip; layout drift from #4917's pre-merge shape was reconciled (cmd/aitools/* paths, unexported listSkillsFn, 3-value installer.GetSkillsRef signature). Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + cmd/aitools/list.go | 229 +++++++++++++++++++++++++++------------ cmd/aitools/list_test.go | 124 +++++++++++++++++++++ 3 files changed, 283 insertions(+), 71 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 806f41eb2e..4500714ee8 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -7,6 +7,7 @@ ### CLI * 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`). +* `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 * 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 8de25c971f..60e392819b 100644 --- a/cmd/aitools/list.go +++ b/cmd/aitools/list.go @@ -1,14 +1,19 @@ package aitools import ( + "context" + "encoding/json" "fmt" + "io" "maps" "slices" "strings" "text/tabwriter" "github.com/databricks/cli/libs/aitools/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,128 +55,188 @@ 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() - 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, _, 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, err := src.FetchManifest(ctx, ref) + 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)} + } + 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 + } + 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 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)" } @@ -185,3 +250,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/cmd/aitools/list_test.go b/cmd/aitools/list_test.go index 5260ad5169..565caa144a 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,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