1// Package skills implements the Agent Skills specification.
2// See https://agentskills.io for the full specification.
3package skills
4
5import (
6 "html"
7 "os"
8 "path/filepath"
9 "strings"
10 "unicode"
11)
12
13const (
14 MaxNameLength = 64
15 MaxDescriptionLength = 1024
16 MaxCompatibilityLength = 500
17)
18
19// Skill represents a parsed skill from a SKILL.md file.
20type Skill struct {
21 Name string `json:"name"`
22 Description string `json:"description"`
23 License string `json:"license,omitempty"`
24 Compatibility string `json:"compatibility,omitempty"`
25 AllowedTools string `json:"allowed_tools,omitempty"`
26 Metadata map[string]string `json:"metadata,omitempty"`
27 Path string `json:"path"` // Path to SKILL.md file
28}
29
30// Discover finds all skills in the given directories.
31// It scans each directory for subdirectories containing SKILL.md files.
32func Discover(dirs []string) []Skill {
33 var skills []Skill
34 seen := make(map[string]bool)
35
36 for _, dir := range dirs {
37 dir = expandPath(dir)
38 entries, err := os.ReadDir(dir)
39 if err != nil {
40 continue
41 }
42
43 for _, entry := range entries {
44 skillDir := filepath.Join(dir, entry.Name())
45 // entry.IsDir() returns false for symlinks, even to directories;
46 // os.Stat follows symlinks so we can detect symlinked skill dirs
47 if info, err := os.Stat(skillDir); err != nil || !info.IsDir() {
48 continue
49 }
50 skillMD := findSkillMD(skillDir)
51 if skillMD == "" {
52 continue
53 }
54
55 // Avoid duplicates
56 absPath, err := filepath.Abs(skillMD)
57 if err != nil {
58 continue
59 }
60 if seen[absPath] {
61 continue
62 }
63 seen[absPath] = true
64
65 skill, err := Parse(skillMD)
66 if err != nil {
67 continue // Skip invalid skills
68 }
69
70 // Validate name matches directory
71 if skill.Name != entry.Name() {
72 continue
73 }
74
75 skills = append(skills, skill)
76 }
77 }
78
79 return skills
80}
81
82// findSkillMD looks for SKILL.md or skill.md in a directory.
83func findSkillMD(dir string) string {
84 for _, name := range []string{"SKILL.md", "skill.md"} {
85 path := filepath.Join(dir, name)
86 if _, err := os.Stat(path); err == nil {
87 return path
88 }
89 }
90 return ""
91}
92
93// Parse reads and parses a SKILL.md file.
94func Parse(path string) (Skill, error) {
95 content, err := os.ReadFile(path)
96 if err != nil {
97 return Skill{}, err
98 }
99
100 frontmatter, err := parseFrontmatter(string(content))
101 if err != nil {
102 return Skill{}, err
103 }
104
105 name, _ := frontmatter["name"].(string)
106 description, _ := frontmatter["description"].(string)
107
108 if name == "" || description == "" {
109 return Skill{}, &ValidationError{Message: "name and description are required"}
110 }
111
112 if err := validateName(name); err != nil {
113 return Skill{}, err
114 }
115
116 if len(description) > MaxDescriptionLength {
117 return Skill{}, &ValidationError{Message: "description exceeds maximum length"}
118 }
119
120 skill := Skill{
121 Name: name,
122 Description: description,
123 Path: path,
124 }
125
126 if license, ok := frontmatter["license"].(string); ok {
127 skill.License = license
128 }
129
130 if compat, ok := frontmatter["compatibility"].(string); ok {
131 if len(compat) > MaxCompatibilityLength {
132 return Skill{}, &ValidationError{Message: "compatibility exceeds maximum length"}
133 }
134 skill.Compatibility = compat
135 }
136
137 if tools, ok := frontmatter["allowed-tools"].(string); ok {
138 skill.AllowedTools = tools
139 }
140
141 if metadata, ok := frontmatter["metadata"].(map[string]any); ok {
142 skill.Metadata = make(map[string]string)
143 for k, v := range metadata {
144 if s, ok := v.(string); ok {
145 skill.Metadata[k] = s
146 }
147 }
148 }
149
150 return skill, nil
151}
152
153// ValidationError represents a skill validation error.
154type ValidationError struct {
155 Message string
156}
157
158func (e *ValidationError) Error() string {
159 return e.Message
160}
161
162// validateName checks that a skill name follows the spec.
163func validateName(name string) error {
164 if len(name) == 0 || len(name) > MaxNameLength {
165 return &ValidationError{Message: "name must be 1-64 characters"}
166 }
167
168 if name != strings.ToLower(name) {
169 return &ValidationError{Message: "name must be lowercase"}
170 }
171
172 if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
173 return &ValidationError{Message: "name cannot start or end with hyphen"}
174 }
175
176 if strings.Contains(name, "--") {
177 return &ValidationError{Message: "name cannot contain consecutive hyphens"}
178 }
179
180 for _, r := range name {
181 if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' {
182 return &ValidationError{Message: "name can only contain letters, digits, and hyphens"}
183 }
184 }
185
186 return nil
187}
188
189// parseFrontmatter extracts YAML frontmatter from markdown content.
190// This is a simple parser that handles basic YAML without external dependencies.
191func parseFrontmatter(content string) (map[string]any, error) {
192 if !strings.HasPrefix(content, "---") {
193 return nil, &ValidationError{Message: "SKILL.md must start with YAML frontmatter (---)"}
194 }
195
196 parts := strings.SplitN(content, "---", 3)
197 if len(parts) < 3 {
198 return nil, &ValidationError{Message: "SKILL.md frontmatter not properly closed with ---"}
199 }
200
201 yamlContent := parts[1]
202 return parseSimpleYAML(yamlContent)
203}
204
205// parseSimpleYAML parses simple YAML frontmatter.
206// Supports: strings, and nested maps (for metadata).
207func parseSimpleYAML(content string) (map[string]any, error) {
208 result := make(map[string]any)
209 lines := strings.Split(content, "\n")
210
211 var currentKey string
212 var inNestedMap bool
213 nestedMap := make(map[string]any)
214
215 for _, line := range lines {
216 // Skip empty lines and comments
217 trimmed := strings.TrimSpace(line)
218 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
219 continue
220 }
221
222 // Check for nested map entries (indented with spaces)
223 if inNestedMap && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) {
224 parts := strings.SplitN(trimmed, ":", 2)
225 if len(parts) == 2 {
226 key := strings.TrimSpace(parts[0])
227 value := strings.TrimSpace(parts[1])
228 value = unquoteYAML(value)
229 nestedMap[key] = value
230 }
231 continue
232 }
233
234 // If we were in a nested map, save it
235 if inNestedMap && currentKey != "" {
236 result[currentKey] = nestedMap
237 nestedMap = make(map[string]any)
238 inNestedMap = false
239 }
240
241 // Parse top-level key: value
242 parts := strings.SplitN(trimmed, ":", 2)
243 if len(parts) != 2 {
244 continue
245 }
246
247 key := strings.TrimSpace(parts[0])
248 value := strings.TrimSpace(parts[1])
249
250 if value == "" {
251 // Could be start of a nested map
252 currentKey = key
253 inNestedMap = true
254 continue
255 }
256
257 value = unquoteYAML(value)
258 result[key] = value
259 }
260
261 // Handle final nested map
262 if inNestedMap && currentKey != "" && len(nestedMap) > 0 {
263 result[currentKey] = nestedMap
264 }
265
266 return result, nil
267}
268
269// unquoteYAML removes surrounding quotes from a YAML string value.
270func unquoteYAML(s string) string {
271 if len(s) >= 2 {
272 if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
273 return s[1 : len(s)-1]
274 }
275 }
276 return s
277}
278
279// ToPromptXML generates the <available_skills> XML block for system prompts.
280func ToPromptXML(skills []Skill) string {
281 if len(skills) == 0 {
282 return ""
283 }
284
285 var sb strings.Builder
286 sb.WriteString("<available_skills>\n")
287
288 for _, skill := range skills {
289 sb.WriteString("<skill>\n")
290 sb.WriteString("<name>")
291 sb.WriteString(html.EscapeString(skill.Name))
292 sb.WriteString("</name>\n")
293 sb.WriteString("<description>")
294 sb.WriteString(html.EscapeString(skill.Description))
295 sb.WriteString("</description>\n")
296 sb.WriteString("<location>")
297 sb.WriteString(html.EscapeString(skill.Path))
298 sb.WriteString("</location>\n")
299 sb.WriteString("</skill>\n")
300 }
301
302 sb.WriteString("</available_skills>")
303 return sb.String()
304}
305
306// DefaultDirs returns the default skill directories to search.
307// These are always returned if they exist, regardless of the current working directory.
308func DefaultDirs() []string {
309 var dirs []string
310
311 home, err := os.UserHomeDir()
312 if err != nil {
313 return dirs
314 }
315
316 // Search these directories for skills:
317 // 1. ~/.config/shelley/ (XDG convention for Shelley)
318 // 2. ~/.config/agents/skills (shared agents skills directory)
319 // 3. ~/.shelley/ (legacy location)
320 candidateDirs := []string{
321 filepath.Join(home, ".config", "shelley"),
322 filepath.Join(home, ".config", "agents", "skills"),
323 filepath.Join(home, ".shelley"),
324 }
325
326 for _, dir := range candidateDirs {
327 if info, err := os.Stat(dir); err == nil && info.IsDir() {
328 dirs = append(dirs, dir)
329 }
330 }
331
332 return dirs
333}
334
335// expandPath expands ~ to the user's home directory.
336func expandPath(path string) string {
337 if strings.HasPrefix(path, "~/") {
338 if home, err := os.UserHomeDir(); err == nil {
339 return filepath.Join(home, path[2:])
340 }
341 }
342 return path
343}
344
345// ProjectSkillsDirs returns all .skills directories found by walking up from
346// the working directory to the git root (or filesystem root if no git root).
347func ProjectSkillsDirs(workingDir, gitRoot string) []string {
348 var dirs []string
349 seen := make(map[string]bool)
350
351 // Determine the stopping point
352 stopAt := gitRoot
353 if stopAt == "" {
354 stopAt = "/"
355 }
356
357 // Walk up from working directory
358 current := workingDir
359 for current != "" {
360 skillsDir := filepath.Join(current, ".skills")
361 if !seen[skillsDir] {
362 if info, err := os.Stat(skillsDir); err == nil && info.IsDir() {
363 dirs = append(dirs, skillsDir)
364 seen[skillsDir] = true
365 }
366 }
367
368 // Stop if we've reached the git root or filesystem root
369 if current == stopAt || current == "/" {
370 break
371 }
372
373 parent := filepath.Dir(current)
374 if parent == current {
375 break
376 }
377 current = parent
378 }
379
380 return dirs
381}
382
383// DiscoverInTree finds all skills by walking the directory tree looking for SKILL.md files.
384// If gitRoot is provided, it searches from gitRoot. Otherwise, it searches from workingDir downward.
385func DiscoverInTree(workingDir, gitRoot string) []Skill {
386 var skills []Skill
387 seen := make(map[string]bool)
388
389 // Determine root to search from
390 searchRoot := gitRoot
391 if searchRoot == "" {
392 searchRoot = workingDir
393 }
394
395 filepath.Walk(searchRoot, func(path string, info os.FileInfo, err error) error {
396 if err != nil {
397 return nil // Continue on errors
398 }
399
400 if info.IsDir() {
401 // Skip hidden directories and common ignore patterns
402 name := info.Name()
403 if name != "." && (strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor") {
404 return filepath.SkipDir
405 }
406 return nil
407 }
408
409 // Check if this is a SKILL.md file
410 lowerName := strings.ToLower(info.Name())
411 if lowerName != "skill.md" {
412 return nil
413 }
414
415 // Avoid duplicates
416 absPath, err := filepath.Abs(path)
417 if err != nil {
418 return nil
419 }
420 if seen[absPath] {
421 return nil
422 }
423 seen[absPath] = true
424
425 skill, err := Parse(path)
426 if err != nil {
427 return nil // Skip invalid skills
428 }
429
430 // Validate name matches parent directory
431 parentDir := filepath.Base(filepath.Dir(path))
432 if skill.Name != parentDir {
433 return nil
434 }
435
436 skills = append(skills, skill)
437 return nil
438 })
439
440 return skills
441}