1package skills
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "syscall"
10 "testing"
11)
12
13func TestSplitFrontmatter(t *testing.T) {
14 in := "---\nname: foo\ndescription: bar\n---\nbody text\n"
15 front, body := splitFrontmatter(in)
16 if front != "name: foo\ndescription: bar" {
17 t.Errorf("front = %q", front)
18 }
19 if body != "body text\n" {
20 t.Errorf("body = %q", body)
21 }
22
23 front2, body2 := splitFrontmatter("no frontmatter here")
24 if front2 != "" || body2 != "no frontmatter here" {
25 t.Errorf("expected pass-through, got front=%q body=%q", front2, body2)
26 }
27}
28
29func TestParseFrontmatter(t *testing.T) {
30 front := `name: code-review
31description: "Review a recent change."
32disable-model-invocation: true
33allowed-tools: [read, bash]
34permissions:
35 bash: ["git diff*", "git log*"]
36`
37 s := &Skill{}
38 parseFrontmatter(front, s)
39 if s.Name != "code-review" {
40 t.Errorf("name = %q", s.Name)
41 }
42 if s.Description != "Review a recent change." {
43 t.Errorf("description = %q", s.Description)
44 }
45 if !s.DisableModelInvocation {
46 t.Error("disable-model-invocation was not parsed")
47 }
48 if got := s.AllowedTools; len(got) != 2 || got[0] != "read" || got[1] != "bash" {
49 t.Errorf("allowed-tools = %v", got)
50 }
51 if got := s.Permissions["bash"]; len(got) != 2 || got[0] != "git diff*" || got[1] != "git log*" {
52 t.Errorf("permissions[bash] = %v", got)
53 }
54}
55
56func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
57 t.Setenv("ZOT_AGENT_SKILLS", "")
58 tmp := t.TempDir()
59 zotHome := filepath.Join(tmp, "home")
60 cwd := filepath.Join(tmp, "proj")
61
62 mk := func(dir, name, desc string) {
63 full := filepath.Join(dir, name)
64 os.MkdirAll(full, 0o755)
65 body := "---\nname: " + name + "\ndescription: " + desc + "\n---\n# " + name + "\n"
66 os.WriteFile(filepath.Join(full, "SKILL.md"), []byte(body), 0o644)
67 }
68
69 // Same skill name in BOTH project and global; project should win.
70 mk(filepath.Join(cwd, ".zot", "skills"), "shared", "project version")
71 mk(filepath.Join(zotHome, "skills"), "shared", "global version")
72 // Unique skill in global only.
73 mk(filepath.Join(zotHome, "skills"), "global-only", "from global")
74
75 skills, errs := Discover(zotHome, cwd, "", true /* includeUser */)
76 if len(errs) > 0 {
77 t.Fatalf("errs: %v", errs)
78 }
79 // Expect the two user skills + every built-in shipped with the
80 // binary (currently the write-zot-extension authoring guide).
81 builtins := loadBuiltins()
82 want := 2 + len(builtins)
83 if len(skills) != want {
84 t.Fatalf("expected %d skills (2 user + %d built-in), got %d (%v)", want, len(builtins), len(skills), skills)
85 }
86 shared := FindByName(skills, "shared")
87 if shared == nil || shared.Description != "project version" {
88 t.Errorf("expected project to win for 'shared', got %v", shared)
89 }
90 if FindByName(skills, "global-only") == nil {
91 t.Errorf("global-only skill missing")
92 }
93 // At least one built-in should have made it through.
94 for _, b := range builtins {
95 if FindByName(skills, b.Name) == nil {
96 t.Errorf("built-in skill %q missing from Discover output", b.Name)
97 }
98 }
99}
100
101func TestDiscoverWithoutUserSkillsStillIncludesBuiltins(t *testing.T) {
102 got, errs := Discover("", "", "", false)
103 if len(errs) != 0 {
104 t.Fatalf("errs: %v", errs)
105 }
106 for _, builtin := range loadBuiltins() {
107 if FindByName(got, builtin.Name) == nil {
108 t.Errorf("built-in skill %q missing", builtin.Name)
109 }
110 }
111}
112
113func TestKebabCase(t *testing.T) {
114 cases := map[string]string{
115 "writing-git-commits": "writing-git-commits",
116 "Writing Git Commits": "writing-git-commits",
117 "git_commits": "git-commits",
118 "git.commits": "git-commits",
119 "Git/Commits": "git-commits",
120 }
121 for in, want := range cases {
122 if got := KebabCase(in); got != want {
123 t.Errorf("KebabCase(%q) = %q, want %q", in, got, want)
124 }
125 }
126}
127
128func TestDiscoverSourcesFollowsSymlinkedRoot(t *testing.T) {
129 if runtime.GOOS == "windows" {
130 t.Skip("directory symlinks require elevated privileges on Windows")
131 }
132
133 target := t.TempDir()
134 path := filepath.Join(target, "nested", "review")
135 if err := os.MkdirAll(path, 0o755); err != nil {
136 t.Fatal(err)
137 }
138 if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\ndescription: linked\n---\nbody"), 0o644); err != nil {
139 t.Fatal(err)
140 }
141 root := filepath.Join(t.TempDir(), "skills")
142 if err := os.Symlink(target, root); err != nil {
143 t.Fatal(err)
144 }
145
146 got, errs := DiscoverSources([]Source{{Root: root, Prefix: "linked:"}}, false)
147 if len(errs) != 0 || len(got) != 1 || got[0].Name != "linked:nested-review" {
148 t.Fatalf("skills=%#v errs=%v", got, errs)
149 }
150}
151
152func TestDiscoverSourcesFollowsSymlinkedSkillDir(t *testing.T) {
153 shared := t.TempDir()
154 writeSkill(t, filepath.Join(shared, "tdd"), "name: tdd", "shared body")
155 root := t.TempDir()
156 linkDir(t, filepath.Join(shared, "tdd"), filepath.Join(root, "tdd"))
157
158 got, errs := DiscoverSources([]Source{{Root: root, Label: "global"}}, false)
159 if len(errs) != 0 || len(got) != 1 {
160 t.Fatalf("skills=%#v errs=%v", got, errs)
161 }
162 if got[0].Name != "tdd" || got[0].Body != "shared body" || got[0].Source != "global" {
163 t.Errorf("skill = %#v", got[0])
164 }
165 if want := filepath.Join(mustEvalSymlinks(t, root), "tdd", "SKILL.md"); got[0].Path != want {
166 t.Errorf("path = %q, want the link location %q", got[0].Path, want)
167 }
168}
169
170func TestDiscoverSourcesFollowsSymlinkedCategoryDir(t *testing.T) {
171 shared := t.TempDir()
172 writeSkill(t, filepath.Join(shared, "code-review"), "name: code-review", "named body")
173 writeSkill(t, filepath.Join(shared, "prototype"), "", "unnamed body")
174 root := t.TempDir()
175 linkDir(t, shared, filepath.Join(root, "engineering"))
176
177 got, errs := DiscoverSources([]Source{{Root: root}}, false)
178 if len(errs) != 0 || len(got) != 2 {
179 t.Fatalf("skills=%#v errs=%v", got, errs)
180 }
181 if got[0].Name != "code-review" || got[1].Name != "engineering-prototype" {
182 t.Errorf("names = %q, %q", got[0].Name, got[1].Name)
183 }
184}
185
186func TestDiscoverSourcesSymlinkCycleTerminates(t *testing.T) {
187 root := t.TempDir()
188 writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
189 linkDir(t, root, filepath.Join(root, "loop"))
190 linkDir(t, root, filepath.Join(root, "alias"))
191
192 got, errs := DiscoverSources([]Source{{Root: root}}, false)
193 if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
194 t.Fatalf("skills=%#v errs=%v", got, errs)
195 }
196}
197
198func TestDiscoverSourcesIgnoresDanglingSymlink(t *testing.T) {
199 root := t.TempDir()
200 writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
201 missing := filepath.Join(t.TempDir(), "gone")
202 linkDir(t, missing, filepath.Join(root, "gone"))
203
204 got, errs := DiscoverSources([]Source{{Root: root}}, false)
205 if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
206 t.Fatalf("skills=%#v errs=%v", got, errs)
207 }
208}
209
210func TestDiscoverSourcesWalksLinkedDirOnce(t *testing.T) {
211 root := t.TempDir()
212 real := filepath.Join(root, "shared")
213 writeSkill(t, real, "name: review", "body")
214 linkDir(t, real, filepath.Join(root, "alias"))
215
216 got, errs := DiscoverSources([]Source{{Root: root}}, false)
217 if len(errs) != 0 || len(got) != 1 {
218 t.Fatalf("skills=%#v errs=%v", got, errs)
219 }
220 if want := filepath.Join(mustEvalSymlinks(t, root), "shared", "SKILL.md"); got[0].Path != want {
221 t.Errorf("path = %q, want %q", got[0].Path, want)
222 }
223}
224
225func TestDiscoverSourcesKeepsUnnamedRealSkillWhenAliasSortsFirst(t *testing.T) {
226 root := t.TempDir()
227 real := filepath.Join(root, "z", "review")
228 writeSkill(t, real, "", "body")
229 linkDir(t, real, filepath.Join(root, "alias"))
230
231 got, errs := DiscoverSources([]Source{{Root: root}}, false)
232 if len(errs) != 0 || len(got) != 1 {
233 t.Fatalf("skills=%#v errs=%v", got, errs)
234 }
235 if got[0].Name != "z-review" || got[0].Path != filepath.Join(mustEvalSymlinks(t, root), "z", "review", "SKILL.md") {
236 t.Errorf("skill = %#v, want the real directory name and path", got[0])
237 }
238}
239
240func TestDiscoverSourcesIgnoresCyclicSymlink(t *testing.T) {
241 root := t.TempDir()
242 writeSkill(t, filepath.Join(root, "review"), "name: review", "body")
243 linkDir(t, filepath.Join(root, "loop"), filepath.Join(root, "loop"))
244
245 got, errs := DiscoverSources([]Source{{Root: root}}, false)
246 if len(errs) != 0 || len(got) != 1 || got[0].Name != "review" {
247 t.Fatalf("skills=%#v errs=%v", got, errs)
248 }
249}
250
251func TestSymlinkLoopError(t *testing.T) {
252 if !symlinkLoopError(fmt.Errorf("stat: %w", syscall.ELOOP)) {
253 t.Error("ELOOP should be ignored")
254 }
255 if runtime.GOOS == "windows" && !symlinkLoopError(fmt.Errorf("stat: %w", syscall.Errno(1921))) {
256 t.Error("Windows cyclic-link error should be ignored")
257 }
258 if symlinkLoopError(fmt.Errorf("stat: %w", os.ErrPermission)) {
259 t.Error("permission errors must still be reported")
260 }
261}
262
263func mustEvalSymlinks(t *testing.T, path string) string {
264 t.Helper()
265 resolved, err := filepath.EvalSymlinks(path)
266 if err != nil {
267 t.Fatal(err)
268 }
269 return resolved
270}
271
272func writeSkill(t *testing.T, dir, front, body string) {
273 t.Helper()
274 if err := os.MkdirAll(dir, 0o755); err != nil {
275 t.Fatal(err)
276 }
277 frontmatter := "---\ndescription: test\n---\n"
278 if front != "" {
279 frontmatter = "---\n" + front + "\ndescription: test\n---\n"
280 }
281 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(frontmatter+body), 0o644); err != nil {
282 t.Fatal(err)
283 }
284}
285
286func linkDir(t *testing.T, target, path string) {
287 t.Helper()
288 if err := os.Symlink(target, path); err != nil {
289 if runtime.GOOS == "windows" {
290 t.Skipf("symlinks unavailable: %v", err)
291 }
292 t.Fatal(err)
293 }
294}
295
296func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {
297 root := t.TempDir()
298 path := filepath.Join(root, "Something", "Writing Git Commits")
299 if err := os.MkdirAll(path, 0o755); err != nil {
300 t.Fatal(err)
301 }
302 body := "---\ndescription: nested\n---\nbody"
303 if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte(body), 0o644); err != nil {
304 t.Fatal(err)
305 }
306 got, errs := DiscoverSources([]Source{{Root: root, Label: "extension Git Tools", Prefix: "git-tools:"}}, false)
307 if len(errs) != 0 {
308 t.Fatalf("errs: %v", errs)
309 }
310 if len(got) != 1 || got[0].Name != "git-tools:something-writing-git-commits" {
311 t.Fatalf("skills = %#v", got)
312 }
313 if got[0].Source != "extension Git Tools" || got[0].Body != "body" {
314 t.Errorf("skill metadata = %#v", got[0])
315 }
316}
317
318func TestDiscoverSourcesHomeDirectoryOverlap(t *testing.T) {
319 t.Setenv("ZOT_AGENT_SKILLS", "")
320 home := t.TempDir()
321 for _, compat := range []string{".agents", ".claude"} {
322 dir := filepath.Join(home, compat, "skills", "review")
323 if err := os.MkdirAll(dir, 0o755); err != nil {
324 t.Fatal(err)
325 }
326 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: "+compat[1:]+"-review\n---\nbody"), 0o644); err != nil {
327 t.Fatal(err)
328 }
329 }
330 got, errs := DiscoverSources(SearchSources(t.TempDir(), home, home), false)
331 if len(got) != 2 || len(errs) != 0 {
332 t.Fatalf("skills=%#v errs=%v", got, errs)
333 }
334 for _, s := range got {
335 if !strings.HasPrefix(s.Source, "project") {
336 t.Errorf("source = %q, want project precedence", s.Source)
337 }
338 }
339}
340
341func TestDiscoverSourcesSameFile(t *testing.T) {
342 root := t.TempDir()
343 path := filepath.Join(root, "SKILL.md")
344 if err := os.WriteFile(path, []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
345 t.Fatal(err)
346 }
347 for _, second := range []string{root, path} {
348 got, errs := DiscoverSources([]Source{{Root: root, Label: "first"}, {Root: second, Label: "second"}}, false)
349 if len(got) != 1 || len(errs) != 0 || got[0].Source != "first" {
350 t.Fatalf("second=%q skills=%#v errs=%v", second, got, errs)
351 }
352 }
353 got, errs := DiscoverSources([]Source{{Root: root, Prefix: "one:"}, {Root: root, Prefix: "two:"}}, false)
354 if len(got) != 2 || len(errs) != 0 || got[0].Name != "one:review" || got[1].Name != "two:review" {
355 t.Fatalf("namespaced skills=%#v errs=%v", got, errs)
356 }
357}
358
359func TestDiscoverSourcesSameFileSymlink(t *testing.T) {
360 root := t.TempDir()
361 path := filepath.Join(root, "SKILL.md")
362 if err := os.WriteFile(path, []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
363 t.Fatal(err)
364 }
365 alias := filepath.Join(t.TempDir(), "SKILL.md")
366 if err := os.Symlink(path, alias); err != nil {
367 if runtime.GOOS == "windows" {
368 t.Skipf("symlinks unavailable: %v", err)
369 }
370 t.Fatal(err)
371 }
372 got, errs := DiscoverSources([]Source{{Root: root, Label: "first"}, {Root: alias}}, false)
373 if len(got) != 1 || len(errs) != 0 || got[0].Source != "first" {
374 t.Fatalf("skills=%#v errs=%v", got, errs)
375 }
376}
377
378func TestDiscoverSourcesDuplicateDiagnostic(t *testing.T) {
379 a, b := t.TempDir(), t.TempDir()
380 for _, root := range []string{a, b} {
381 dir := filepath.Join(root, "review")
382 if err := os.MkdirAll(dir, 0o755); err != nil {
383 t.Fatal(err)
384 }
385 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
386 t.Fatal(err)
387 }
388 }
389 got, errs := DiscoverSources([]Source{{Root: a}, {Root: b}}, false)
390 if len(got) != 1 || len(errs) != 1 || !strings.Contains(errs[0].Error(), "shadowed") {
391 t.Fatalf("got=%#v errs=%v", got, errs)
392 }
393}
394
395func TestVisibleSkillsHidesBuiltins(t *testing.T) {
396 in := []*Skill{
397 {Name: "user-one"},
398 {Name: "built-one", Builtin: true},
399 {Name: "user-two"},
400 }
401 out := VisibleSkills(in)
402 if len(out) != 2 {
403 t.Fatalf("expected 2 visible skills, got %d (%v)", len(out), out)
404 }
405 for _, s := range out {
406 if s.Builtin {
407 t.Errorf("built-in %q leaked into visible set", s.Name)
408 }
409 }
410}
411
412func TestSystemPromptAddendum(t *testing.T) {
413 skills := []*Skill{
414 {Name: "built-a", Description: "Do A.", Builtin: true},
415 {Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"},
416 {Name: "manual-c", Description: "Do C.", DisableModelInvocation: true},
417 }
418 out := SystemPromptAddendum(skills)
419 if !contains(out, "- built-a [builtin]: Do A.\n") {
420 t.Errorf("builtin entry missing or wrong:\n%s", out)
421 }
422 if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") {
423 t.Errorf("user entry missing path pointer:\n%s", out)
424 }
425 if contains(out, "manual-c") {
426 t.Errorf("manual skill leaked into system prompt:\n%s", out)
427 }
428}
429
430func TestSystemPromptAddendumEmptyWhenAllSkillsDisableModelInvocation(t *testing.T) {
431 out := SystemPromptAddendum([]*Skill{{Name: "manual", DisableModelInvocation: true}})
432 if out != "" {
433 t.Fatalf("SystemPromptAddendum() = %q, want empty", out)
434 }
435}
436
437func contains(s, sub string) bool {
438 return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && stringIndex(s, sub) >= 0))
439}
440
441func stringIndex(s, sub string) int {
442 for i := 0; i+len(sub) <= len(s); i++ {
443 if s[i:i+len(sub)] == sub {
444 return i
445 }
446 }
447 return -1
448}