skills.go

  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}