detached f9a6e9f

Merge pull request #217 from m1k3s0/fix/skill-discovery-symlinks

Follow symlinked directories during skill discovery
commit
f9a6e9f296de68dd0a035e6ac09f24e0c4514334
author
Patric Eckhart · · 2026-09-28 04:30 UTC
committer
GitHub · · 2026-09-28 04:30 UTC
merge of
41873f6 + e0ea86f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +229 -35
M docs/skills.md +8 -4
@@ -74,10 +74,14 @@ 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-This includes overlapping project and global locations when running from your
78-home directory. `--no-ext` omits implicit extension bundles;
79-`--no-ext --ext PATH` loads only the explicitly named bundle. `--no-skill`
80-disables all skills, including extension and built-in skills.
77+Skill directories may themselves be symlinks, so a checkout can be kept in one
78+place and linked into a skills directory. Real directories are scanned before
79+links, so an alias cannot rename an existing unnamed skill. Link cycles are
80+ignored, a directory reached twice through links is scanned once, and a link
81+that points at nothing is skipped. This includes overlapping project and global
82+locations when running from your home directory. `--no-ext` omits implicit
83+extension bundles. `--no-ext --ext PATH` loads only the explicitly named bundle.
84+`--no-skill` disables all skills, including extension and built-in skills.
8185
8286 When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
8387 `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
@@ -74,10 +74,14 @@ 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-This includes overlapping project and global locations when running from your77+Skill directories may themselves be symlinks, so a checkout can be kept in one
78-home directory. `--no-ext` omits implicit extension bundles;78+place and linked into a skills directory. Real directories are scanned before
79-`--no-ext --ext PATH` loads only the explicitly named bundle. `--no-skill`79+links, so an alias cannot rename an existing unnamed skill. Link cycles are
80-disables all skills, including extension and built-in skills.80+ignored, a directory reached twice through links is scanned once, and a link
81+that points at nothing is skipped. This includes overlapping project and global
82+locations when running from your home directory. `--no-ext` omits implicit
83+extension bundles. `--no-ext --ext PATH` loads only the explicitly named bundle.
84+`--no-skill` disables all skills, including extension and built-in skills.
81 85
82 When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to86 When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
83 `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`87 `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
M packages/agent/skills/skills.go +89 -31
@@ -21,14 +21,21 @@
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"
3137 "strings"
38+ "syscall"
3239 )
3340
3441 // Skill is one discovered SKILL.md file.
@@ -207,50 +214,101 @@ func scanSource(source Source) ([]*Skill, []error) {
207214 if err != nil {
208215 return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)}
209216 }
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
217+ // filepath.WalkDir does not descend into symlinked directories, which
218+ // would silently drop a skill directory that links into a shared
219+ // checkout. Walk real directories before following links so an alias
220+ // cannot rename an existing unnamed skill. Record resolved directories
221+ // so a link cycle cannot recurse forever.
222+ visited := map[string]bool{}
223+ var linkedDirs []string
224+ var walk func(dir string)
225+ walk = func(dir string) {
226+ resolved, err := filepath.EvalSymlinks(dir)
227+ if err != nil {
228+ errs = append(errs, fmt.Errorf("resolve %s: %w", dir, err))
229+ return
214230 }
215- if d.IsDir() || d.Name() != "SKILL.md" {
216- return nil
231+ if visited[resolved] {
232+ return
217233 }
218- s, e := load(path, source.Label)
219- if e != nil {
220- errs = append(errs, fmt.Errorf("%s: %w", path, e))
221- return nil
234+ visited[resolved] = true
235+ entries, err := os.ReadDir(dir)
236+ if err != nil {
237+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
238+ return
222239 }
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
240+ for _, d := range entries {
241+ path := filepath.Join(dir, d.Name())
242+ if d.IsDir() {
243+ walk(path)
244+ continue
228245 }
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)
246+ if d.Type()&fs.ModeSymlink != 0 {
247+ info, statErr := os.Stat(path) // resolves the link
248+ if statErr != nil {
249+ // Dangling and cyclic links are not skills. Anything
250+ // else (permissions, I/O) is worth reporting.
251+ if !errors.Is(statErr, fs.ErrNotExist) && !errors.Is(statErr, syscall.ELOOP) {
252+ errs = append(errs, fmt.Errorf("%s: %w", path, statErr))
234253 }
254+ continue
255+ }
256+ if info.IsDir() {
257+ linkedDirs = append(linkedDirs, path)
258+ continue
235259 }
236260 }
237- if len(parts) == 0 {
238- parts = append(parts, kebabCase(filepath.Base(filepath.Dir(path))))
261+ if d.Name() != "SKILL.md" {
262+ continue
239263 }
240- s.Name = strings.Join(parts, "-")
241- }
242- if source.Prefix != "" {
243- s.Name = source.Prefix + s.Name
264+ s, e := load(path, source.Label)
265+ if e != nil {
266+ errs = append(errs, fmt.Errorf("%s: %w", path, e))
267+ continue
268+ }
269+ if s.Name == "" {
270+ name, e := deriveName(root, path)
271+ if e != nil {
272+ errs = append(errs, e)
273+ continue
274+ }
275+ s.Name = name
276+ }
277+ if source.Prefix != "" {
278+ s.Name = source.Prefix + s.Name
279+ }
280+ out = append(out, s)
244281 }
245- out = append(out, s)
246- return nil
247- })
248- if err != nil {
249- errs = append(errs, fmt.Errorf("scan %s: %w", root, err))
282+ }
283+ walk(root)
284+ for i := 0; i < len(linkedDirs); i++ {
285+ walk(linkedDirs[i])
250286 }
251287 return out, errs
252288 }
253289
290+// deriveName builds a skill name for a SKILL.md file that carries no
291+// frontmatter `name`, from its directory path relative to the source root.
292+func deriveName(root, path string) (string, error) {
293+ dir := filepath.Dir(path)
294+ rel, err := filepath.Rel(root, dir)
295+ if err != nil {
296+ return "", fmt.Errorf("%s: %w", path, err)
297+ }
298+ var parts []string
299+ if rel != "." {
300+ for _, p := range strings.Split(filepath.ToSlash(rel), "/") {
301+ if k := kebabCase(p); k != "" {
302+ parts = append(parts, k)
303+ }
304+ }
305+ }
306+ if len(parts) == 0 {
307+ parts = append(parts, kebabCase(filepath.Base(dir)))
308+ }
309+ return strings.Join(parts, "-"), nil
310+}
311+
254312 // KebabCase converts a path component or display name to a stable skill name.
255313 func KebabCase(s string) string { return kebabCase(s) }
256314
@@ -21,14 +21,21 @@
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"
31 "strings"37 "strings"
38+ "syscall"
32 )39 )
33 40
34 // Skill is one discovered SKILL.md file.41 // Skill is one discovered SKILL.md file.
@@ -207,50 +214,101 @@ func scanSource(source Source) ([]*Skill, []error) {
207 if err != nil {214 if err != nil {
208 return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)}215 return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)}
209 }216 }
210- err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {217+ // filepath.WalkDir does not descend into symlinked directories, which
211- if walkErr != nil {218+ // would silently drop a skill directory that links into a shared
212- errs = append(errs, fmt.Errorf("%s: %w", path, walkErr))219+ // checkout. Walk real directories before following links so an alias
213- return nil220+ // cannot rename an existing unnamed skill. Record resolved directories
221+ // so a link cycle cannot recurse forever.
222+ visited := map[string]bool{}
223+ var linkedDirs []string
224+ var walk func(dir string)
225+ walk = func(dir string) {
226+ resolved, err := filepath.EvalSymlinks(dir)
227+ if err != nil {
228+ errs = append(errs, fmt.Errorf("resolve %s: %w", dir, err))
229+ return
214 }230 }
215- if d.IsDir() || d.Name() != "SKILL.md" {231+ if visited[resolved] {
216- return nil232+ return
217 }233 }
218- s, e := load(path, source.Label)234+ visited[resolved] = true
219- if e != nil {235+ entries, err := os.ReadDir(dir)
220- errs = append(errs, fmt.Errorf("%s: %w", path, e))236+ if err != nil {
221- return nil237+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
238+ return
222 }239 }
223- if s.Name == "" {240+ for _, d := range entries {
224- rel, e := filepath.Rel(root, filepath.Dir(path))241+ path := filepath.Join(dir, d.Name())
225- if e != nil {242+ if d.IsDir() {
226- errs = append(errs, e)243+ walk(path)
227- return nil244+ continue
228 }245 }
229- parts := []string{}246+ if d.Type()&fs.ModeSymlink != 0 {
230- if rel != "." {247+ info, statErr := os.Stat(path) // resolves the link
231- for _, p := range strings.Split(filepath.ToSlash(rel), "/") {248+ if statErr != nil {
232- if k := kebabCase(p); k != "" {249+ // Dangling and cyclic links are not skills. Anything
233- parts = append(parts, k)250+ // else (permissions, I/O) is worth reporting.
251+ if !errors.Is(statErr, fs.ErrNotExist) && !errors.Is(statErr, syscall.ELOOP) {
252+ errs = append(errs, fmt.Errorf("%s: %w", path, statErr))
234 }253 }
254+ continue
255+ }
256+ if info.IsDir() {
257+ linkedDirs = append(linkedDirs, path)
258+ continue
235 }259 }
236 }260 }
237- if len(parts) == 0 {261+ if d.Name() != "SKILL.md" {
238- parts = append(parts, kebabCase(filepath.Base(filepath.Dir(path))))262+ continue
239 }263 }
240- s.Name = strings.Join(parts, "-")264+ s, e := load(path, source.Label)
241- }265+ if e != nil {
242- if source.Prefix != "" {266+ errs = append(errs, fmt.Errorf("%s: %w", path, e))
243- s.Name = source.Prefix + s.Name267+ continue
268+ }
269+ if s.Name == "" {
270+ name, e := deriveName(root, path)
271+ if e != nil {
272+ errs = append(errs, e)
273+ continue
274+ }
275+ s.Name = name
276+ }
277+ if source.Prefix != "" {
278+ s.Name = source.Prefix + s.Name
279+ }
280+ out = append(out, s)
244 }281 }
245- out = append(out, s)282+ }
246- return nil283+ walk(root)
247- })284+ for i := 0; i < len(linkedDirs); i++ {
248- if err != nil {285+ walk(linkedDirs[i])
249- errs = append(errs, fmt.Errorf("scan %s: %w", root, err))
250 }286 }
251 return out, errs287 return out, errs
252 }288 }
253 289
290+// deriveName builds a skill name for a SKILL.md file that carries no
291+// frontmatter `name`, from its directory path relative to the source root.
292+func deriveName(root, path string) (string, error) {
293+ dir := filepath.Dir(path)
294+ rel, err := filepath.Rel(root, dir)
295+ if err != nil {
296+ return "", fmt.Errorf("%s: %w", path, err)
297+ }
298+ var parts []string
299+ if rel != "." {
300+ for _, p := range strings.Split(filepath.ToSlash(rel), "/") {
301+ if k := kebabCase(p); k != "" {
302+ parts = append(parts, k)
303+ }
304+ }
305+ }
306+ if len(parts) == 0 {
307+ parts = append(parts, kebabCase(filepath.Base(dir)))
308+ }
309+ return strings.Join(parts, "-"), nil
310+}
311+
254 // KebabCase converts a path component or display name to a stable skill name.312 // KebabCase converts a path component or display name to a stable skill name.
255 func KebabCase(s string) string { return kebabCase(s) }313 func KebabCase(s string) string { return kebabCase(s) }
256 314
M packages/agent/skills/skills_test.go +132 -0
@@ -147,6 +147,138 @@ 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(mustEvalSymlinks(t, 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(mustEvalSymlinks(t, root), "shared", "SKILL.md"); got[0].Path != want {
219+ t.Errorf("path = %q, want %q", got[0].Path, want)
220+ }
221+}
222+
223+func TestDiscoverSourcesKeepsUnnamedRealSkillWhenAliasSortsFirst(t *testing.T) {
224+ root := t.TempDir()
225+ real := filepath.Join(root, "z", "review")
226+ writeSkill(t, real, "", "body")
227+ linkDir(t, real, filepath.Join(root, "alias"))
228+
229+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
230+ if len(errs) != 0 || len(got) != 1 {
231+ t.Fatalf("skills=%#v errs=%v", got, errs)
232+ }
233+ if got[0].Name != "z-review" || got[0].Path != filepath.Join(mustEvalSymlinks(t, root), "z", "review", "SKILL.md") {
234+ t.Errorf("skill = %#v, want the real directory name and path", got[0])
235+ }
236+}
237+
238+func TestDiscoverSourcesIgnoresCyclicSymlink(t *testing.T) {
239+ root := t.TempDir()
240+ writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
241+ linkDir(t, filepath.Join(root, "loop"), filepath.Join(root, "loop"))
242+
243+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
244+ if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
245+ t.Fatalf("skills=%#v errs=%v", got, errs)
246+ }
247+}
248+
249+func mustEvalSymlinks(t *testing.T, path string) string {
250+ t.Helper()
251+ resolved, err := filepath.EvalSymlinks(path)
252+ if err != nil {
253+ t.Fatal(err)
254+ }
255+ return resolved
256+}
257+
258+func writeSkill(t *testing.T, dir, front, body string) {
259+ t.Helper()
260+ if err := os.MkdirAll(dir, 0o755); err != nil {
261+ t.Fatal(err)
262+ }
263+ frontmatter := "---\ndescription: test\n---\n"
264+ if front != "" {
265+ frontmatter = "---\n" + front + "\ndescription: test\n---\n"
266+ }
267+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(frontmatter+body), 0o644); err != nil {
268+ t.Fatal(err)
269+ }
270+}
271+
272+func linkDir(t *testing.T, target, path string) {
273+ t.Helper()
274+ if err := os.Symlink(target, path); err != nil {
275+ if runtime.GOOS == "windows" {
276+ t.Skipf("symlinks unavailable: %v", err)
277+ }
278+ t.Fatal(err)
279+ }
280+}
281+
150282 func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {
151283 root := t.TempDir()
152284 path := filepath.Join(root, "Something", "Writing Git Commits")
@@ -147,6 +147,138 @@ 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(mustEvalSymlinks(t, 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(mustEvalSymlinks(t, root), "shared", "SKILL.md"); got[0].Path != want {
219+ t.Errorf("path = %q, want %q", got[0].Path, want)
220+ }
221+}
222+
223+func TestDiscoverSourcesKeepsUnnamedRealSkillWhenAliasSortsFirst(t *testing.T) {
224+ root := t.TempDir()
225+ real := filepath.Join(root, "z", "review")
226+ writeSkill(t, real, "", "body")
227+ linkDir(t, real, filepath.Join(root, "alias"))
228+
229+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
230+ if len(errs) != 0 || len(got) != 1 {
231+ t.Fatalf("skills=%#v errs=%v", got, errs)
232+ }
233+ if got[0].Name != "z-review" || got[0].Path != filepath.Join(mustEvalSymlinks(t, root), "z", "review", "SKILL.md") {
234+ t.Errorf("skill = %#v, want the real directory name and path", got[0])
235+ }
236+}
237+
238+func TestDiscoverSourcesIgnoresCyclicSymlink(t *testing.T) {
239+ root := t.TempDir()
240+ writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
241+ linkDir(t, filepath.Join(root, "loop"), filepath.Join(root, "loop"))
242+
243+ got, errs := DiscoverSources([]Source{{Root: root}}, false)
244+ if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
245+ t.Fatalf("skills=%#v errs=%v", got, errs)
246+ }
247+}
248+
249+func mustEvalSymlinks(t *testing.T, path string) string {
250+ t.Helper()
251+ resolved, err := filepath.EvalSymlinks(path)
252+ if err != nil {
253+ t.Fatal(err)
254+ }
255+ return resolved
256+}
257+
258+func writeSkill(t *testing.T, dir, front, body string) {
259+ t.Helper()
260+ if err := os.MkdirAll(dir, 0o755); err != nil {
261+ t.Fatal(err)
262+ }
263+ frontmatter := "---\ndescription: test\n---\n"
264+ if front != "" {
265+ frontmatter = "---\n" + front + "\ndescription: test\n---\n"
266+ }
267+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(frontmatter+body), 0o644); err != nil {
268+ t.Fatal(err)
269+ }
270+}
271+
272+func linkDir(t *testing.T, target, path string) {
273+ t.Helper()
274+ if err := os.Symlink(target, path); err != nil {
275+ if runtime.GOOS == "windows" {
276+ t.Skipf("symlinks unavailable: %v", err)
277+ }
278+ t.Fatal(err)
279+ }
280+}
281+
150 func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {282 func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {
151 root := t.TempDir()283 root := t.TempDir()
152 path := filepath.Join(root, "Something", "Writing Git Commits")284 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