Skip to content

Commit a17092c

Browse files
committed
Address review findings: relative symlinks, project scope list, zero-agent guard, param cleanup
Co-authored-by: Isaac
1 parent 3ff3e69 commit a17092c

File tree

9 files changed

+244
-38
lines changed

9 files changed

+244
-38
lines changed

experimental/aitools/cmd/list.go

Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package aitools
22

33
import (
4+
"errors"
45
"fmt"
56
"sort"
67
"strings"
@@ -17,6 +18,8 @@ import (
1718
var listSkillsFn = defaultListSkills
1819

1920
func newListCmd() *cobra.Command {
21+
var projectFlag, globalFlag bool
22+
2023
// --skills is accepted for forward-compat (future component types)
2124
// but currently skills is the only component, so the output is the same.
2225
var showSkills bool
@@ -25,16 +28,27 @@ func newListCmd() *cobra.Command {
2528
Use: "list",
2629
Short: "List installed AI tools components",
2730
RunE: func(cmd *cobra.Command, args []string) error {
28-
_ = showSkills
29-
return listSkillsFn(cmd)
31+
if projectFlag && globalFlag {
32+
return errors.New("cannot use --global and --project together")
33+
}
34+
// For list: no flag = show both scopes (empty string).
35+
var scope string
36+
if projectFlag {
37+
scope = installer.ScopeProject
38+
} else if globalFlag {
39+
scope = installer.ScopeGlobal
40+
}
41+
return listSkillsFn(cmd, scope)
3042
},
3143
}
3244

3345
cmd.Flags().BoolVar(&showSkills, "skills", false, "Show detailed skills information")
46+
cmd.Flags().BoolVar(&projectFlag, "project", false, "Show only project-scoped skills")
47+
cmd.Flags().BoolVar(&globalFlag, "global", false, "Show only globally-scoped skills")
3448
return cmd
3549
}
3650

37-
func defaultListSkills(cmd *cobra.Command) error {
51+
func defaultListSkills(cmd *cobra.Command, scope string) error {
3852
ctx := cmd.Context()
3953

4054
src := &installer.GitHubManifestSource{}
@@ -48,14 +62,28 @@ func defaultListSkills(cmd *cobra.Command) error {
4862
return fmt.Errorf("failed to fetch manifest: %w", err)
4963
}
5064

51-
globalDir, err := installer.GlobalSkillsDir(ctx)
52-
if err != nil {
53-
return err
65+
// Load global state.
66+
var globalState *installer.InstallState
67+
if scope != installer.ScopeProject {
68+
globalDir, gErr := installer.GlobalSkillsDir(ctx)
69+
if gErr == nil {
70+
globalState, err = installer.LoadState(globalDir)
71+
if err != nil {
72+
log.Debugf(ctx, "Could not load global install state: %v", err)
73+
}
74+
}
5475
}
5576

56-
state, err := installer.LoadState(globalDir)
57-
if err != nil {
58-
log.Debugf(ctx, "Could not load install state: %v", err)
77+
// Load project state.
78+
var projectState *installer.InstallState
79+
if scope != installer.ScopeGlobal {
80+
projectDir, pErr := installer.ProjectSkillsDir(ctx)
81+
if pErr == nil {
82+
projectState, err = installer.LoadState(projectDir)
83+
if err != nil {
84+
log.Debugf(ctx, "Could not load project install state: %v", err)
85+
}
86+
}
5987
}
6088

6189
// Build sorted list of skill names.
@@ -73,7 +101,10 @@ func defaultListSkills(cmd *cobra.Command) error {
73101
tw := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0)
74102
fmt.Fprintln(tw, " NAME\tVERSION\tINSTALLED")
75103

76-
installedCount := 0
104+
bothScopes := globalState != nil && projectState != nil
105+
106+
globalCount := 0
107+
projectCount := 0
77108
for _, name := range names {
78109
meta := manifest.Skills[name]
79110

@@ -82,15 +113,15 @@ func defaultListSkills(cmd *cobra.Command) error {
82113
tag = " [experimental]"
83114
}
84115

85-
installedStr := "not installed"
86-
if state != nil {
87-
if v, ok := state.Skills[name]; ok {
88-
installedCount++
89-
if v == meta.Version {
90-
installedStr = "v" + v + " (up to date)"
91-
} else {
92-
installedStr = "v" + v + " (update available)"
93-
}
116+
installedStr := installedStatus(name, meta.Version, globalState, projectState, bothScopes)
117+
if globalState != nil {
118+
if _, ok := globalState.Skills[name]; ok {
119+
globalCount++
120+
}
121+
}
122+
if projectState != nil {
123+
if _, ok := projectState.Skills[name]; ok {
124+
projectCount++
94125
}
95126
}
96127

@@ -99,6 +130,59 @@ func defaultListSkills(cmd *cobra.Command) error {
99130
tw.Flush()
100131
cmdio.LogString(ctx, buf.String())
101132

102-
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", installedCount, len(names)))
133+
// Summary line.
134+
switch {
135+
case bothScopes:
136+
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global), %d/%d (project)", globalCount, len(names), projectCount, len(names)))
137+
case projectState != nil:
138+
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (project)", projectCount, len(names)))
139+
default:
140+
installedCount := globalCount
141+
cmdio.LogString(ctx, fmt.Sprintf("%d/%d skills installed (global)", installedCount, len(names)))
142+
}
103143
return nil
104144
}
145+
146+
// installedStatus returns the display string for a skill's installation status.
147+
func installedStatus(name, latestVersion string, globalState, projectState *installer.InstallState, bothScopes bool) string {
148+
globalVer := ""
149+
projectVer := ""
150+
151+
if globalState != nil {
152+
globalVer = globalState.Skills[name]
153+
}
154+
if projectState != nil {
155+
projectVer = projectState.Skills[name]
156+
}
157+
158+
if globalVer == "" && projectVer == "" {
159+
return "not installed"
160+
}
161+
162+
// If both scopes have the skill, show the project version (takes precedence).
163+
if bothScopes && globalVer != "" && projectVer != "" {
164+
return versionLabel(projectVer, latestVersion) + " (project, global)"
165+
}
166+
167+
if projectVer != "" {
168+
label := versionLabel(projectVer, latestVersion)
169+
if bothScopes {
170+
return label + " (project)"
171+
}
172+
return label
173+
}
174+
175+
label := versionLabel(globalVer, latestVersion)
176+
if bothScopes {
177+
return label + " (global)"
178+
}
179+
return label
180+
}
181+
182+
// versionLabel formats version with update status.
183+
func versionLabel(installed, latest string) string {
184+
if installed == latest {
185+
return "v" + installed + " (up to date)"
186+
}
187+
return "v" + installed + " (update available)"
188+
}

experimental/aitools/cmd/list_test.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestListCommandCallsListFn(t *testing.T) {
1919
t.Cleanup(func() { listSkillsFn = orig })
2020

2121
called := false
22-
listSkillsFn = func(cmd *cobra.Command) error {
22+
listSkillsFn = func(cmd *cobra.Command, scope string) error {
2323
called = true
2424
return nil
2525
}
@@ -40,12 +40,20 @@ func TestListCommandHasSkillsFlag(t *testing.T) {
4040
assert.Equal(t, "false", f.DefValue)
4141
}
4242

43+
func TestListCommandHasScopeFlags(t *testing.T) {
44+
cmd := newListCmd()
45+
f := cmd.Flags().Lookup("project")
46+
require.NotNil(t, f, "--project flag should exist")
47+
f = cmd.Flags().Lookup("global")
48+
require.NotNil(t, f, "--global flag should exist")
49+
}
50+
4351
func TestSkillsListDelegatesToListFn(t *testing.T) {
4452
orig := listSkillsFn
4553
t.Cleanup(func() { listSkillsFn = orig })
4654

4755
called := false
48-
listSkillsFn = func(cmd *cobra.Command) error {
56+
listSkillsFn = func(cmd *cobra.Command, scope string) error {
4957
called = true
5058
return nil
5159
}

experimental/aitools/cmd/skills.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ func newSkillsListCmd() *cobra.Command {
6666
Use: "list",
6767
Short: "List available skills",
6868
RunE: func(cmd *cobra.Command, args []string) error {
69-
return listSkillsFn(cmd)
69+
// Default to showing all scopes (empty scope = both).
70+
return listSkillsFn(cmd, "")
7071
},
7172
}
7273
}

experimental/aitools/cmd/version.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,10 @@ func printVersionLine(ctx context.Context, label string, state *installer.Instal
106106
latestVersion := strings.TrimPrefix(latest, "v")
107107
cmdio.LogString(ctx, fmt.Sprintf(" %s: v%s (%d %s, update available: v%s)", label, version, len(state.Skills), skillNoun, latestVersion))
108108
}
109+
110+
cmdio.LogString(ctx, " Last updated: "+state.LastUpdated.Format("2006-01-02"))
111+
112+
if authoritative && latest != state.Release {
113+
cmdio.LogString(ctx, " Run 'databricks experimental aitools update' to update.")
114+
}
109115
}

experimental/aitools/cmd/version_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ func TestVersionShowsBothScopes(t *testing.T) {
6464
assert.Contains(t, output, "v0.2.0")
6565
assert.Contains(t, output, "2 skills")
6666
assert.Contains(t, output, "3 skills")
67+
assert.Contains(t, output, "Last updated: 2026-03-22")
6768
}
6869

6970
func TestVersionShowsSingleScopeWithoutQualifier(t *testing.T) {

experimental/aitools/lib/installer/installer.go

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,11 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
125125
if err != nil {
126126
return fmt.Errorf("failed to determine working directory: %w", err)
127127
}
128+
incompatible := incompatibleAgentNames(targetAgents)
128129
targetAgents = filterProjectAgents(ctx, targetAgents)
130+
if len(targetAgents) == 0 {
131+
return fmt.Errorf("no agents support project-scoped skills. The following detected agents are global-only: %s", strings.Join(incompatible, ", "))
132+
}
129133
}
130134

131135
// Load existing state for idempotency checks.
@@ -150,6 +154,13 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
150154
return err
151155
}
152156

157+
params := installParams{
158+
baseDir: baseDir,
159+
scope: scope,
160+
cwd: cwd,
161+
ref: latestTag,
162+
}
163+
153164
// Install each skill in sorted order for determinism.
154165
skillNames := make([]string, 0, len(targetSkills))
155166
for name := range targetSkills {
@@ -169,7 +180,7 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
169180
}
170181
}
171182

172-
if err := installSkillForAgents(ctx, latestTag, name, meta.Files, targetAgents, baseDir, scope, cwd); err != nil {
183+
if err := installSkillForAgents(ctx, name, meta.Files, targetAgents, params); err != nil {
173184
return err
174185
}
175186
}
@@ -226,6 +237,17 @@ func filterProjectAgents(ctx context.Context, targetAgents []*agents.Agent) []*a
226237
return compatible
227238
}
228239

240+
// incompatibleAgentNames returns the display names of agents that do not support project scope.
241+
func incompatibleAgentNames(targetAgents []*agents.Agent) []string {
242+
var names []string
243+
for _, a := range targetAgents {
244+
if !a.SupportsProjectScope {
245+
names = append(names, a.DisplayName)
246+
}
247+
}
248+
return names
249+
}
250+
229251
// resolveSkills filters the manifest skills based on the install options,
230252
// experimental flag, and CLI version constraints.
231253
func resolveSkills(ctx context.Context, skills map[string]SkillMeta, opts InstallOptions) (map[string]SkillMeta, error) {
@@ -362,17 +384,25 @@ func allAgentsHaveSkill(ctx context.Context, skillName string, targetAgents []*a
362384
return true
363385
}
364386

365-
func installSkillForAgents(ctx context.Context, ref, skillName string, files []string, detectedAgents []*agents.Agent, baseDir, scope, cwd string) error {
366-
canonicalDir := filepath.Join(baseDir, skillName)
367-
if err := installSkillToDir(ctx, ref, skillName, canonicalDir, files); err != nil {
387+
// installParams bundles the parameters for installSkillForAgents to keep the signature manageable.
388+
type installParams struct {
389+
baseDir string
390+
scope string
391+
cwd string
392+
ref string
393+
}
394+
395+
func installSkillForAgents(ctx context.Context, skillName string, files []string, detectedAgents []*agents.Agent, params installParams) error {
396+
canonicalDir := filepath.Join(params.baseDir, skillName)
397+
if err := installSkillToDir(ctx, params.ref, skillName, canonicalDir, files); err != nil {
368398
return err
369399
}
370400

371401
// For project scope, always symlink. For global, symlink when multiple agents.
372-
useSymlinks := scope == ScopeProject || len(detectedAgents) > 1
402+
useSymlinks := params.scope == ScopeProject || len(detectedAgents) > 1
373403

374404
for _, agent := range detectedAgents {
375-
agentSkillDir, err := agentSkillsDirForScope(ctx, agent, scope, cwd)
405+
agentSkillDir, err := agentSkillsDirForScope(ctx, agent, params.scope, params.cwd)
376406
if err != nil {
377407
log.Warnf(ctx, "Skipped %s: %v", agent.DisplayName, err)
378408
continue
@@ -386,7 +416,15 @@ func installSkillForAgents(ctx context.Context, ref, skillName string, files []s
386416
}
387417

388418
if useSymlinks {
389-
if err := createSymlink(canonicalDir, destDir); err != nil {
419+
symlinkTarget := canonicalDir
420+
// For project scope, use relative symlinks so they work for teammates.
421+
if params.scope == ScopeProject {
422+
rel, relErr := filepath.Rel(filepath.Dir(destDir), canonicalDir)
423+
if relErr == nil {
424+
symlinkTarget = rel
425+
}
426+
}
427+
if err := createSymlink(symlinkTarget, destDir); err != nil {
390428
log.Debugf(ctx, "Symlink failed for %s, copying instead: %v", agent.DisplayName, err)
391429
if err := copyDir(canonicalDir, destDir); err != nil {
392430
log.Warnf(ctx, "Failed to install for %s: %v", agent.DisplayName, err)
@@ -429,8 +467,14 @@ func backupThirdPartySkill(ctx context.Context, destDir, canonicalDir, skillName
429467
// If it's a symlink to our canonical dir, no backup needed.
430468
if fi.Mode()&os.ModeSymlink != 0 {
431469
target, err := os.Readlink(destDir)
432-
if err == nil && target == canonicalDir {
433-
return nil
470+
if err == nil {
471+
absTarget := target
472+
if !filepath.IsAbs(target) {
473+
absTarget = filepath.Clean(filepath.Join(filepath.Dir(destDir), target))
474+
}
475+
if absTarget == canonicalDir {
476+
return nil
477+
}
434478
}
435479
}
436480

0 commit comments

Comments
 (0)