1package skills
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "github.com/stretchr/testify/require"
10)
11
12func TestParse(t *testing.T) {
13 t.Parallel()
14
15 tests := []struct {
16 name string
17 content string
18 wantName string
19 wantDesc string
20 wantLicense string
21 wantCompat string
22 wantMeta map[string]string
23 wantTools string
24 wantInstr string
25 wantErr bool
26 }{
27 {
28 name: "full skill",
29 content: `---
30name: pdf-processing
31description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.
32license: Apache-2.0
33compatibility: Requires python 3.8+, pdfplumber, pdfrw libraries
34metadata:
35 author: example-org
36 version: "1.0"
37---
38
39# PDF Processing
40
41## When to use this skill
42Use this skill when the user needs to work with PDF files.
43`,
44 wantName: "pdf-processing",
45 wantDesc: "Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.",
46 wantLicense: "Apache-2.0",
47 wantCompat: "Requires python 3.8+, pdfplumber, pdfrw libraries",
48 wantMeta: map[string]string{"author": "example-org", "version": "1.0"},
49 wantInstr: "# PDF Processing\n\n## When to use this skill\nUse this skill when the user needs to work with PDF files.",
50 },
51 {
52 name: "minimal skill",
53 content: `---
54name: my-skill
55description: A simple skill for testing.
56---
57
58# My Skill
59
60Instructions here.
61`,
62 wantName: "my-skill",
63 wantDesc: "A simple skill for testing.",
64 wantInstr: "# My Skill\n\nInstructions here.",
65 },
66 {
67 name: "frontmatter with utf8 bom",
68 content: "\uFEFF---\n" +
69 "name: bom-skill\n" +
70 "description: Skill with bom.\n" +
71 "---\n\n" +
72 "# BOM Skill\n",
73 wantName: "bom-skill",
74 wantDesc: "Skill with bom.",
75 wantInstr: "# BOM Skill",
76 },
77 {
78 name: "frontmatter with leading blank lines",
79 content: "\n\n---\n" +
80 "name: blank-prefix\n" +
81 "description: Skill with leading blank lines.\n" +
82 "---\n\n" +
83 "# Blank Prefix\n",
84 wantName: "blank-prefix",
85 wantDesc: "Skill with leading blank lines.",
86 wantInstr: "# Blank Prefix",
87 },
88 {
89 name: "frontmatter delimiter with trailing spaces",
90 content: "--- \n" +
91 "name: spaced-delimiter\n" +
92 "description: Delimiter has spaces.\n" +
93 "--- \n\n" +
94 "# Spaced Delimiter\n",
95 wantName: "spaced-delimiter",
96 wantDesc: "Delimiter has spaces.",
97 wantInstr: "# Spaced Delimiter",
98 },
99 {
100 name: "no frontmatter",
101 content: "# Just Markdown\n\nNo frontmatter here.",
102 wantErr: true,
103 },
104 }
105
106 for _, tt := range tests {
107 t.Run(tt.name, func(t *testing.T) {
108 t.Parallel()
109
110 // Write content to temp file.
111 dir := t.TempDir()
112 path := filepath.Join(dir, "SKILL.md")
113 require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644))
114
115 skill, err := Parse(path)
116 if tt.wantErr {
117 require.Error(t, err)
118 return
119 }
120 require.NoError(t, err)
121
122 require.Equal(t, tt.wantName, skill.Name)
123 require.Equal(t, tt.wantDesc, skill.Description)
124 require.Equal(t, tt.wantLicense, skill.License)
125 require.Equal(t, tt.wantCompat, skill.Compatibility)
126
127 if tt.wantMeta != nil {
128 require.Equal(t, tt.wantMeta, skill.Metadata)
129 }
130
131 require.Equal(t, tt.wantInstr, skill.Instructions)
132 })
133 }
134}
135
136func TestSkillValidate(t *testing.T) {
137 t.Parallel()
138
139 tests := []struct {
140 name string
141 skill Skill
142 wantErr bool
143 errMsg string
144 }{
145 {
146 name: "valid skill",
147 skill: Skill{
148 Name: "pdf-processing",
149 Description: "Processes PDF files.",
150 Path: "/skills/pdf-processing",
151 },
152 },
153 {
154 name: "missing name",
155 skill: Skill{Description: "Some description."},
156 wantErr: true,
157 errMsg: "name is required",
158 },
159 {
160 name: "missing description",
161 skill: Skill{Name: "my-skill", Path: "/skills/my-skill"},
162 wantErr: true,
163 errMsg: "description is required",
164 },
165 {
166 name: "name too long",
167 skill: Skill{Name: strings.Repeat("a", 65), Description: "Some description."},
168 wantErr: true,
169 errMsg: "exceeds",
170 },
171 {
172 name: "valid name - mixed case",
173 skill: Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"},
174 wantErr: false,
175 },
176 {
177 name: "invalid name - starts with hyphen",
178 skill: Skill{Name: "-my-skill", Description: "Some description."},
179 wantErr: true,
180 errMsg: "alphanumeric with hyphens",
181 },
182 {
183 name: "name doesn't match directory",
184 skill: Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"},
185 wantErr: true,
186 errMsg: "must match directory",
187 },
188 {
189 name: "description too long",
190 skill: Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"},
191 wantErr: true,
192 errMsg: "description exceeds",
193 },
194 {
195 name: "compatibility too long",
196 skill: Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"},
197 wantErr: true,
198 errMsg: "compatibility exceeds",
199 },
200 }
201
202 for _, tt := range tests {
203 t.Run(tt.name, func(t *testing.T) {
204 t.Parallel()
205
206 err := tt.skill.Validate()
207 if tt.wantErr {
208 require.Error(t, err)
209 require.Contains(t, err.Error(), tt.errMsg)
210 } else {
211 require.NoError(t, err)
212 }
213 })
214 }
215}
216
217func TestDiscover(t *testing.T) {
218 t.Parallel()
219
220 tmpDir := t.TempDir()
221
222 // Create valid skill 1.
223 skill1Dir := filepath.Join(tmpDir, "skill-one")
224 require.NoError(t, os.MkdirAll(skill1Dir, 0o755))
225 require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`---
226name: skill-one
227description: First test skill.
228---
229# Skill One
230`), 0o644))
231
232 // Create valid skill 2 in nested directory.
233 skill2Dir := filepath.Join(tmpDir, "nested", "skill-two")
234 require.NoError(t, os.MkdirAll(skill2Dir, 0o755))
235 require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`---
236name: skill-two
237description: Second test skill.
238---
239# Skill Two
240`), 0o644))
241
242 // Create invalid skill (won't be included).
243 invalidDir := filepath.Join(tmpDir, "invalid-dir")
244 require.NoError(t, os.MkdirAll(invalidDir, 0o755))
245 require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "SKILL.md"), []byte(`---
246name: wrong-name
247description: Name doesn't match directory.
248---
249`), 0o644))
250
251 skills, states := DiscoverWithStates([]string{tmpDir})
252
253 var normalCount int
254 var errorCount int
255 var hasInvalidDir bool
256 for _, state := range states {
257 if state.State == StateNormal {
258 normalCount++
259 }
260 if state.State == StateError {
261 errorCount++
262 if strings.Contains(state.Path, "invalid-dir") {
263 hasInvalidDir = true
264 }
265 }
266 }
267 require.Equal(t, 2, normalCount)
268 require.Equal(t, 1, errorCount)
269 require.True(t, hasInvalidDir)
270 require.Len(t, skills, 2)
271 require.Equal(t, []string{"skill-two", "skill-one"}, []string{skills[0].Name, skills[1].Name})
272
273 names := make(map[string]bool)
274 for _, s := range skills {
275 names[s.Name] = true
276 }
277 require.True(t, names["skill-one"])
278 require.True(t, names["skill-two"])
279}
280
281func TestDiscoverEmptyDir(t *testing.T) {
282 t.Parallel()
283
284 tmpDir := t.TempDir()
285
286 skills, states := DiscoverWithStates([]string{tmpDir})
287 require.Empty(t, states)
288 require.Empty(t, skills)
289}
290
291func TestDiscoverMissingPath(t *testing.T) {
292 t.Parallel()
293
294 skills, states := DiscoverWithStates([]string{filepath.Join(t.TempDir(), "missing")})
295 require.Empty(t, states)
296 require.Empty(t, skills)
297}
298
299func TestToPromptXML(t *testing.T) {
300 t.Parallel()
301
302 skills := []*Skill{
303 {Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"},
304 {Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"},
305 }
306
307 xml := ToPromptXML(skills)
308
309 require.Contains(t, xml, "<available_skills>")
310 require.Contains(t, xml, "<name>pdf-processing</name>")
311 require.Contains(t, xml, "<description>Extracts text from PDFs.</description>")
312 require.Contains(t, xml, "&") // XML escaping
313}
314
315func TestToPromptXMLEmpty(t *testing.T) {
316 t.Parallel()
317 require.Empty(t, ToPromptXML(nil))
318 require.Empty(t, ToPromptXML([]*Skill{}))
319}
320
321func TestEscape(t *testing.T) {
322 t.Parallel()
323
324 tests := []struct {
325 name string
326 in string
327 want string
328 }{
329 {
330 name: "escape xml special chars",
331 in: `<tag attr="x&y">'z'</tag>`,
332 want: `<tag attr="x&y">'z'</tag>`,
333 },
334 {
335 name: "plain text unchanged",
336 in: "hello world",
337 want: "hello world",
338 },
339 }
340
341 for _, tt := range tests {
342 t.Run(tt.name, func(t *testing.T) {
343 t.Parallel()
344 require.Equal(t, tt.want, escape(tt.in))
345 })
346 }
347}
348
349func TestToPromptXMLBuiltinType(t *testing.T) {
350 t.Parallel()
351
352 skills := []*Skill{
353 {Name: "builtin-skill", Description: "A builtin.", SkillFilePath: "crush://skills/builtin-skill/SKILL.md", Builtin: true},
354 {Name: "user-skill", Description: "A user skill.", SkillFilePath: "/home/user/.config/crush/skills/user-skill/SKILL.md"},
355 }
356 xml := ToPromptXML(skills)
357 require.Contains(t, xml, "<type>builtin</type>")
358 require.Equal(t, 1, strings.Count(xml, "<type>builtin</type>"))
359}
360
361func TestParseContent(t *testing.T) {
362 t.Parallel()
363
364 content := []byte(`---
365name: my-skill
366description: A test skill.
367---
368
369# My Skill
370
371Instructions here.
372`)
373 skill, err := ParseContent(content)
374 require.NoError(t, err)
375 require.Equal(t, "my-skill", skill.Name)
376 require.Equal(t, "A test skill.", skill.Description)
377 require.Equal(t, "# My Skill\n\nInstructions here.", skill.Instructions)
378 require.Empty(t, skill.Path)
379 require.Empty(t, skill.SkillFilePath)
380}
381
382func TestParseContent_NoFrontmatter(t *testing.T) {
383 t.Parallel()
384
385 _, err := ParseContent([]byte("# Just Markdown"))
386 require.Error(t, err)
387}
388
389func TestDiscoverBuiltin(t *testing.T) {
390 t.Parallel()
391
392 discovered := DiscoverBuiltin()
393 require.NotEmpty(t, discovered)
394
395 var found bool
396 for _, s := range discovered {
397 if s.Name == "crush-config" {
398 found = true
399 require.True(t, strings.HasPrefix(s.SkillFilePath, BuiltinPrefix))
400 require.True(t, strings.HasPrefix(s.Path, BuiltinPrefix))
401 require.Equal(t, "crush://skills/crush-config/SKILL.md", s.SkillFilePath)
402 require.Equal(t, "crush://skills/crush-config", s.Path)
403 require.NotEmpty(t, s.Description)
404 require.NotEmpty(t, s.Instructions)
405 require.True(t, s.Builtin)
406 }
407 }
408 require.True(t, found, "crush-config builtin skill not found")
409
410 var foundJQ bool
411 for _, s := range discovered {
412 if s.Name == "jq" {
413 foundJQ = true
414 require.Equal(t, "crush://skills/jq/SKILL.md", s.SkillFilePath)
415 require.Equal(t, "crush://skills/jq", s.Path)
416 require.NotEmpty(t, s.Description)
417 require.NotEmpty(t, s.Instructions)
418 require.True(t, s.Builtin)
419 }
420 }
421 require.True(t, foundJQ, "jq builtin skill not found")
422
423 var foundHooks bool
424 for _, s := range discovered {
425 if s.Name == "crush-hooks" {
426 foundHooks = true
427 require.Equal(t, "crush://skills/crush-hooks/SKILL.md", s.SkillFilePath)
428 require.Equal(t, "crush://skills/crush-hooks", s.Path)
429 require.NotEmpty(t, s.Description)
430 require.NotEmpty(t, s.Instructions)
431 require.True(t, s.Builtin)
432 }
433 }
434 require.True(t, foundHooks, "crush-hooks builtin skill not found")
435}
436
437func TestDeduplicate(t *testing.T) {
438 t.Parallel()
439
440 tests := []struct {
441 name string
442 input []*Skill
443 wantLen int
444 wantName string
445 wantPath string
446 }{
447 {
448 name: "no duplicates",
449 input: []*Skill{{Name: "a", Path: "/a"}, {Name: "b", Path: "/b"}},
450 wantLen: 2,
451 },
452 {
453 name: "user overrides builtin",
454 input: []*Skill{{Name: "crush-config", Path: "crush://skills/crush-config"}, {Name: "crush-config", Path: "/user/crush-config"}},
455 wantLen: 1,
456 wantName: "crush-config",
457 wantPath: "/user/crush-config",
458 },
459 {
460 name: "empty",
461 input: nil,
462 wantLen: 0,
463 },
464 }
465
466 for _, tt := range tests {
467 t.Run(tt.name, func(t *testing.T) {
468 t.Parallel()
469 result := Deduplicate(tt.input)
470 require.Len(t, result, tt.wantLen)
471 if tt.wantName != "" {
472 require.Equal(t, tt.wantName, result[0].Name)
473 require.Equal(t, tt.wantPath, result[0].Path)
474 }
475 })
476 }
477}
478
479func TestFilter(t *testing.T) {
480 t.Parallel()
481
482 all := []*Skill{
483 {Name: "a"},
484 {Name: "b"},
485 {Name: "c"},
486 }
487
488 tests := []struct {
489 name string
490 disabled []string
491 wantLen int
492 }{
493 {"no filter", nil, 3},
494 {"filter one", []string{"b"}, 2},
495 {"filter all", []string{"a", "b", "c"}, 0},
496 {"filter nonexistent", []string{"d"}, 3},
497 }
498
499 for _, tt := range tests {
500 t.Run(tt.name, func(t *testing.T) {
501 t.Parallel()
502 result := Filter(all, tt.disabled)
503 require.Len(t, result, tt.wantLen)
504 })
505 }
506}