Skip to content

Commit 47f3578

Browse files
committed
Address review findings: relative symlinks, project scope list, zero-agent guard, param cleanup
Co-authored-by: Isaac
1 parent 5f48ad7 commit 47f3578

File tree

9 files changed

+246
-40
lines changed

9 files changed

+246
-40
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: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,11 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
124124
if err != nil {
125125
return fmt.Errorf("failed to determine working directory: %w", err)
126126
}
127+
incompatible := incompatibleAgentNames(targetAgents)
127128
targetAgents = filterProjectAgents(ctx, targetAgents)
129+
if len(targetAgents) == 0 {
130+
return fmt.Errorf("no agents support project-scoped skills. The following detected agents are global-only: %s", strings.Join(incompatible, ", "))
131+
}
128132
}
129133

130134
// Load existing state for idempotency checks.
@@ -144,6 +148,13 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
144148
return err
145149
}
146150

151+
params := installParams{
152+
baseDir: baseDir,
153+
scope: scope,
154+
cwd: cwd,
155+
ref: latestTag,
156+
}
157+
147158
// Install each skill in sorted order for determinism.
148159
skillNames := make([]string, 0, len(targetSkills))
149160
for name := range targetSkills {
@@ -162,7 +173,7 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
162173
}
163174
}
164175

165-
if err := installSkillForAgents(ctx, latestTag, name, meta.Files, targetAgents, baseDir, scope, cwd); err != nil {
176+
if err := installSkillForAgents(ctx, name, meta.Files, targetAgents, params); err != nil {
166177
return err
167178
}
168179
}
@@ -230,6 +241,17 @@ func filterProjectAgents(ctx context.Context, targetAgents []*agents.Agent) []*a
230241
return compatible
231242
}
232243

244+
// incompatibleAgentNames returns the display names of agents that do not support project scope.
245+
func incompatibleAgentNames(targetAgents []*agents.Agent) []string {
246+
var names []string
247+
for _, a := range targetAgents {
248+
if !a.SupportsProjectScope {
249+
names = append(names, a.DisplayName)
250+
}
251+
}
252+
return names
253+
}
254+
233255
// resolveSkills filters the manifest skills based on the install options,
234256
// experimental flag, and CLI version constraints.
235257
func resolveSkills(ctx context.Context, skills map[string]SkillMeta, opts InstallOptions) (map[string]SkillMeta, error) {
@@ -348,17 +370,25 @@ func hasSkillsOnDisk(dir string) bool {
348370
return false
349371
}
350372

351-
func installSkillForAgents(ctx context.Context, ref, skillName string, files []string, detectedAgents []*agents.Agent, baseDir, scope, cwd string) error {
352-
canonicalDir := filepath.Join(baseDir, skillName)
353-
if err := installSkillToDir(ctx, ref, skillName, canonicalDir, files); err != nil {
373+
// installParams bundles the parameters for installSkillForAgents to keep the signature manageable.
374+
type installParams struct {
375+
baseDir string
376+
scope string
377+
cwd string
378+
ref string
379+
}
380+
381+
func installSkillForAgents(ctx context.Context, skillName string, files []string, detectedAgents []*agents.Agent, params installParams) error {
382+
canonicalDir := filepath.Join(params.baseDir, skillName)
383+
if err := installSkillToDir(ctx, params.ref, skillName, canonicalDir, files); err != nil {
354384
return err
355385
}
356386

357387
// For project scope, always symlink. For global, symlink when multiple agents.
358-
useSymlinks := scope == ScopeProject || len(detectedAgents) > 1
388+
useSymlinks := params.scope == ScopeProject || len(detectedAgents) > 1
359389

360390
for _, agent := range detectedAgents {
361-
agentSkillDir, err := agentSkillsDirForScope(ctx, agent, scope, cwd)
391+
agentSkillDir, err := agentSkillsDirForScope(ctx, agent, params.scope, params.cwd)
362392
if err != nil {
363393
log.Warnf(ctx, "Skipped %s: %v", agent.DisplayName, err)
364394
continue
@@ -372,16 +402,24 @@ func installSkillForAgents(ctx context.Context, ref, skillName string, files []s
372402
}
373403

374404
if useSymlinks {
375-
if err := createSymlink(canonicalDir, destDir); err != nil {
405+
symlinkTarget := canonicalDir
406+
// For project scope, use relative symlinks so they work for teammates.
407+
if params.scope == ScopeProject {
408+
rel, relErr := filepath.Rel(filepath.Dir(destDir), canonicalDir)
409+
if relErr == nil {
410+
symlinkTarget = rel
411+
}
412+
}
413+
if err := createSymlink(symlinkTarget, destDir); err != nil {
376414
log.Debugf(ctx, "Symlink failed for %s, copying instead: %v", agent.DisplayName, err)
377-
if err := installSkillToDir(ctx, ref, skillName, destDir, files); err != nil {
415+
if err := installSkillToDir(ctx, params.ref, skillName, destDir, files); err != nil {
378416
log.Warnf(ctx, "Failed to install for %s: %v", agent.DisplayName, err)
379417
continue
380418
}
381419
}
382420
log.Debugf(ctx, "Installed %q for %s (symlinked)", skillName, agent.DisplayName)
383421
} else {
384-
if err := installSkillToDir(ctx, ref, skillName, destDir, files); err != nil {
422+
if err := installSkillToDir(ctx, params.ref, skillName, destDir, files); err != nil {
385423
log.Warnf(ctx, "Failed to install for %s: %v", agent.DisplayName, err)
386424
continue
387425
}
@@ -414,8 +452,14 @@ func backupThirdPartySkill(ctx context.Context, destDir, canonicalDir, skillName
414452
// If it's a symlink to our canonical dir, no backup needed.
415453
if fi.Mode()&os.ModeSymlink != 0 {
416454
target, err := os.Readlink(destDir)
417-
if err == nil && target == canonicalDir {
418-
return nil
455+
if err == nil {
456+
absTarget := target
457+
if !filepath.IsAbs(target) {
458+
absTarget = filepath.Clean(filepath.Join(filepath.Dir(destDir), target))
459+
}
460+
if absTarget == canonicalDir {
461+
return nil
462+
}
419463
}
420464
}
421465

0 commit comments

Comments
 (0)