Skip to content

Commit c02b45c

Browse files
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
1 parent b3b6d1a commit c02b45c

3 files changed

Lines changed: 282 additions & 70 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* JSON output for single objects now uses standard `"key": "value"` spacing (matching list output and `encoding/json` defaults).
1010
* `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).
1111
* 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.
12+
* `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.
1213

1314
### Bundles
1415
* Stop applying `presets.name_prefix` (and the dev-mode `[dev <user>]` rename) to `vector_search_endpoints` ([#5209](https://github.com/databricks/cli/pull/5209)).

aitools/cmd/list.go

Lines changed: 157 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package aitools
22

33
import (
4+
"context"
5+
"encoding/json"
46
"fmt"
7+
"io"
58
"maps"
69
"slices"
710
"strings"
811
"text/tabwriter"
912

1013
"github.com/databricks/cli/aitools/lib/installer"
14+
"github.com/databricks/cli/cmd/root"
1115
"github.com/databricks/cli/libs/cmdio"
16+
"github.com/databricks/cli/libs/flags"
1217
"github.com/databricks/cli/libs/log"
1318
"github.com/spf13/cobra"
1419
)
@@ -50,125 +55,185 @@ func NewListCmd() *cobra.Command {
5055
return cmd
5156
}
5257

58+
// listOutput is the structured representation of `aitools list` used by both
59+
// text rendering and `--output json` consumers. The JSON shape is part of
60+
// the public CLI contract; do not break field names or types.
61+
type listOutput struct {
62+
Release string `json:"release"`
63+
Skills []skillEntry `json:"skills"`
64+
Summary map[string]scopeSummary `json:"summary"`
65+
}
66+
67+
type skillEntry struct {
68+
Name string `json:"name"`
69+
LatestVersion string `json:"latest_version"`
70+
Experimental bool `json:"experimental"`
71+
Installed map[string]string `json:"installed"`
72+
}
73+
74+
type scopeSummary struct {
75+
Installed int `json:"installed"`
76+
Total int `json:"total"`
77+
}
78+
5379
func defaultListSkills(cmd *cobra.Command, scope string) error {
5480
ctx := cmd.Context()
5581

82+
out, err := buildListOutput(ctx, scope)
83+
if err != nil {
84+
return err
85+
}
86+
87+
switch root.OutputType(cmd) {
88+
case flags.OutputJSON:
89+
return renderListJSON(cmd.OutOrStdout(), out)
90+
default:
91+
renderListText(ctx, out, scope)
92+
return nil
93+
}
94+
}
95+
96+
// buildListOutput fetches the manifest and per-scope install state and
97+
// returns the structured listOutput. scope=="" loads both scopes; "global"
98+
// or "project" loads only that scope.
99+
func buildListOutput(ctx context.Context, scope string) (listOutput, error) {
56100
ref := installer.GetSkillsRef(ctx)
57101

58102
src := &installer.GitHubManifestSource{}
59103
manifest, err := src.FetchManifest(ctx, ref)
60104
if err != nil {
61-
return fmt.Errorf("failed to fetch manifest: %w", err)
62-
}
63-
64-
// Load global state.
65-
var globalState *installer.InstallState
66-
if scope != installer.ScopeProject {
67-
globalDir, gErr := installer.GlobalSkillsDir(ctx)
68-
if gErr == nil {
69-
globalState, err = installer.LoadState(globalDir)
70-
if err != nil {
71-
log.Debugf(ctx, "Could not load global install state: %v", err)
72-
}
73-
}
105+
return listOutput{}, fmt.Errorf("failed to fetch manifest: %w", err)
74106
}
75107

76-
// Load project state.
77-
var projectState *installer.InstallState
78-
if scope != installer.ScopeGlobal {
79-
projectDir, pErr := installer.ProjectSkillsDir(ctx)
80-
if pErr == nil {
81-
projectState, err = installer.LoadState(projectDir)
82-
if err != nil {
83-
log.Debugf(ctx, "Could not load project install state: %v", err)
84-
}
85-
}
86-
}
108+
globalState := loadStateForScope(ctx, scope, installer.ScopeProject, installer.GlobalSkillsDir, "global")
109+
projectState := loadStateForScope(ctx, scope, installer.ScopeGlobal, installer.ProjectSkillsDir, "project")
87110

88-
// Build sorted list of skill names.
89111
names := slices.Sorted(maps.Keys(manifest.Skills))
90112

91-
version := strings.TrimPrefix(ref, "v")
92-
cmdio.LogString(ctx, "Available skills (v"+version+"):")
93-
cmdio.LogString(ctx, "")
94-
95-
var buf strings.Builder
96-
tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
97-
fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED")
98-
99-
bothScopes := globalState != nil && projectState != nil
113+
out := listOutput{
114+
Release: strings.TrimPrefix(ref, "v"),
115+
Skills: make([]skillEntry, 0, len(names)),
116+
Summary: map[string]scopeSummary{},
117+
}
100118

101-
globalCount := 0
102-
projectCount := 0
119+
globalCount, projectCount := 0, 0
103120
for _, name := range names {
104121
meta := manifest.Skills[name]
105-
106-
tag := ""
107-
if meta.Experimental {
108-
tag = " [experimental]"
122+
entry := skillEntry{
123+
Name: name,
124+
LatestVersion: meta.Version,
125+
Experimental: meta.Experimental,
126+
Installed: map[string]string{},
109127
}
110-
111-
installedStr := installedStatus(name, meta.Version, globalState, projectState, bothScopes)
112128
if globalState != nil {
113-
if _, ok := globalState.Skills[name]; ok {
129+
if v, ok := globalState.Skills[name]; ok {
130+
entry.Installed[installer.ScopeGlobal] = v
114131
globalCount++
115132
}
116133
}
117134
if projectState != nil {
118-
if _, ok := projectState.Skills[name]; ok {
135+
if v, ok := projectState.Skills[name]; ok {
136+
entry.Installed[installer.ScopeProject] = v
119137
projectCount++
120138
}
121139
}
140+
out.Skills = append(out.Skills, entry)
141+
}
122142

123-
fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", name, tag, meta.Version, installedStr)
143+
// Include a summary entry for every scope that was queried, even when the
144+
// install state is missing — agents should see "0/N" rather than guess
145+
// from the absence of a key.
146+
if scope != installer.ScopeProject {
147+
out.Summary[installer.ScopeGlobal] = scopeSummary{Installed: globalCount, Total: len(names)}
148+
}
149+
if scope != installer.ScopeGlobal {
150+
out.Summary[installer.ScopeProject] = scopeSummary{Installed: projectCount, Total: len(names)}
124151
}
125-
tw.Flush()
126-
cmdio.LogString(ctx, buf.String())
127152

128-
// Summary line.
129-
switch {
130-
case bothScopes:
131-
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", globalCount, len(names), projectCount, len(names)))
132-
case projectState != nil:
133-
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", projectCount, len(names)))
134-
case scope == installer.ScopeProject:
135-
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", 0, len(names)))
136-
default:
137-
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", globalCount, len(names)))
153+
return out, nil
154+
}
155+
156+
// loadStateForScope returns the install state for the named scope when the
157+
// scope filter allows it. excludeScope is the scope value that means "skip
158+
// loading this one" (so passing ScopeProject to the global loader skips
159+
// global when --scope=project).
160+
func loadStateForScope(ctx context.Context, scopeFilter, excludeScope string, dirFn func(context.Context) (string, error), label string) *installer.InstallState {
161+
if scopeFilter == excludeScope {
162+
return nil
138163
}
139-
return nil
164+
dir, err := dirFn(ctx)
165+
if err != nil {
166+
return nil
167+
}
168+
state, err := installer.LoadState(dir)
169+
if err != nil {
170+
log.Debugf(ctx, "Could not load %s install state: %v", label, err)
171+
return nil
172+
}
173+
return state
174+
}
175+
176+
func renderListJSON(w io.Writer, out listOutput) error {
177+
enc := json.NewEncoder(w)
178+
enc.SetIndent("", " ")
179+
return enc.Encode(out)
140180
}
141181

142-
// installedStatus returns the display string for a skill's installation status.
143-
func installedStatus(name, latestVersion string, globalState, projectState *installer.InstallState, bothScopes bool) string {
144-
globalVer := ""
145-
projectVer := ""
182+
func renderListText(ctx context.Context, out listOutput, scope string) {
183+
cmdio.LogString(ctx, "Available skills (v"+out.Release+"):")
184+
cmdio.LogString(ctx, "")
185+
186+
bothScopes := scope == "" && len(out.Summary) == 2 &&
187+
out.Summary[installer.ScopeGlobal].Installed+out.Summary[installer.ScopeProject].Installed > 0 &&
188+
anyInstalled(out, installer.ScopeGlobal) && anyInstalled(out, installer.ScopeProject)
146189

147-
if globalState != nil {
148-
globalVer = globalState.Skills[name]
190+
var buf strings.Builder
191+
tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
192+
fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED")
193+
for _, s := range out.Skills {
194+
tag := ""
195+
if s.Experimental {
196+
tag = " [experimental]"
197+
}
198+
fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", s.Name, tag, s.LatestVersion, installedStatusFromEntry(s, bothScopes))
149199
}
150-
if projectState != nil {
151-
projectVer = projectState.Skills[name]
200+
tw.Flush()
201+
cmdio.LogString(ctx, buf.String())
202+
203+
cmdio.LogString(ctx, summaryLine(out, scope))
204+
}
205+
206+
// anyInstalled reports whether at least one skill is installed in the named scope.
207+
func anyInstalled(out listOutput, scope string) bool {
208+
for _, s := range out.Skills {
209+
if _, ok := s.Installed[scope]; ok {
210+
return true
211+
}
152212
}
213+
return false
214+
}
215+
216+
func installedStatusFromEntry(s skillEntry, bothScopes bool) string {
217+
globalVer := s.Installed[installer.ScopeGlobal]
218+
projectVer := s.Installed[installer.ScopeProject]
153219

154220
if globalVer == "" && projectVer == "" {
155221
return "not installed"
156222
}
157223

158-
// If both scopes have the skill, show the project version (takes precedence).
159224
if bothScopes && globalVer != "" && projectVer != "" {
160-
return versionLabel(projectVer, latestVersion) + " (project, global)"
225+
return versionLabel(projectVer, s.LatestVersion) + " (project, global)"
161226
}
162227

163228
if projectVer != "" {
164-
label := versionLabel(projectVer, latestVersion)
229+
label := versionLabel(projectVer, s.LatestVersion)
165230
if bothScopes {
166231
return label + " (project)"
167232
}
168233
return label
169234
}
170235

171-
label := versionLabel(globalVer, latestVersion)
236+
label := versionLabel(globalVer, s.LatestVersion)
172237
if bothScopes {
173238
return label + " (global)"
174239
}
@@ -182,3 +247,25 @@ func versionLabel(installed, latest string) string {
182247
}
183248
return "v" + installed + " (update available)"
184249
}
250+
251+
func summaryLine(out listOutput, scope string) string {
252+
g, gOK := out.Summary[installer.ScopeGlobal]
253+
p, pOK := out.Summary[installer.ScopeProject]
254+
255+
switch {
256+
case gOK && pOK:
257+
// Mirror prior behavior: only print the dual-scope line when both
258+
// scopes have a state file; otherwise only mention the one that does.
259+
if anyInstalled(out, installer.ScopeGlobal) && anyInstalled(out, installer.ScopeProject) {
260+
return fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", g.Installed, g.Total, p.Installed, p.Total)
261+
}
262+
if anyInstalled(out, installer.ScopeProject) {
263+
return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total)
264+
}
265+
return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total)
266+
case pOK:
267+
return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total)
268+
default:
269+
return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total)
270+
}
271+
}

0 commit comments

Comments
 (0)