detached 29f5fcb

Follow symlinked directories during skill discovery

filepath.WalkDir does not descend into symlinked directories, so a
skills directory whose entries link into a shared checkout discovered
nothing at all. Discovery now follows directory links, walks each
resolved directory once so a link cycle terminates, and skips a link
that points at nothing. A skill reached through a link reports the
link path, so the location shown by /skills is the installed one.
commit
29f5fcbed507adfb9e8997e0ca0a2419455ed24f
author
Mike Smith · · 2026-09-27 17:48 UTC
parent
41873f6
signature
unsigned
3 files · +184 -31
M docs/skills.md +4 -0
@@ -74,6 +74,10 @@ Explicit `--ext` bundles take precedence over environment, project, global, and
7474 compatibility sources. Duplicate names from different files keep the
7575 higher-precedence file and report a diagnostic. Rediscovering the same file
7676 under the same skill name (including through a symlink) is silently ignored.
77+Skill directories may themselves be symlinks, so a checkout can be kept in one
78+place and linked into a skills directory. Link cycles are ignored, a directory
79+reached twice through links is scanned once, and a link that points at nothing
80+is skipped.
7781 This includes overlapping project and global locations when running from your
7882 home directory. `--no-ext` omits implicit extension bundles;
7983 `--no-ext --ext PATH` loads only the explicitly named bundle. `--no-skill`
@@ -74,6 +74,10 @@ Explicit `--ext` bundles take precedence over environment, project, global, and
74 compatibility sources. Duplicate names from different files keep the74 compatibility sources. Duplicate names from different files keep the
75 higher-precedence file and report a diagnostic. Rediscovering the same file75 higher-precedence file and report a diagnostic. Rediscovering the same file
76 under the same skill name (including through a symlink) is silently ignored.76 under the same skill name (including through a symlink) is silently ignored.
77+Skill directories may themselves be symlinks, so a checkout can be kept in one
78+place and linked into a skills directory. Link cycles are ignored, a directory
79+reached twice through links is scanned once, and a link that points at nothing
80+is skipped.
77 This includes overlapping project and global locations when running from your81 This includes overlapping project and global locations when running from your
78 home directory. `--no-ext` omits implicit extension bundles;82 home directory. `--no-ext` omits implicit extension bundles;
79 `--no-ext --ext PATH` loads only the explicitly named bundle. `--no-skill`83 `--no-ext --ext PATH` loads only the explicitly named bundle. `--no-skill`
M packages/agent/skills/skills.go +83 -31
@@ -21,10 +21,16 @@
2121 //
2222 // The compat paths are deliberate: a SKILL.md written for any of
2323 // the related ecosystems works in zot unchanged.
24+//
25+// Discovery walks each source recursively and follows symlinked
26+// directories, so a skill can be a link into a shared checkout. A link
27+// cycle is ignored, and each resolved directory is walked only once.
2428 package skills
2529
2630 import (
31+ "errors"
2732 "fmt"
33+ "io/fs"
2834 "os"
2935 "path/filepath"
3036 "sort"
@@ -207,50 +213,96 @@ func scanSource(source Source) ([]*Skill, []error) {
207213 if err != nil {
208214 return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)}
209215 }
210- err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
211- if walkErr != nil {
212- errs = append(errs, fmt.Errorf("%s: %w", path, walkErr))
213- return nil
216+ // filepath.WalkDir does not descend into symlinked directories, which
217+ // would silently drop a skill directory that links into a shared
218+ // checkout. The walk below follows those links and records every
219+ // resolved directory so a link cycle cannot recurse forever.
220+ visited := map[string]bool{}
221+ var walk func(dir string)
222+ walk = func(dir string) {
223+ resolved, err := filepath.EvalSymlinks(dir)
224+ if err != nil {
225+ errs = append(errs, fmt.Errorf("resolve %s: %w", dir, err))
226+ return
214227 }
215- if d.IsDir() || d.Name() != "SKILL.md" {
216- return nil
228+ if visited[resolved] {
229+ return
217230 }
218- s, e := load(path, source.Label)
219- if e != nil {
220- errs = append(errs, fmt.Errorf("%s: %w", path, e))
221- return nil
231+ visited[resolved] = true
232+ entries, err := os.ReadDir(dir)
233+ if err != nil {
234+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
235+ return
222236 }
223- if s.Name == "" {
224- rel, e := filepath.Rel(root, filepath.Dir(path))
225- if e != nil {
226- errs = append(errs, e)
227- return nil
237+ for _, d := range entries {
238+ path := filepath.Join(dir, d.Name())
239+ if d.IsDir() {
240+ walk(path)
241+ continue
228242 }
229- parts := []string{}
230- if rel != "." {
231- for _, p := range strings.Split(filepath.ToSlash(rel), "/") {
232- if k := kebabCase(p); k != "" {
233- parts = append(parts, k)
243+ if d.Type()&fs.ModeSymlink != 0 {
244+ info, statErr := os.Stat(path) // resolves the link
245+ if statErr != nil {
246+ // A dangling link is not a skill. Anything else
247+ // (permissions, I/O) is worth reporting.
248+ if !errors.Is(statErr, fs.ErrNotExist) {
249+ errs = append(errs, fmt.Errorf("%s: %w", path, statErr))
234250 }
251+ continue
252+ }
253+ if info.IsDir() {
254+ walk(path)
255+ continue
235256 }
236257 }
237- if len(parts) == 0 {
238- parts = append(parts, kebabCase(filepath.Base(filepath.Dir(path))))
258+ if d.Name() != "SKILL.md" {
259+ continue
239260 }
240- s.Name = strings.Join(parts, "-")
241- }
242- if source.Prefix != "" {
243- s.Name = source.Prefix + s.Name
261+ s, e := load(path, source.Label)
262+ if e != nil {
263+ errs = append(errs, fmt.Errorf("%s: %w", path, e))
264+ continue
265+ }
266+ if s.Name == "" {
267+ name, e := deriveName(root, path)
268+ if e != nil {
269+ errs = append(errs, e)
270+ continue
271+ }
272+ s.Name = name
273+ }
274+ if source.Prefix != "" {
275+ s.Name = source.Prefix + s.Name
276+ }
277+ out = append(out, s)
244278 }
245- out = append(out, s)
246- return nil
247- })
248- if err != nil {
249- errs = append(errs, fmt.Errorf("scan %s: %w", root, err))
250279 }
280+ walk(root)
251281 return out, errs
252282 }
253283
284+// deriveName builds a skill name for a SKILL.md file that carries no
285+// frontmatter `name`, from its directory path relative to the source root.
286+func deriveName(root, path string) (string, error) {
287+ dir := filepath.Dir(path)
288+ rel, err := filepath.Rel(root, dir)
289+ if err != nil {
290+ return "", fmt.Errorf("%s: %w", path, err)
291+ }
292+ var parts []string
293+ if rel != "." {
294+ for _, p := range strings.Split(filepath.ToSlash(rel), "/") {
295+ if k := kebabCase(p); k != "" {
296+ parts = append(parts, k)
297+ }
298+ }
299+ }
300+ if len(parts) == 0 {
301+ parts = append(parts, kebabCase(filepath.Base(dir)))
302+ }
303+ return strings.Join(parts, "-"), nil
304+}
305+
254306 // KebabCase converts a path component or display name to a stable skill name.
255307 func KebabCase(s string) string { return kebabCase(s) }
256308
@@ -21,10 +21,16 @@
21 //21 //
22 // The compat paths are deliberate: a SKILL.md written for any of22 // The compat paths are deliberate: a SKILL.md written for any of
23 // the related ecosystems works in zot unchanged.23 // the related ecosystems works in zot unchanged.
24+//
25+// Discovery walks each source recursively and follows symlinked
26+// directories, so a skill can be a link into a shared checkout. A link
27+// cycle is ignored, and each resolved directory is walked only once.
24 package skills28 package skills
25 29
26 import (30 import (
31+ "errors"
27 "fmt"32 "fmt"
33+ "io/fs"
28 "os"34 "os"
29 "path/filepath"35 "path/filepath"
30 "sort"36 "sort"
@@ -207,50 +213,96 @@ func scanSource(source Source) ([]*Skill, []error) {
207 if err != nil {213 if err != nil {
208 return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)}214 return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)}
209 }215 }
210- err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {216+ // filepath.WalkDir does not descend into symlinked directories, which
211- if walkErr != nil {217+ // would silently drop a skill directory that links into a shared
212- errs = append(errs, fmt.Errorf("%s: %w", path, walkErr))218+ // checkout. The walk below follows those links and records every
213- return nil219+ // resolved directory so a link cycle cannot recurse forever.
220+ visited := map[string]bool{}
221+ var walk func(dir string)
222+ walk = func(dir string) {
223+ resolved, err := filepath.EvalSymlinks(dir)
224+ if err != nil {
225+ errs = append(errs, fmt.Errorf("resolve %s: %w", dir, err))
226+ return
214 }227 }
215- if d.IsDir() || d.Name() != "SKILL.md" {228+ if visited[resolved] {
216- return nil229+ return
217 }230 }
218- s, e := load(path, source.Label)231+ visited[resolved] = true
219- if e != nil {232+ entries, err := os.ReadDir(dir)
220- errs = append(errs, fmt.Errorf("%s: %w", path, e))233+ if err != nil {
221- return nil234+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
235+ return
222 }236 }
223- if s.Name == "" {237+ for _, d := range entries {
224- rel, e := filepath.Rel(root, filepath.Dir(path))238+ path := filepath.Join(dir, d.Name())
225- if e != nil {239+ if d.IsDir() {
226- errs = append(errs, e)240+ walk(path)
227- return nil241+ continue
228 }242 }
229- parts := []string{}243+ if d.Type()&fs.ModeSymlink != 0 {
230- if rel != "." {244+ info, statErr := os.Stat(path) // resolves the link
231- for _, p := range strings.Split(filepath.ToSlash(rel), "/") {245+ if statErr != nil {
232- if k := kebabCase(p); k != "" {246+ // A dangling link is not a skill. Anything else
233- parts = append(parts, k)247+ // (permissions, I/O) is worth reporting.
248+ if !errors.Is(statErr, fs.ErrNotExist) {
249+ errs = append(errs, fmt.Errorf("%s: %w", path, statErr))
234 }250 }
251+ continue
252+ }
253+ if info.IsDir() {
254+ walk(path)
255+ continue
235 }256 }
236 }257 }
237- if len(parts) == 0 {258+ if d.Name() != "SKILL.md" {
238- parts = append(parts, kebabCase(filepath.Base(filepath.Dir(path))))259+ continue
239 }260 }
240- s.Name = strings.Join(parts, "-")261+ s, e := load(path, source.Label)
241- }262+ if e != nil {
242- if source.Prefix != "" {263+ errs = append(errs, fmt.Errorf("%s: %w", path, e))
243- s.Name = source.Prefix + s.Name264+ continue
265+ }
266+ if s.Name == "" {
267+ name, e := deriveName(root, path)
268+ if e != nil {
269+ errs = append(errs, e)
270+ continue
271+ }
272+ s.Name = name
273+ }
274+ if source.Prefix != "" {
275+ s.Name = source.Prefix + s.Name
276+ }
277+ out = append(out, s)
244 }278 }
245- out = append(out, s)
246- return nil
247- })
248- if err != nil {
249- errs = append(errs, fmt.Errorf("scan %s: %w", root, err))
250 }279 }
280+ walk(root)
251 return out, errs281 return out, errs
252 }282 }
253 283
284+// deriveName builds a skill name for a SKILL.md file that carries no
285+// frontmatter `name`, from its directory path relative to the source root.
286+func deriveName(root, path string) (string, error) {
287+ dir := filepath.Dir(path)
288+ rel, err := filepath.Rel(root, dir)
289+ if err != nil {
290+ return "", fmt.Errorf("%s: %w", path, err)
291+ }
292+ var parts []string
293+ if rel != "." {
294+ for _, p := range strings.Split(filepath.ToSlash(rel), "/") {
295+ if k := kebabCase(p); k != "" {
296+ parts = append(parts, k)
297+ }
298+ }
299+ }
300+ if len(parts) == 0 {
301+ parts = append(parts, kebabCase(filepath.Base(dir)))
302+ }
303+ return strings.Join(parts, "-"), nil
304+}
305+
254 // KebabCase converts a path component or display name to a stable skill name.306 // KebabCase converts a path component or display name to a stable skill name.
255 func KebabCase(s string) string { return kebabCase(s) }307 func KebabCase(s string) string { return kebabCase(s) }
256 308
M packages/agent/skills/skills_test.go +97 -0
@@ -147,6 +147,103 @@ func TestDiscoverSourcesFollowsSymlinkedRoot(t *testing.T) {
147147 }
148148 }
149149
150+func TestDiscoverSourcesFollowsSymlinkedSkillDir(t *testing.T) {
151+ shared := t.TempDir()
152+ writeSkill(t, filepath.Join(shared, "tdd"), "name: tdd", "shared body")
153+ root := t.TempDir()
154+ linkDir(t, filepath.Join(shared, "tdd"), filepath.Join(root, "tdd"))
155+
156+ got, errs := DiscoverSources([]Source{{Root: root, Label: "global"}}, false)
157+ if len(errs) != 0 || len(got) != 1 {
158+ t.Fatalf("skills=%#v errs=%v", got, errs)
159+ }
160+ if got[0].Name != "tdd" || got[0].Body != "shared body" || got[0].Source != "global" {
161+ t.Errorf("skill = %#v", got[0])
162+ }
163+ if want := filepath.Join(root, "tdd", "SKILL.md"); got[0].Path != want {
164+ t.Errorf("path = %q, want the link location %q", got[0].Path, want)
165+ }
166+}
167+
168+func TestDiscoverSourcesFollowsSymlinkedCategoryDir(t *testing.T) {
169+ shared := t.TempDir()
170+ writeSkill(t, filepath.Join(shared, "code-review"), "name: code-review", "named body")
171+ writeSkill(t, filepath.Join(shared, "prototype"), "", "unnamed body")
172+ root := t.TempDir()
173+ linkDir(t, shared, filepath.Join(root, "engineering"))
174+
175+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
176+ if len(errs) != 0 || len(got) != 2 {
177+ t.Fatalf("skills=%#v errs=%v", got, errs)
178+ }
179+ if got[0].Name != "code-review" || got[1].Name != "engineering-prototype" {
180+ t.Errorf("names = %q, %q", got[0].Name, got[1].Name)
181+ }
182+}
183+
184+func TestDiscoverSourcesSymlinkCycleTerminates(t *testing.T) {
185+ root := t.TempDir()
186+ writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
187+ linkDir(t, root, filepath.Join(root, "loop"))
188+ linkDir(t, root, filepath.Join(root, "alias"))
189+
190+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
191+ if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
192+ t.Fatalf("skills=%#v errs=%v", got, errs)
193+ }
194+}
195+
196+func TestDiscoverSourcesIgnoresDanglingSymlink(t *testing.T) {
197+ root := t.TempDir()
198+ writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
199+ missing := filepath.Join(t.TempDir(), "gone")
200+ linkDir(t, missing, filepath.Join(root, "gone"))
201+
202+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
203+ if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
204+ t.Fatalf("skills=%#v errs=%v", got, errs)
205+ }
206+}
207+
208+func TestDiscoverSourcesWalksLinkedDirOnce(t *testing.T) {
209+ root := t.TempDir()
210+ real := filepath.Join(root, "shared")
211+ writeSkill(t, real, "name: review", "body")
212+ linkDir(t, real, filepath.Join(root, "alias"))
213+
214+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
215+ if len(errs) != 0 || len(got) != 1 {
216+ t.Fatalf("skills=%#v errs=%v", got, errs)
217+ }
218+ if want := filepath.Join(root, "alias", "SKILL.md"); got[0].Path != want {
219+ t.Errorf("path = %q, want %q", got[0].Path, want)
220+ }
221+}
222+
223+func writeSkill(t *testing.T, dir, front, body string) {
224+ t.Helper()
225+ if err := os.MkdirAll(dir, 0o755); err != nil {
226+ t.Fatal(err)
227+ }
228+ frontmatter := "---\ndescription: test\n---\n"
229+ if front != "" {
230+ frontmatter = "---\n" + front + "\ndescription: test\n---\n"
231+ }
232+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(frontmatter+body), 0o644); err != nil {
233+ t.Fatal(err)
234+ }
235+}
236+
237+func linkDir(t *testing.T, target, path string) {
238+ t.Helper()
239+ if err := os.Symlink(target, path); err != nil {
240+ if runtime.GOOS == "windows" {
241+ t.Skipf("symlinks unavailable: %v", err)
242+ }
243+ t.Fatal(err)
244+ }
245+}
246+
150247 func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {
151248 root := t.TempDir()
152249 path := filepath.Join(root, "Something", "Writing Git Commits")
@@ -147,6 +147,103 @@ func TestDiscoverSourcesFollowsSymlinkedRoot(t *testing.T) {
147 }147 }
148 }148 }
149 149
150+func TestDiscoverSourcesFollowsSymlinkedSkillDir(t *testing.T) {
151+ shared := t.TempDir()
152+ writeSkill(t, filepath.Join(shared, "tdd"), "name: tdd", "shared body")
153+ root := t.TempDir()
154+ linkDir(t, filepath.Join(shared, "tdd"), filepath.Join(root, "tdd"))
155+
156+ got, errs := DiscoverSources([]Source{{Root: root, Label: "global"}}, false)
157+ if len(errs) != 0 || len(got) != 1 {
158+ t.Fatalf("skills=%#v errs=%v", got, errs)
159+ }
160+ if got[0].Name != "tdd" || got[0].Body != "shared body" || got[0].Source != "global" {
161+ t.Errorf("skill = %#v", got[0])
162+ }
163+ if want := filepath.Join(root, "tdd", "SKILL.md"); got[0].Path != want {
164+ t.Errorf("path = %q, want the link location %q", got[0].Path, want)
165+ }
166+}
167+
168+func TestDiscoverSourcesFollowsSymlinkedCategoryDir(t *testing.T) {
169+ shared := t.TempDir()
170+ writeSkill(t, filepath.Join(shared, "code-review"), "name: code-review", "named body")
171+ writeSkill(t, filepath.Join(shared, "prototype"), "", "unnamed body")
172+ root := t.TempDir()
173+ linkDir(t, shared, filepath.Join(root, "engineering"))
174+
175+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
176+ if len(errs) != 0 || len(got) != 2 {
177+ t.Fatalf("skills=%#v errs=%v", got, errs)
178+ }
179+ if got[0].Name != "code-review" || got[1].Name != "engineering-prototype" {
180+ t.Errorf("names = %q, %q", got[0].Name, got[1].Name)
181+ }
182+}
183+
184+func TestDiscoverSourcesSymlinkCycleTerminates(t *testing.T) {
185+ root := t.TempDir()
186+ writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
187+ linkDir(t, root, filepath.Join(root, "loop"))
188+ linkDir(t, root, filepath.Join(root, "alias"))
189+
190+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
191+ if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
192+ t.Fatalf("skills=%#v errs=%v", got, errs)
193+ }
194+}
195+
196+func TestDiscoverSourcesIgnoresDanglingSymlink(t *testing.T) {
197+ root := t.TempDir()
198+ writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
199+ missing := filepath.Join(t.TempDir(), "gone")
200+ linkDir(t, missing, filepath.Join(root, "gone"))
201+
202+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
203+ if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
204+ t.Fatalf("skills=%#v errs=%v", got, errs)
205+ }
206+}
207+
208+func TestDiscoverSourcesWalksLinkedDirOnce(t *testing.T) {
209+ root := t.TempDir()
210+ real := filepath.Join(root, "shared")
211+ writeSkill(t, real, "name: review", "body")
212+ linkDir(t, real, filepath.Join(root, "alias"))
213+
214+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
215+ if len(errs) != 0 || len(got) != 1 {
216+ t.Fatalf("skills=%#v errs=%v", got, errs)
217+ }
218+ if want := filepath.Join(root, "alias", "SKILL.md"); got[0].Path != want {
219+ t.Errorf("path = %q, want %q", got[0].Path, want)
220+ }
221+}
222+
223+func writeSkill(t *testing.T, dir, front, body string) {
224+ t.Helper()
225+ if err := os.MkdirAll(dir, 0o755); err != nil {
226+ t.Fatal(err)
227+ }
228+ frontmatter := "---\ndescription: test\n---\n"
229+ if front != "" {
230+ frontmatter = "---\n" + front + "\ndescription: test\n---\n"
231+ }
232+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(frontmatter+body), 0o644); err != nil {
233+ t.Fatal(err)
234+ }
235+}
236+
237+func linkDir(t *testing.T, target, path string) {
238+ t.Helper()
239+ if err := os.Symlink(target, path); err != nil {
240+ if runtime.GOOS == "windows" {
241+ t.Skipf("symlinks unavailable: %v", err)
242+ }
243+ t.Fatal(err)
244+ }
245+}
246+
150 func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {247 func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {
151 root := t.TempDir()248 root := t.TempDir()
152 path := filepath.Join(root, "Something", "Writing Git Commits")249 path := filepath.Join(root, "Something", "Writing Git Commits")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close