11package aitools
22
33import (
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+
6190func 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\t VERSION\t INSTALLED" )
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 .Experimental {
119- tag = " [experimental]"
136+ entry := skillEntry {
137+ Name : name ,
138+ LatestVersion : meta .Version ,
139+ Experimental : meta .Experimental ,
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\t v%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\t VERSION\t INSTALLED" )
207+ for _ , s := range out .Skills {
208+ tag := ""
209+ if s .Experimental {
210+ tag = " [experimental]"
211+ }
212+ fmt .Fprintf (tw , " %s%s\t v%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