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/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+
5379func 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\t VERSION\t INSTALLED" )
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\t v%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\t VERSION\t INSTALLED" )
196+ for _ , s := range out .Skills {
197+ tag := ""
198+ if s .Experimental {
199+ tag = " [experimental]"
200+ }
201+ fmt .Fprintf (tw , " %s%s\t v%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