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 TestToPromptXMLDisableModelInvocation(t *testing.T) {
316 t.Parallel()
317
318 skills := []*Skill{
319 {Name: "visible-skill", Description: "This one appears.", SkillFilePath: "/skills/visible/SKILL.md"},
320 {Name: "hidden-skill", Description: "This one is hidden.", SkillFilePath: "/skills/hidden/SKILL.md", DisableModelInvocation: true},
321 }
322
323 xml := ToPromptXML(skills)
324
325 require.Contains(t, xml, "<name>visible-skill</name>")
326 require.NotContains(t, xml, "<name>hidden-skill</name>")
327}
328
329func TestToPromptXMLEmpty(t *testing.T) {
330 t.Parallel()
331 require.Empty(t, ToPromptXML(nil))
332 require.Empty(t, ToPromptXML([]*Skill{}))
333}
334
335func TestEscape(t *testing.T) {
336 t.Parallel()
337
338 tests := []struct {
339 name string
340 in string
341 want string
342 }{
343 {
344 name: "escape xml special chars",
345 in: `<tag attr="x&y">'z'</tag>`,
346 want: `<tag attr="x&y">'z'</tag>`,
347 },
348 {
349 name: "plain text unchanged",
350 in: "hello world",
351 want: "hello world",
352 },
353 }
354
355 for _, tt := range tests {
356 t.Run(tt.name, func(t *testing.T) {
357 t.Parallel()
358 require.Equal(t, tt.want, escape(tt.in))
359 })
360 }
361}
362
363func TestToPromptXMLBuiltinType(t *testing.T) {
364 t.Parallel()
365
366 skills := []*Skill{
367 {Name: "builtin-skill", Description: "A builtin.", SkillFilePath: "crush://skills/builtin-skill/SKILL.md", Builtin: true},
368 {Name: "user-skill", Description: "A user skill.", SkillFilePath: "/home/user/.config/crush/skills/user-skill/SKILL.md"},
369 }
370 xml := ToPromptXML(skills)
371 require.Contains(t, xml, "<type>builtin</type>")
372 require.Equal(t, 1, strings.Count(xml, "<type>builtin</type>"))
373}
374
375func TestParseContent(t *testing.T) {
376 t.Parallel()
377
378 content := []byte(`---
379name: my-skill
380description: A test skill.
381---
382
383# My Skill
384
385Instructions here.
386`)
387 skill, err := ParseContent(content)
388 require.NoError(t, err)
389 require.Equal(t, "my-skill", skill.Name)
390 require.Equal(t, "A test skill.", skill.Description)
391 require.Equal(t, "# My Skill\n\nInstructions here.", skill.Instructions)
392 require.Empty(t, skill.Path)
393 require.Empty(t, skill.SkillFilePath)
394}
395
396func TestParseContent_NoFrontmatter(t *testing.T) {
397 t.Parallel()
398
399 _, err := ParseContent([]byte("# Just Markdown"))
400 require.Error(t, err)
401}
402
403func TestDiscoverBuiltin(t *testing.T) {
404 t.Parallel()
405
406 discovered := DiscoverBuiltin()
407 require.NotEmpty(t, discovered)
408
409 var found bool
410 for _, s := range discovered {
411 if s.Name == "crush-config" {
412 found = true
413 require.True(t, strings.HasPrefix(s.SkillFilePath, BuiltinPrefix))
414 require.True(t, strings.HasPrefix(s.Path, BuiltinPrefix))
415 require.Equal(t, "crush://skills/crush-config/SKILL.md", s.SkillFilePath)
416 require.Equal(t, "crush://skills/crush-config", s.Path)
417 require.NotEmpty(t, s.Description)
418 require.NotEmpty(t, s.Instructions)
419 require.True(t, s.Builtin)
420 }
421 }
422 require.True(t, found, "crush-config builtin skill not found")
423
424 var foundJQ bool
425 for _, s := range discovered {
426 if s.Name == "jq" {
427 foundJQ = true
428 require.Equal(t, "crush://skills/jq/SKILL.md", s.SkillFilePath)
429 require.Equal(t, "crush://skills/jq", s.Path)
430 require.NotEmpty(t, s.Description)
431 require.NotEmpty(t, s.Instructions)
432 require.True(t, s.Builtin)
433 }
434 }
435 require.True(t, foundJQ, "jq builtin skill not found")
436
437 var foundHooks bool
438 for _, s := range discovered {
439 if s.Name == "crush-hooks" {
440 foundHooks = true
441 require.Equal(t, "crush://skills/crush-hooks/SKILL.md", s.SkillFilePath)
442 require.Equal(t, "crush://skills/crush-hooks", s.Path)
443 require.NotEmpty(t, s.Description)
444 require.NotEmpty(t, s.Instructions)
445 require.True(t, s.Builtin)
446 }
447 }
448 require.True(t, foundHooks, "crush-hooks builtin skill not found")
449}
450
451func TestDeduplicate(t *testing.T) {
452 t.Parallel()
453
454 tests := []struct {
455 name string
456 input []*Skill
457 wantLen int
458 wantName string
459 wantPath string
460 }{
461 {
462 name: "no duplicates",
463 input: []*Skill{{Name: "a", Path: "/a"}, {Name: "b", Path: "/b"}},
464 wantLen: 2,
465 },
466 {
467 name: "user overrides builtin",
468 input: []*Skill{{Name: "crush-config", Path: "crush://skills/crush-config"}, {Name: "crush-config", Path: "/user/crush-config"}},
469 wantLen: 1,
470 wantName: "crush-config",
471 wantPath: "/user/crush-config",
472 },
473 {
474 name: "empty",
475 input: nil,
476 wantLen: 0,
477 },
478 }
479
480 for _, tt := range tests {
481 t.Run(tt.name, func(t *testing.T) {
482 t.Parallel()
483 result := Deduplicate(tt.input)
484 require.Len(t, result, tt.wantLen)
485 if tt.wantName != "" {
486 require.Equal(t, tt.wantName, result[0].Name)
487 require.Equal(t, tt.wantPath, result[0].Path)
488 }
489 })
490 }
491}
492
493func TestFilter(t *testing.T) {
494 t.Parallel()
495
496 all := []*Skill{
497 {Name: "a"},
498 {Name: "b"},
499 {Name: "c"},
500 }
501
502 tests := []struct {
503 name string
504 disabled []string
505 wantLen int
506 }{
507 {"no filter", nil, 3},
508 {"filter one", []string{"b"}, 2},
509 {"filter all", []string{"a", "b", "c"}, 0},
510 {"filter nonexistent", []string{"d"}, 3},
511 }
512
513 for _, tt := range tests {
514 t.Run(tt.name, func(t *testing.T) {
515 t.Parallel()
516 result := Filter(all, tt.disabled)
517 require.Len(t, result, tt.wantLen)
518 })
519 }
520}