1package skills
2
3import (
4 "os"
5 "path/filepath"
6 "runtime"
7 "strings"
8 "testing"
9)
10
11func TestSplitFrontmatter(t *testing.T) {
12 in := "---\nname: foo\ndescription: bar\n---\nbody text\n"
13 front, body := splitFrontmatter(in)
14 if front != "name: foo\ndescription: bar" {
15 t.Errorf("front = %q", front)
16 }
17 if body != "body text\n" {
18 t.Errorf("body = %q", body)
19 }
20
21 front2, body2 := splitFrontmatter("no frontmatter here")
22 if front2 != "" || body2 != "no frontmatter here" {
23 t.Errorf("expected pass-through, got front=%q body=%q", front2, body2)
24 }
25}
26
27func TestParseFrontmatter(t *testing.T) {
28 front := `name: code-review
29description: "Review a recent change."
30disable-model-invocation: true
31allowed-tools: [read, bash]
32permissions:
33 bash: ["git diff*", "git log*"]
34`
35 s := &Skill{}
36 parseFrontmatter(front, s)
37 if s.Name != "code-review" {
38 t.Errorf("name = %q", s.Name)
39 }
40 if s.Description != "Review a recent change." {
41 t.Errorf("description = %q", s.Description)
42 }
43 if !s.DisableModelInvocation {
44 t.Error("disable-model-invocation was not parsed")
45 }
46 if got := s.AllowedTools; len(got) != 2 || got[0] != "read" || got[1] != "bash" {
47 t.Errorf("allowed-tools = %v", got)
48 }
49 if got := s.Permissions["bash"]; len(got) != 2 || got[0] != "git diff*" || got[1] != "git log*" {
50 t.Errorf("permissions[bash] = %v", got)
51 }
52}
53
54func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
55 t.Setenv("ZOT_AGENT_SKILLS", "")
56 tmp := t.TempDir()
57 zotHome := filepath.Join(tmp, "home")
58 cwd := filepath.Join(tmp, "proj")
59
60 mk := func(dir, name, desc string) {
61 full := filepath.Join(dir, name)
62 os.MkdirAll(full, 0o755)
63 body := "---\nname: " + name + "\ndescription: " + desc + "\n---\n# " + name + "\n"
64 os.WriteFile(filepath.Join(full, "SKILL.md"), []byte(body), 0o644)
65 }
66
67 // Same skill name in BOTH project and global; project should win.
68 mk(filepath.Join(cwd, ".zot", "skills"), "shared", "project version")
69 mk(filepath.Join(zotHome, "skills"), "shared", "global version")
70 // Unique skill in global only.
71 mk(filepath.Join(zotHome, "skills"), "global-only", "from global")
72
73 skills, errs := Discover(zotHome, cwd, "", true /* includeUser */)
74 if len(errs) > 0 {
75 t.Fatalf("errs: %v", errs)
76 }
77 // Expect the two user skills + every built-in shipped with the
78 // binary (currently the write-zot-extension authoring guide).
79 builtins := loadBuiltins()
80 want := 2 + len(builtins)
81 if len(skills) != want {
82 t.Fatalf("expected %d skills (2 user + %d built-in), got %d (%v)", want, len(builtins), len(skills), skills)
83 }
84 shared := FindByName(skills, "shared")
85 if shared == nil || shared.Description != "project version" {
86 t.Errorf("expected project to win for 'shared', got %v", shared)
87 }
88 if FindByName(skills, "global-only") == nil {
89 t.Errorf("global-only skill missing")
90 }
91 // At least one built-in should have made it through.
92 for _, b := range builtins {
93 if FindByName(skills, b.Name) == nil {
94 t.Errorf("built-in skill %q missing from Discover output", b.Name)
95 }
96 }
97}
98
99func TestDiscoverWithoutUserSkillsStillIncludesBuiltins(t *testing.T) {
100 got, errs := Discover("", "", "", false)
101 if len(errs) != 0 {
102 t.Fatalf("errs: %v", errs)
103 }
104 for _, builtin := range loadBuiltins() {
105 if FindByName(got, builtin.Name) == nil {
106 t.Errorf("built-in skill %q missing", builtin.Name)
107 }
108 }
109}
110
111func TestKebabCase(t *testing.T) {
112 cases := map[string]string{
113 "writing-git-commits": "writing-git-commits",
114 "Writing Git Commits": "writing-git-commits",
115 "git_commits": "git-commits",
116 "git.commits": "git-commits",
117 "Git/Commits": "git-commits",
118 }
119 for in, want := range cases {
120 if got := KebabCase(in); got != want {
121 t.Errorf("KebabCase(%q) = %q, want %q", in, got, want)
122 }
123 }
124}
125
126func TestDiscoverSourcesFollowsSymlinkedRoot(t *testing.T) {
127 if runtime.GOOS == "windows" {
128 t.Skip("directory symlinks require elevated privileges on Windows")
129 }
130
131 target := t.TempDir()
132 path := filepath.Join(target, "nested", "review")
133 if err := os.MkdirAll(path, 0o755); err != nil {
134 t.Fatal(err)
135 }
136 if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\ndescription: linked\n---\nbody"), 0o644); err != nil {
137 t.Fatal(err)
138 }
139 root := filepath.Join(t.TempDir(), "skills")
140 if err := os.Symlink(target, root); err != nil {
141 t.Fatal(err)
142 }
143
144 got, errs := DiscoverSources([]Source{{Root: root, Prefix: "linked:"}}, false)
145 if len(errs) != 0 || len(got) != 1 || got[0].Name != "linked:nested-review" {
146 t.Fatalf("skills=%#v errs=%v", got, errs)
147 }
148}
149
150func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {
151 root := t.TempDir()
152 path := filepath.Join(root, "Something", "Writing Git Commits")
153 if err := os.MkdirAll(path, 0o755); err != nil {
154 t.Fatal(err)
155 }
156 body := "---\ndescription: nested\n---\nbody"
157 if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte(body), 0o644); err != nil {
158 t.Fatal(err)
159 }
160 got, errs := DiscoverSources([]Source{{Root: root, Label: "extension Git Tools", Prefix: "git-tools:"}}, false)
161 if len(errs) != 0 {
162 t.Fatalf("errs: %v", errs)
163 }
164 if len(got) != 1 || got[0].Name != "git-tools:something-writing-git-commits" {
165 t.Fatalf("skills = %#v", got)
166 }
167 if got[0].Source != "extension Git Tools" || got[0].Body != "body" {
168 t.Errorf("skill metadata = %#v", got[0])
169 }
170}
171
172func TestDiscoverSourcesDuplicateDiagnostic(t *testing.T) {
173 a, b := t.TempDir(), t.TempDir()
174 for _, root := range []string{a, b} {
175 dir := filepath.Join(root, "review")
176 if err := os.MkdirAll(dir, 0o755); err != nil {
177 t.Fatal(err)
178 }
179 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
180 t.Fatal(err)
181 }
182 }
183 got, errs := DiscoverSources([]Source{{Root: a}, {Root: b}}, false)
184 if len(got) != 1 || len(errs) != 1 || !strings.Contains(errs[0].Error(), "shadowed") {
185 t.Fatalf("got=%#v errs=%v", got, errs)
186 }
187}
188
189func TestVisibleSkillsHidesBuiltins(t *testing.T) {
190 in := []*Skill{
191 {Name: "user-one"},
192 {Name: "built-one", Builtin: true},
193 {Name: "user-two"},
194 }
195 out := VisibleSkills(in)
196 if len(out) != 2 {
197 t.Fatalf("expected 2 visible skills, got %d (%v)", len(out), out)
198 }
199 for _, s := range out {
200 if s.Builtin {
201 t.Errorf("built-in %q leaked into visible set", s.Name)
202 }
203 }
204}
205
206func TestSystemPromptAddendum(t *testing.T) {
207 skills := []*Skill{
208 {Name: "built-a", Description: "Do A.", Builtin: true},
209 {Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"},
210 {Name: "manual-c", Description: "Do C.", DisableModelInvocation: true},
211 }
212 out := SystemPromptAddendum(skills)
213 if !contains(out, "- built-a [builtin]: Do A.\n") {
214 t.Errorf("builtin entry missing or wrong:\n%s", out)
215 }
216 if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") {
217 t.Errorf("user entry missing path pointer:\n%s", out)
218 }
219 if contains(out, "manual-c") {
220 t.Errorf("manual skill leaked into system prompt:\n%s", out)
221 }
222}
223
224func TestSystemPromptAddendumEmptyWhenAllSkillsDisableModelInvocation(t *testing.T) {
225 out := SystemPromptAddendum([]*Skill{{Name: "manual", DisableModelInvocation: true}})
226 if out != "" {
227 t.Fatalf("SystemPromptAddendum() = %q, want empty", out)
228 }
229}
230
231func contains(s, sub string) bool {
232 return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && stringIndex(s, sub) >= 0))
233}
234
235func stringIndex(s, sub string) int {
236 for i := 0; i+len(sub) <= len(s); i++ {
237 if s[i:i+len(sub)] == sub {
238 return i
239 }
240 }
241 return -1
242}