11package aitools
22
33import (
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+
5379func 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\t VERSION\t INSTALLED" )
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\t v%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\t VERSION\t INSTALLED" )
193+ for _ , s := range out .Skills {
194+ tag := ""
195+ if s .Experimental {
196+ tag = " [experimental]"
197+ }
198+ fmt .Fprintf (tw , " %s%s\t v%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