@@ -0,0 +1,249 @@
1+package skills
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func 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: `---
30+name: pdf-processing
31+description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.
32+license: Apache-2.0
33+compatibility: Requires python 3.8+, pdfplumber, pdfrw libraries
34+metadata:
35+ author: example-org
36+ version: "1.0"
37+---
38+
39+# PDF Processing
40+
41+## When to use this skill
42+Use 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: `---
54+name: my-skill
55+description: A simple skill for testing.
56+---
57+
58+# My Skill
59+
60+Instructions here.
61+`,
62+ wantName: "my-skill",
63+ wantDesc: "A simple skill for testing.",
64+ wantInstr: "# My Skill\n\nInstructions here.",
65+ },
66+ {
67+ name: "no frontmatter",
68+ content: "# Just Markdown\n\nNo frontmatter here.",
69+ wantErr: true,
70+ },
71+ }
72+
73+ for _, tt := range tests {
74+ t.Run(tt.name, func(t *testing.T) {
75+ t.Parallel()
76+
77+ // Write content to temp file.
78+ dir := t.TempDir()
79+ path := filepath.Join(dir, "SKILL.md")
80+ require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644))
81+
82+ skill, err := Parse(path)
83+ if tt.wantErr {
84+ require.Error(t, err)
85+ return
86+ }
87+ require.NoError(t, err)
88+
89+ require.Equal(t, tt.wantName, skill.Name)
90+ require.Equal(t, tt.wantDesc, skill.Description)
91+ require.Equal(t, tt.wantLicense, skill.License)
92+ require.Equal(t, tt.wantCompat, skill.Compatibility)
93+
94+ if tt.wantMeta != nil {
95+ require.Equal(t, tt.wantMeta, skill.Metadata)
96+ }
97+
98+ require.Equal(t, tt.wantInstr, skill.Instructions)
99+ })
100+ }
101+}
102+
103+func TestSkillValidate(t *testing.T) {
104+ t.Parallel()
105+
106+ tests := []struct {
107+ name string
108+ skill Skill
109+ wantErr bool
110+ errMsg string
111+ }{
112+ {
113+ name: "valid skill",
114+ skill: Skill{
115+ Name: "pdf-processing",
116+ Description: "Processes PDF files.",
117+ Path: "/skills/pdf-processing",
118+ },
119+ },
120+ {
121+ name: "missing name",
122+ skill: Skill{Description: "Some description."},
123+ wantErr: true,
124+ errMsg: "name is required",
125+ },
126+ {
127+ name: "missing description",
128+ skill: Skill{Name: "my-skill", Path: "/skills/my-skill"},
129+ wantErr: true,
130+ errMsg: "description is required",
131+ },
132+ {
133+ name: "name too long",
134+ skill: Skill{Name: strings.Repeat("a", 65), Description: "Some description."},
135+ wantErr: true,
136+ errMsg: "exceeds",
137+ },
138+ {
139+ name: "valid name - mixed case",
140+ skill: Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"},
141+ wantErr: false,
142+ },
143+ {
144+ name: "invalid name - starts with hyphen",
145+ skill: Skill{Name: "-my-skill", Description: "Some description."},
146+ wantErr: true,
147+ errMsg: "alphanumeric with hyphens",
148+ },
149+ {
150+ name: "name doesn't match directory",
151+ skill: Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"},
152+ wantErr: true,
153+ errMsg: "must match directory",
154+ },
155+ {
156+ name: "description too long",
157+ skill: Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"},
158+ wantErr: true,
159+ errMsg: "description exceeds",
160+ },
161+ {
162+ name: "compatibility too long",
163+ skill: Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"},
164+ wantErr: true,
165+ errMsg: "compatibility exceeds",
166+ },
167+ }
168+
169+ for _, tt := range tests {
170+ t.Run(tt.name, func(t *testing.T) {
171+ t.Parallel()
172+
173+ err := tt.skill.Validate()
174+ if tt.wantErr {
175+ require.Error(t, err)
176+ require.Contains(t, err.Error(), tt.errMsg)
177+ } else {
178+ require.NoError(t, err)
179+ }
180+ })
181+ }
182+}
183+
184+func TestDiscover(t *testing.T) {
185+ t.Parallel()
186+
187+ tmpDir := t.TempDir()
188+
189+ // Create valid skill 1.
190+ skill1Dir := filepath.Join(tmpDir, "skill-one")
191+ require.NoError(t, os.MkdirAll(skill1Dir, 0o755))
192+ require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`---
193+name: skill-one
194+description: First test skill.
195+---
196+# Skill One
197+`), 0o644))
198+
199+ // Create valid skill 2 in nested directory.
200+ skill2Dir := filepath.Join(tmpDir, "nested", "skill-two")
201+ require.NoError(t, os.MkdirAll(skill2Dir, 0o755))
202+ require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`---
203+name: skill-two
204+description: Second test skill.
205+---
206+# Skill Two
207+`), 0o644))
208+
209+ // Create invalid skill (won't be included).
210+ invalidDir := filepath.Join(tmpDir, "invalid-dir")
211+ require.NoError(t, os.MkdirAll(invalidDir, 0o755))
212+ require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "SKILL.md"), []byte(`---
213+name: wrong-name
214+description: Name doesn't match directory.
215+---
216+`), 0o644))
217+
218+ skills := Discover([]string{tmpDir})
219+ require.Len(t, skills, 2)
220+
221+ names := make(map[string]bool)
222+ for _, s := range skills {
223+ names[s.Name] = true
224+ }
225+ require.True(t, names["skill-one"])
226+ require.True(t, names["skill-two"])
227+}
228+
229+func TestToPromptXML(t *testing.T) {
230+ t.Parallel()
231+
232+ skills := []*Skill{
233+ {Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"},
234+ {Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"},
235+ }
236+
237+ xml := ToPromptXML(skills)
238+
239+ require.Contains(t, xml, "<available_skills>")
240+ require.Contains(t, xml, "<name>pdf-processing</name>")
241+ require.Contains(t, xml, "<description>Extracts text from PDFs.</description>")
242+ require.Contains(t, xml, "&") // XML escaping
243+}
244+
245+func TestToPromptXMLEmpty(t *testing.T) {
246+ t.Parallel()
247+ require.Empty(t, ToPromptXML(nil))
248+ require.Empty(t, ToPromptXML([]*Skill{}))
249+}