Skip to content

Commit c4ef02c

Browse files
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
1 parent 7666312 commit c4ef02c

3 files changed

Lines changed: 283 additions & 71 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
### CLI
88

99
* 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`).
10+
* `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.
1011

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

cmd/aitools/list.go

Lines changed: 158 additions & 71 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/libs/aitools/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,128 +55,188 @@ 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

56-
ref, explicit, err := installer.GetSkillsRef(ctx)
82+
out, err := buildListOutput(ctx, scope)
5783
if err != nil {
5884
return err
5985
}
6086

61-
src := &installer.GitHubManifestSource{}
62-
manifest, ref, err := installer.FetchSkillsManifestWithFallback(ctx, src, ref, !explicit)
63-
if err != nil {
64-
return fmt.Errorf("failed to fetch manifest: %w", err)
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
6593
}
94+
}
6695

67-
// Load global state.
68-
var globalState *installer.InstallState
69-
if scope != installer.ScopeProject {
70-
globalDir, gErr := installer.GlobalSkillsDir(ctx)
71-
if gErr == nil {
72-
globalState, err = installer.LoadState(globalDir)
73-
if err != nil {
74-
log.Debugf(ctx, "Could not load global install state: %v", err)
75-
}
76-
}
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) {
100+
ref, _, err := installer.GetSkillsRef(ctx)
101+
if err != nil {
102+
return listOutput{}, err
77103
}
78104

79-
// Load project state.
80-
var projectState *installer.InstallState
81-
if scope != installer.ScopeGlobal {
82-
projectDir, pErr := installer.ProjectSkillsDir(ctx)
83-
if pErr == nil {
84-
projectState, err = installer.LoadState(projectDir)
85-
if err != nil {
86-
log.Debugf(ctx, "Could not load project install state: %v", err)
87-
}
88-
}
105+
src := &installer.GitHubManifestSource{}
106+
manifest, err := src.FetchManifest(ctx, ref)
107+
if err != nil {
108+
return listOutput{}, fmt.Errorf("failed to fetch manifest: %w", err)
89109
}
90110

91-
// Build sorted list of skill names.
92-
names := slices.Sorted(maps.Keys(manifest.Skills))
93-
94-
version := strings.TrimPrefix(ref, "v")
95-
cmdio.LogString(ctx, "Available skills (v"+version+"):")
96-
cmdio.LogString(ctx, "")
111+
globalState := loadStateForScope(ctx, scope, installer.ScopeProject, installer.GlobalSkillsDir, "global")
112+
projectState := loadStateForScope(ctx, scope, installer.ScopeGlobal, installer.ProjectSkillsDir, "project")
97113

98-
var buf strings.Builder
99-
tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
100-
fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED")
114+
names := slices.Sorted(maps.Keys(manifest.Skills))
101115

102-
bothScopes := globalState != nil && projectState != nil
116+
out := listOutput{
117+
Release: strings.TrimPrefix(ref, "v"),
118+
Skills: make([]skillEntry, 0, len(names)),
119+
Summary: map[string]scopeSummary{},
120+
}
103121

104-
globalCount := 0
105-
projectCount := 0
122+
globalCount, projectCount := 0, 0
106123
for _, name := range names {
107124
meta := manifest.Skills[name]
108-
109-
tag := ""
110-
if meta.Experimental {
111-
tag = " [experimental]"
125+
entry := skillEntry{
126+
Name: name,
127+
LatestVersion: meta.Version,
128+
Experimental: meta.Experimental,
129+
Installed: map[string]string{},
112130
}
113-
114-
installedStr := installedStatus(name, meta.Version, globalState, projectState, bothScopes)
115131
if globalState != nil {
116-
if _, ok := globalState.Skills[name]; ok {
132+
if v, ok := globalState.Skills[name]; ok {
133+
entry.Installed[installer.ScopeGlobal] = v
117134
globalCount++
118135
}
119136
}
120137
if projectState != nil {
121-
if _, ok := projectState.Skills[name]; ok {
138+
if v, ok := projectState.Skills[name]; ok {
139+
entry.Installed[installer.ScopeProject] = v
122140
projectCount++
123141
}
124142
}
143+
out.Skills = append(out.Skills, entry)
144+
}
125145

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

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

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

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

157223
if globalVer == "" && projectVer == "" {
158224
return "not installed"
159225
}
160226

161-
// If both scopes have the skill, show the project version (takes precedence).
162227
if bothScopes && globalVer != "" && projectVer != "" {
163-
return versionLabel(projectVer, latestVersion) + " (project, global)"
228+
return versionLabel(projectVer, s.LatestVersion) + " (project, global)"
164229
}
165230

166231
if projectVer != "" {
167-
label := versionLabel(projectVer, latestVersion)
232+
label := versionLabel(projectVer, s.LatestVersion)
168233
if bothScopes {
169234
return label + " (project)"
170235
}
171236
return label
172237
}
173238

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

0 commit comments

Comments
 (0)