1package skills
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10)
11
12func writeSkill(t *testing.T, dir, name, desc string) {
13 t.Helper()
14 if err := os.MkdirAll(dir, 0o755); err != nil {
15 t.Fatal(err)
16 }
17 body := "---\nname: " + name + "\ndescription: " + desc + "\n---\nbody of " + name + "\n"
18 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
19 t.Fatal(err)
20 }
21}
22
23func symlinkOrSkip(t *testing.T, target, link string) {
24 t.Helper()
25 if err := os.Symlink(target, link); err != nil {
26 t.Skipf("symlink unavailable: %v", err)
27 }
28}
29
30func TestDiscoverFollowsSymlinkedSkillDirectories(t *testing.T) {
31 t.Setenv("ZUT_AGENT_SKILLS", "")
32 tmp := t.TempDir()
33 home := filepath.Join(tmp, "home")
34 shared := filepath.Join(tmp, "shared")
35 writeSkill(t, filepath.Join(shared, "linked"), "linked", "from checkout")
36 writeSkill(t, filepath.Join(shared, "group", "inner"), "inner", "nested")
37 root := filepath.Join(home, ".agents", "skills")
38 if err := os.MkdirAll(root, 0o755); err != nil {
39 t.Fatal(err)
40 }
41 symlinkOrSkip(t, filepath.Join(shared, "linked"), filepath.Join(root, "linked"))
42 symlinkOrSkip(t, filepath.Join(shared, "group"), filepath.Join(root, "bundle"))
43 symlinkOrSkip(t, filepath.Join(tmp, "missing"), filepath.Join(root, "dangling"))
44
45 list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
46 if len(errs) != 0 {
47 t.Fatalf("errors = %v", errs)
48 }
49 linked := FindByName(list, "linked")
50 if linked == nil {
51 t.Fatalf("linked skill missing: %v", list)
52 }
53 // The reported path is the installed (link) location.
54 if want := filepath.Join(root, "linked", "SKILL.md"); linked.Path != want {
55 t.Fatalf("path = %q, want %q", linked.Path, want)
56 }
57 inner := FindByName(list, "inner")
58 if inner == nil {
59 t.Fatal("skill under linked parent missing")
60 }
61 // Fork slash aliases follow the walked path and keep the frontmatter name.
62 if FindByName(list, "bundle/inner") != inner {
63 t.Fatalf("alias bundle/inner not registered: %#v", inner.Aliases)
64 }
65}
66
67func TestDiscoverSymlinkedUserRoot(t *testing.T) {
68 t.Setenv("ZUT_AGENT_SKILLS", "")
69 tmp := t.TempDir()
70 real := filepath.Join(tmp, "real-skills")
71 writeSkill(t, filepath.Join(real, "one"), "one", "d")
72 home := filepath.Join(tmp, "home")
73 if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
74 t.Fatal(err)
75 }
76 symlinkOrSkip(t, real, filepath.Join(home, ".claude", "skills"))
77 list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
78 if len(errs) != 0 || FindByName(list, "one") == nil {
79 t.Fatalf("skills=%v errs=%v", list, errs)
80 }
81}
82
83func TestDiscoverSymlinkCycleTerminates(t *testing.T) {
84 t.Setenv("ZUT_AGENT_SKILLS", "")
85 tmp := t.TempDir()
86 home := filepath.Join(tmp, "home")
87 root := filepath.Join(home, ".agents", "skills")
88 writeSkill(t, filepath.Join(root, "a"), "a", "d")
89 symlinkOrSkip(t, root, filepath.Join(root, "a", "loop"))
90 symlinkOrSkip(t, filepath.Join(root, "self"), filepath.Join(root, "self")) // ELOOP
91 list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
92 if len(errs) != 0 {
93 t.Fatalf("errors = %v", errs)
94 }
95 n := 0
96 for _, s := range list {
97 if !s.Builtin {
98 n++
99 }
100 }
101 if n != 1 {
102 t.Fatalf("user skills = %d, want 1: %v", n, list)
103 }
104}
105
106func TestDiscoverPrefersRealDirectoryOverLink(t *testing.T) {
107 t.Setenv("ZUT_AGENT_SKILLS", "")
108 tmp := t.TempDir()
109 home := filepath.Join(tmp, "home")
110 root := filepath.Join(home, ".agents", "skills")
111 // "aaa" sorts before "real" but is a link to it; the real path must win.
112 writeSkill(t, filepath.Join(root, "real"), "real", "d")
113 symlinkOrSkip(t, filepath.Join(root, "real"), filepath.Join(root, "aaa"))
114 list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
115 if len(errs) != 0 {
116 t.Fatalf("errors = %v", errs)
117 }
118 s := FindByName(list, "real")
119 if s == nil || s.Path != filepath.Join(root, "real", "SKILL.md") {
120 t.Fatalf("skill = %#v", s)
121 }
122}
123
124func TestBundledSymlinkedEntriesStayConfinedToRoot(t *testing.T) {
125 tmp := t.TempDir()
126 root := filepath.Join(tmp, "ext")
127 bundle := filepath.Join(root, "skills")
128 writeSkill(t, filepath.Join(root, "shared", "inside"), "inside", "in root")
129 writeSkill(t, filepath.Join(tmp, "outside", "evil"), "evil", "outside root")
130 if err := os.MkdirAll(bundle, 0o755); err != nil {
131 t.Fatal(err)
132 }
133 symlinkOrSkip(t, filepath.Join(root, "shared", "inside"), filepath.Join(bundle, "inside"))
134 symlinkOrSkip(t, filepath.Join(tmp, "outside", "evil"), filepath.Join(bundle, "evil"))
135 symlinkOrSkip(t, filepath.Join(tmp, "nowhere"), filepath.Join(bundle, "dangling"))
136
137 got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
138 if FindByName(got, "inside") == nil {
139 t.Fatalf("in-root link not followed: %v (errs %v)", got, errs)
140 }
141 if FindByName(got, "evil") != nil {
142 t.Fatal("link escaped extension root")
143 }
144 if len(errs) == 0 {
145 t.Fatal("expected escape diagnostic")
146 }
147}
148
149func TestBundledSymlinkCycleAndDanglingAreSilentButRealDirsWin(t *testing.T) {
150 tmp := t.TempDir()
151 root := filepath.Join(tmp, "ext")
152 bundle := filepath.Join(root, "skills")
153 // "aaa" sorts before "zzz" and links to it. Both carry the same
154 // frontmatter name, so whichever loads first owns it: the real directory.
155 realDir := filepath.Join(bundle, "zzz")
156 writeSkill(t, realDir, "shared", "real")
157 symlinkOrSkip(t, realDir, filepath.Join(bundle, "aaa"))
158 symlinkOrSkip(t, filepath.Join(bundle, "loop"), filepath.Join(bundle, "loop"))
159 symlinkOrSkip(t, filepath.Join(tmp, "nowhere"), filepath.Join(bundle, "dangling"))
160 got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
161 if len(errs) != 0 {
162 t.Fatalf("dangling/cyclic links must be skipped silently: %v", errs)
163 }
164 s := FindByName(got, "shared")
165 if s == nil || s.Path != filepath.Join(realDir, "SKILL.md") {
166 t.Fatalf("real directory did not win: %#v", s)
167 }
168 if len(got) != 1 {
169 t.Fatalf("skills = %v", got)
170 }
171}
172
173func TestBundledSymlinkPermissionErrorIsReported(t *testing.T) {
174 if runtime.GOOS == "windows" || os.Geteuid() == 0 {
175 t.Skip("permission bits are not enforced here")
176 }
177 tmp := t.TempDir()
178 root := filepath.Join(tmp, "ext")
179 bundle := filepath.Join(root, "skills")
180 locked := filepath.Join(root, "locked")
181 writeSkill(t, filepath.Join(locked, "hidden"), "hidden", "d")
182 if err := os.MkdirAll(bundle, 0o755); err != nil {
183 t.Fatal(err)
184 }
185 symlinkOrSkip(t, filepath.Join(locked, "hidden"), filepath.Join(bundle, "hidden"))
186 if err := os.Chmod(locked, 0); err != nil {
187 t.Fatal(err)
188 }
189 t.Cleanup(func() { os.Chmod(locked, 0o755) })
190 got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
191 if len(got) != 0 || len(errs) == 0 {
192 t.Fatalf("permission failure was swallowed: skills=%v errs=%v", got, errs)
193 }
194}
195
196func TestDiscoverReportsUnreadableLinkTarget(t *testing.T) {
197 if runtime.GOOS == "windows" || os.Geteuid() == 0 {
198 t.Skip("permission bits are not enforced here")
199 }
200 t.Setenv("ZUT_AGENT_SKILLS", "")
201 tmp := t.TempDir()
202 home := filepath.Join(tmp, "home")
203 root := filepath.Join(home, ".agents", "skills")
204 locked := filepath.Join(tmp, "locked")
205 writeSkill(t, filepath.Join(locked, "x"), "x", "d")
206 if err := os.MkdirAll(root, 0o755); err != nil {
207 t.Fatal(err)
208 }
209 symlinkOrSkip(t, filepath.Join(locked, "x"), filepath.Join(root, "x"))
210 if err := os.Chmod(locked, 0); err != nil {
211 t.Fatal(err)
212 }
213 t.Cleanup(func() { os.Chmod(locked, 0o755) })
214 _, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
215 if len(errs) == 0 {
216 t.Fatal("permission failure on a linked directory was swallowed")
217 }
218}
219
220func userSkillCount(list []*Skill) int {
221 n := 0
222 for _, s := range list {
223 if !s.Builtin {
224 n++
225 }
226 }
227 return n
228}
229
230func TestDiscoverIgnoresLinkToAncestorOfRoot(t *testing.T) {
231 t.Setenv("ZUT_AGENT_SKILLS", "")
232 tmp := t.TempDir()
233 home := filepath.Join(tmp, "home")
234 root := filepath.Join(home, ".agents", "skills")
235 writeSkill(t, filepath.Join(root, "mine"), "mine", "d")
236 // A sibling tree that must never be pulled in through an ancestor link.
237 writeSkill(t, filepath.Join(home, "unrelated", "stray"), "stray", "d")
238 symlinkOrSkip(t, home, filepath.Join(root, "up-home"))
239 symlinkOrSkip(t, filepath.Join(home, ".agents"), filepath.Join(root, "up-agents"))
240 symlinkOrSkip(t, root, filepath.Join(root, "self"))
241 // On Windows a bare separator depends on the process drive, which can
242 // differ from the temporary directory's drive. Use this tree's own root.
243 symlinkOrSkip(t, filepath.VolumeName(root)+string(filepath.Separator), filepath.Join(root, "up-fs-root"))
244 list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
245 if len(errs) != 0 {
246 t.Fatalf("errors = %v", errs)
247 }
248 if FindByName(list, "mine") == nil || FindByName(list, "stray") != nil || userSkillCount(list) != 1 {
249 t.Fatalf("skills = %v", list)
250 }
251}
252
253func TestDiscoverKeepsExternalSharedLinkThatDoesNotContainRoot(t *testing.T) {
254 t.Setenv("ZUT_AGENT_SKILLS", "")
255 tmp := t.TempDir()
256 home := filepath.Join(tmp, "home")
257 root := filepath.Join(home, ".agents", "skills")
258 shared := filepath.Join(tmp, "dev", "skills-checkout")
259 writeSkill(t, filepath.Join(shared, "team", "review"), "review", "d")
260 if err := os.MkdirAll(root, 0o755); err != nil {
261 t.Fatal(err)
262 }
263 symlinkOrSkip(t, shared, filepath.Join(root, "shared"))
264 list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
265 if len(errs) != 0 || FindByName(list, "review") == nil {
266 t.Fatalf("skills=%v errs=%v", list, errs)
267 }
268}
269
270func TestScanUserSkillDirBoundsDepthWithDiagnostic(t *testing.T) {
271 tmp := t.TempDir()
272 root := filepath.Join(tmp, "skills")
273 writeSkill(t, filepath.Join(root, "top"), "top", "d")
274 deep := root
275 for i := 0; i < maxSkillScanDepth+5; i++ {
276 deep = filepath.Join(deep, "d")
277 }
278 writeSkill(t, deep, "too-deep", "d")
279 seen := map[string]*Skill{}
280 errs := scanUserSkillDir(location{dir: root, label: "test"}, seen)
281 if seen["top"] == nil || seen["too-deep"] != nil {
282 t.Fatalf("seen = %v", seen)
283 }
284 if len(errs) != 1 || !strings.Contains(errs[0].Error(), "skill scan stopped early") {
285 t.Fatalf("errs = %v", errs)
286 }
287}
288
289func TestScanUserSkillDirBoundsDirectoryCount(t *testing.T) {
290 tmp := t.TempDir()
291 root := filepath.Join(tmp, "skills")
292 writeSkill(t, filepath.Join(root, "a-first"), "first", "d")
293 for i := 0; i < maxSkillScanDirs+50; i++ {
294 if err := os.MkdirAll(filepath.Join(root, "z-bulk", fmt.Sprintf("d%05d", i)), 0o755); err != nil {
295 t.Fatal(err)
296 }
297 }
298 seen := map[string]*Skill{}
299 errs := scanUserSkillDir(location{dir: root, label: "test"}, seen)
300 if seen["first"] == nil {
301 t.Fatal("skills found before the cap were dropped")
302 }
303 if len(errs) != 1 || !strings.Contains(errs[0].Error(), "more than") {
304 t.Fatalf("errs = %v", errs)
305 }
306}
307
308func TestBundledRealAndAliasUnnamedSkillLoadsOnce(t *testing.T) {
309 tmp := t.TempDir()
310 root := filepath.Join(tmp, "ext")
311 bundle := filepath.Join(root, "skills")
312 bar := filepath.Join(bundle, "bar")
313 if err := os.MkdirAll(bar, 0o755); err != nil {
314 t.Fatal(err)
315 }
316 // No frontmatter name: the directory name is the fallback.
317 if err := os.WriteFile(filepath.Join(bar, "SKILL.md"), []byte("---\ndescription: d\n---\nbody\n"), 0o644); err != nil {
318 t.Fatal(err)
319 }
320 symlinkOrSkip(t, bar, filepath.Join(bundle, "foo")) // sorts after "bar" only by luck; also test the reverse
321 symlinkOrSkip(t, bar, filepath.Join(bundle, "aaa"))
322 got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
323 if len(errs) != 0 {
324 t.Fatalf("errs = %v", errs)
325 }
326 if len(got) != 1 || got[0].Name != "bar" || FindByName(got, "foo") != nil || FindByName(got, "aaa") != nil {
327 t.Fatalf("alias duplicated the skill: %v", got)
328 }
329}