Skip to content

Commit c037584

Browse files
aitools list: emit JSON via --output json (#5233)
## Summary `databricks aitools list` learns `--output json`, emitting a structured document so coding agents and CI can consume the skill/version/installation matrix without scraping the tabwriter text output. Text rendering is unchanged. **Stacked on #4917** (uses `--scope` and the moved-to-top-level `aitools/` package). Base will rebase to `main` once #4917 merges. ## JSON shape ```json { "release": "0.1.0", "skills": [ { "name": "databricks-jobs", "latest_version": "1.0.0", "experimental": false, "installed": { "global": "1.0.0", "project": "0.9.0" } } ], "summary": { "global": { "installed": 5, "total": 10 }, "project": { "installed": 3, "total": 10 } } } ``` - `installed` is keyed by scope; absent key = not installed in that scope; empty map = not installed anywhere. - `summary` only includes scopes that were queried, so `--scope=global` narrows it to one key. - `release` is the version string without the `v` prefix. This is the documented public contract — field names and types should not change without a major version bump. ## 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. Matches the convention used by other CLI commands that already honor `--output json` (`bundle validate`, `pipelines run`, etc.). ## Test plan - [ ] `databricks aitools list --output json` against a workspace with a mix of installed/uninstalled skills, both scopes — JSON validates against the shape above. - [ ] `databricks aitools list --output json --scope=global` — `summary` only contains `global`. - [ ] `databricks aitools list` (no `--output`) — output is byte-for-byte unchanged from main. - [ ] Unit: `TestRenderListJSON`, `TestRenderListJSONScopeFiltersSummary`, `TestInstalledStatusFromEntry` cover the rendering paths. This pull request was AI-assisted by Isaac.
1 parent 7e0236a commit c037584

3 files changed

Lines changed: 357 additions & 72 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* `[__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`.
1313
* `databricks workspace import-dir` now skips `.git`, `.databricks`, and `node_modules` directories during recursive imports. To import one of these directories deliberately, pass it as `SOURCE_PATH` ([#5118](https://github.com/databricks/cli/pull/5118)).
1414
* `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)).
15+
* `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)).
1516

1617
### Bundles
1718
* Make sure warnings asking for approval are understood by agents ([#5239](https://github.com/databricks/cli/pull/5239))

cmd/aitools/list.go

Lines changed: 152 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package aitools
22

33
import (
4+
"context"
5+
"encoding/json"
46
"errors"
57
"fmt"
8+
"io"
69
"maps"
710
"slices"
811
"strings"
912
"text/tabwriter"
1013

14+
"github.com/databricks/cli/cmd/root"
1115
"github.com/databricks/cli/libs/aitools/installer"
1216
"github.com/databricks/cli/libs/cmdio"
17+
"github.com/databricks/cli/libs/flags"
1318
"github.com/databricks/cli/libs/log"
1419
"github.com/spf13/cobra"
1520
)
@@ -58,128 +63,181 @@ func NewListCmd() *cobra.Command {
5863
return cmd
5964
}
6065

66+
// listOutput is the structured representation of `aitools list` used by both
67+
// text rendering and `--output json` consumers. The JSON shape is part of
68+
// the public CLI contract; do not break field names or types.
69+
type listOutput struct {
70+
Release string `json:"release"`
71+
Skills []skillEntry `json:"skills"`
72+
Summary map[string]scopeSummary `json:"summary"`
73+
}
74+
75+
type skillEntry struct {
76+
Name string `json:"name"`
77+
LatestVersion string `json:"latest_version"`
78+
Experimental bool `json:"experimental"`
79+
Installed map[string]string `json:"installed"`
80+
}
81+
82+
type scopeSummary struct {
83+
Installed int `json:"installed"`
84+
Total int `json:"total"`
85+
86+
// loaded preserves text rendering semantics without changing the JSON contract.
87+
loaded bool
88+
}
89+
6190
func defaultListSkills(cmd *cobra.Command, scope string) error {
6291
ctx := cmd.Context()
6392

64-
ref, explicit, err := installer.GetSkillsRef(ctx)
93+
out, err := buildListOutput(ctx, scope)
6594
if err != nil {
6695
return err
6796
}
6897

69-
src := &installer.GitHubManifestSource{}
70-
manifest, ref, err := installer.FetchSkillsManifestWithFallback(ctx, src, ref, !explicit)
71-
if err != nil {
72-
return fmt.Errorf("failed to fetch manifest: %w", err)
98+
switch root.OutputType(cmd) {
99+
case flags.OutputJSON:
100+
return renderListJSON(cmd.OutOrStdout(), out)
101+
default:
102+
renderListText(ctx, out, scope)
103+
return nil
73104
}
105+
}
74106

75-
// Load global state.
76-
var globalState *installer.InstallState
77-
if scope != installer.ScopeProject {
78-
globalDir, gErr := installer.GlobalSkillsDir(ctx)
79-
if gErr == nil {
80-
globalState, err = installer.LoadState(globalDir)
81-
if err != nil {
82-
log.Debugf(ctx, "Could not load global install state: %v", err)
83-
}
84-
}
107+
// buildListOutput fetches the manifest and per-scope install state and
108+
// returns the structured listOutput. scope=="" loads both scopes; "global"
109+
// or "project" loads only that scope.
110+
func buildListOutput(ctx context.Context, scope string) (listOutput, error) {
111+
ref, explicit, err := installer.GetSkillsRef(ctx)
112+
if err != nil {
113+
return listOutput{}, err
85114
}
86115

87-
// Load project state.
88-
var projectState *installer.InstallState
89-
if scope != installer.ScopeGlobal {
90-
projectDir, pErr := installer.ProjectSkillsDir(ctx)
91-
if pErr == nil {
92-
projectState, err = installer.LoadState(projectDir)
93-
if err != nil {
94-
log.Debugf(ctx, "Could not load project install state: %v", err)
95-
}
96-
}
116+
src := &installer.GitHubManifestSource{}
117+
manifest, ref, err := installer.FetchSkillsManifestWithFallback(ctx, src, ref, !explicit)
118+
if err != nil {
119+
return listOutput{}, fmt.Errorf("failed to fetch manifest: %w", err)
97120
}
98121

99-
// Build sorted list of skill names.
100-
names := slices.Sorted(maps.Keys(manifest.Skills))
101-
102-
version := strings.TrimPrefix(ref, "v")
103-
cmdio.LogString(ctx, "Available skills (v"+version+"):")
104-
cmdio.LogString(ctx, "")
122+
globalState := loadStateForScope(ctx, scope, installer.ScopeProject, installer.GlobalSkillsDir, "global")
123+
projectState := loadStateForScope(ctx, scope, installer.ScopeGlobal, installer.ProjectSkillsDir, "project")
105124

106-
var buf strings.Builder
107-
tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
108-
fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED")
125+
names := slices.Sorted(maps.Keys(manifest.Skills))
109126

110-
bothScopes := globalState != nil && projectState != nil
127+
out := listOutput{
128+
Release: strings.TrimPrefix(ref, "v"),
129+
Skills: make([]skillEntry, 0, len(names)),
130+
Summary: map[string]scopeSummary{},
131+
}
111132

112-
globalCount := 0
113-
projectCount := 0
133+
globalCount, projectCount := 0, 0
114134
for _, name := range names {
115135
meta := manifest.Skills[name]
116-
117-
tag := ""
118-
if meta.IsExperimental() {
119-
tag = " [experimental]"
136+
entry := skillEntry{
137+
Name: name,
138+
LatestVersion: meta.Version,
139+
Experimental: meta.IsExperimental(),
140+
Installed: map[string]string{},
120141
}
121-
122-
installedStr := installedStatus(name, meta.Version, globalState, projectState, bothScopes)
123142
if globalState != nil {
124-
if _, ok := globalState.Skills[name]; ok {
143+
if v, ok := globalState.Skills[name]; ok {
144+
entry.Installed[installer.ScopeGlobal] = v
125145
globalCount++
126146
}
127147
}
128148
if projectState != nil {
129-
if _, ok := projectState.Skills[name]; ok {
149+
if v, ok := projectState.Skills[name]; ok {
150+
entry.Installed[installer.ScopeProject] = v
130151
projectCount++
131152
}
132153
}
154+
out.Skills = append(out.Skills, entry)
155+
}
133156

134-
fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", name, tag, meta.Version, installedStr)
157+
// Include a summary entry for every scope that was queried, even when the
158+
// install state is missing — agents should see "0/N" rather than guess
159+
// from the absence of a key.
160+
if scope != installer.ScopeProject {
161+
out.Summary[installer.ScopeGlobal] = scopeSummary{Installed: globalCount, Total: len(names), loaded: globalState != nil}
162+
}
163+
if scope != installer.ScopeGlobal {
164+
out.Summary[installer.ScopeProject] = scopeSummary{Installed: projectCount, Total: len(names), loaded: projectState != nil}
135165
}
136-
tw.Flush()
137-
cmdio.LogString(ctx, buf.String())
138166

139-
// Summary line.
140-
switch {
141-
case bothScopes:
142-
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", globalCount, len(names), projectCount, len(names)))
143-
case projectState != nil:
144-
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", projectCount, len(names)))
145-
case scope == installer.ScopeProject:
146-
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", 0, len(names)))
147-
default:
148-
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", globalCount, len(names)))
167+
return out, nil
168+
}
169+
170+
// loadStateForScope returns the install state for the named scope when the
171+
// scope filter allows it. excludeScope is the scope value that means "skip
172+
// loading this one" (so passing ScopeProject to the global loader skips
173+
// global when --scope=project).
174+
func loadStateForScope(ctx context.Context, scopeFilter, excludeScope string, dirFn func(context.Context) (string, error), label string) *installer.InstallState {
175+
if scopeFilter == excludeScope {
176+
return nil
149177
}
150-
return nil
178+
dir, err := dirFn(ctx)
179+
if err != nil {
180+
return nil
181+
}
182+
state, err := installer.LoadState(dir)
183+
if err != nil {
184+
log.Debugf(ctx, "Could not load %s install state: %v", label, err)
185+
return nil
186+
}
187+
return state
151188
}
152189

153-
// installedStatus returns the display string for a skill's installation status.
154-
func installedStatus(name, latestVersion string, globalState, projectState *installer.InstallState, bothScopes bool) string {
155-
globalVer := ""
156-
projectVer := ""
190+
func renderListJSON(w io.Writer, out listOutput) error {
191+
enc := json.NewEncoder(w)
192+
enc.SetIndent("", " ")
193+
return enc.Encode(out)
194+
}
157195

158-
if globalState != nil {
159-
globalVer = globalState.Skills[name]
160-
}
161-
if projectState != nil {
162-
projectVer = projectState.Skills[name]
196+
func renderListText(ctx context.Context, out listOutput, scope string) {
197+
cmdio.LogString(ctx, "Available skills (v"+out.Release+"):")
198+
cmdio.LogString(ctx, "")
199+
200+
bothScopes := scope == "" &&
201+
out.Summary[installer.ScopeGlobal].loaded &&
202+
out.Summary[installer.ScopeProject].loaded
203+
204+
var buf strings.Builder
205+
tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
206+
fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED")
207+
for _, s := range out.Skills {
208+
tag := ""
209+
if s.Experimental {
210+
tag = " [experimental]"
211+
}
212+
fmt.Fprintf(tw, " %s%s\tv%s\t%s\n", s.Name, tag, s.LatestVersion, installedStatusFromEntry(s, bothScopes))
163213
}
214+
tw.Flush()
215+
cmdio.LogString(ctx, buf.String())
216+
217+
cmdio.LogString(ctx, summaryLine(out, scope))
218+
}
219+
220+
func installedStatusFromEntry(s skillEntry, bothScopes bool) string {
221+
globalVer := s.Installed[installer.ScopeGlobal]
222+
projectVer := s.Installed[installer.ScopeProject]
164223

165224
if globalVer == "" && projectVer == "" {
166225
return "not installed"
167226
}
168227

169-
// If both scopes have the skill, show the project version (takes precedence).
170228
if bothScopes && globalVer != "" && projectVer != "" {
171-
return versionLabel(projectVer, latestVersion) + " (project, global)"
229+
return versionLabel(projectVer, s.LatestVersion) + " (project, global)"
172230
}
173231

174232
if projectVer != "" {
175-
label := versionLabel(projectVer, latestVersion)
233+
label := versionLabel(projectVer, s.LatestVersion)
176234
if bothScopes {
177235
return label + " (project)"
178236
}
179237
return label
180238
}
181239

182-
label := versionLabel(globalVer, latestVersion)
240+
label := versionLabel(globalVer, s.LatestVersion)
183241
if bothScopes {
184242
return label + " (global)"
185243
}
@@ -193,3 +251,25 @@ func versionLabel(installed, latest string) string {
193251
}
194252
return "v" + installed + " (update available)"
195253
}
254+
255+
func summaryLine(out listOutput, scope string) string {
256+
g, gOK := out.Summary[installer.ScopeGlobal]
257+
p, pOK := out.Summary[installer.ScopeProject]
258+
259+
switch {
260+
case gOK && pOK:
261+
// Mirror prior behavior: only print the dual-scope line when both
262+
// scopes have a state file; otherwise only mention the one that does.
263+
if g.loaded && p.loaded {
264+
return fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", g.Installed, g.Total, p.Installed, p.Total)
265+
}
266+
if p.loaded {
267+
return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total)
268+
}
269+
return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total)
270+
case pOK:
271+
return fmt.Sprintf("%d/%d skills installed (project)", p.Installed, p.Total)
272+
default:
273+
return fmt.Sprintf("%d/%d skills installed (global)", g.Installed, g.Total)
274+
}
275+
}

0 commit comments

Comments
 (0)