skills.go

  1// Package skills implements the Agent Skills open standard.
  2// See https://agentskills.io for the specification.
  3package skills
  4
  5import (
  6	"errors"
  7	"fmt"
  8	"log/slog"
  9	"os"
 10	"path/filepath"
 11	"regexp"
 12	"slices"
 13	"sort"
 14	"strings"
 15	"sync"
 16
 17	"github.com/charlievieth/fastwalk"
 18	"gopkg.in/yaml.v3"
 19)
 20
 21const (
 22	SkillFileName          = "SKILL.md"
 23	MaxNameLength          = 64
 24	MaxDescriptionLength   = 1024
 25	MaxCompatibilityLength = 500
 26)
 27
 28var (
 29	namePattern    = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
 30	promptReplacer = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
 31)
 32
 33// Skill represents a parsed SKILL.md file.
 34type Skill struct {
 35	Name          string            `yaml:"name" json:"name"`
 36	Description   string            `yaml:"description" json:"description"`
 37	License       string            `yaml:"license,omitempty" json:"license,omitempty"`
 38	Compatibility string            `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
 39	Metadata      map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
 40	Instructions  string            `yaml:"-" json:"instructions"`
 41	Path          string            `yaml:"-" json:"path"`
 42	SkillFilePath string            `yaml:"-" json:"skill_file_path"`
 43	Builtin       bool              `yaml:"-" json:"builtin"`
 44}
 45
 46// Validate checks if the skill meets spec requirements.
 47func (s *Skill) Validate() error {
 48	var errs []error
 49
 50	if s.Name == "" {
 51		errs = append(errs, errors.New("name is required"))
 52	} else {
 53		if len(s.Name) > MaxNameLength {
 54			errs = append(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
 55		}
 56		if !namePattern.MatchString(s.Name) {
 57			errs = append(errs, errors.New("name must be alphanumeric with hyphens, no leading/trailing/consecutive hyphens"))
 58		}
 59		if s.Path != "" && !strings.EqualFold(filepath.Base(s.Path), s.Name) {
 60			errs = append(errs, fmt.Errorf("name %q must match directory %q", s.Name, filepath.Base(s.Path)))
 61		}
 62	}
 63
 64	if s.Description == "" {
 65		errs = append(errs, errors.New("description is required"))
 66	} else if len(s.Description) > MaxDescriptionLength {
 67		errs = append(errs, fmt.Errorf("description exceeds %d characters", MaxDescriptionLength))
 68	}
 69
 70	if len(s.Compatibility) > MaxCompatibilityLength {
 71		errs = append(errs, fmt.Errorf("compatibility exceeds %d characters", MaxCompatibilityLength))
 72	}
 73
 74	return errors.Join(errs...)
 75}
 76
 77// Parse parses a SKILL.md file from disk.
 78func Parse(path string) (*Skill, error) {
 79	content, err := os.ReadFile(path)
 80	if err != nil {
 81		return nil, err
 82	}
 83
 84	skill, err := ParseContent(content)
 85	if err != nil {
 86		return nil, err
 87	}
 88
 89	skill.Path = filepath.Dir(path)
 90	skill.SkillFilePath = path
 91
 92	return skill, nil
 93}
 94
 95// ParseContent parses a SKILL.md from raw bytes.
 96func ParseContent(content []byte) (*Skill, error) {
 97	frontmatter, body, err := splitFrontmatter(string(content))
 98	if err != nil {
 99		return nil, err
100	}
101
102	var skill Skill
103	if err := yaml.Unmarshal([]byte(frontmatter), &skill); err != nil {
104		return nil, fmt.Errorf("parsing frontmatter: %w", err)
105	}
106
107	skill.Instructions = strings.TrimSpace(body)
108
109	return &skill, nil
110}
111
112// splitFrontmatter extracts YAML frontmatter and body from markdown content.
113func splitFrontmatter(content string) (frontmatter, body string, err error) {
114	// Strip UTF-8 BOM for compatibility with editors that include it.
115	content = strings.TrimPrefix(content, "\uFEFF")
116	// Normalize line endings to \n for consistent parsing.
117	content = strings.ReplaceAll(content, "\r\n", "\n")
118	content = strings.ReplaceAll(content, "\r", "\n")
119
120	lines := strings.Split(content, "\n")
121	start := slices.IndexFunc(lines, func(line string) bool {
122		return strings.TrimSpace(line) != ""
123	})
124	if start == -1 || strings.TrimSpace(lines[start]) != "---" {
125		return "", "", errors.New("no YAML frontmatter found")
126	}
127
128	endOffset := slices.IndexFunc(lines[start+1:], func(line string) bool {
129		return strings.TrimSpace(line) == "---"
130	})
131	if endOffset == -1 {
132		return "", "", errors.New("unclosed frontmatter")
133	}
134	end := start + 1 + endOffset
135
136	frontmatter = strings.Join(lines[start+1:end], "\n")
137	body = strings.Join(lines[end+1:], "\n")
138	return frontmatter, body, nil
139}
140
141// Discover finds all valid skills in the given paths.
142func Discover(paths []string) []*Skill {
143	var skills []*Skill
144	var mu sync.Mutex
145	seen := make(map[string]bool)
146
147	for _, base := range paths {
148		// We use fastwalk with Follow: true instead of filepath.WalkDir because
149		// WalkDir doesn't follow symlinked directories at any depthโ€”only entry
150		// points. This ensures skills in symlinked subdirectories are discovered.
151		// fastwalk is concurrent, so we protect shared state (seen, skills) with mu.
152		conf := fastwalk.Config{
153			Follow:  true,
154			ToSlash: fastwalk.DefaultToSlash(),
155		}
156		err := fastwalk.Walk(&conf, base, func(path string, d os.DirEntry, err error) error {
157			if err != nil {
158				slog.Warn("Failed to walk skills path entry", "base", base, "path", path, "error", err)
159				return nil
160			}
161			if d.IsDir() || d.Name() != SkillFileName {
162				return nil
163			}
164			mu.Lock()
165			if seen[path] {
166				mu.Unlock()
167				return nil
168			}
169			seen[path] = true
170			mu.Unlock()
171			skill, err := Parse(path)
172			if err != nil {
173				slog.Warn("Failed to parse skill file", "path", path, "error", err)
174				return nil
175			}
176			if err := skill.Validate(); err != nil {
177				slog.Warn("Skill validation failed", "path", path, "error", err)
178				return nil
179			}
180			slog.Debug("Successfully loaded skill", "name", skill.Name, "path", path)
181			mu.Lock()
182			skills = append(skills, skill)
183			mu.Unlock()
184			return nil
185		})
186		if err != nil {
187			slog.Warn("Failed to walk skills path", "path", base, "error", err)
188		}
189	}
190
191	// fastwalk traversal order is non-deterministic, so sort for stable output.
192	sort.SliceStable(skills, func(i, j int) bool {
193		left := strings.ToLower(skills[i].SkillFilePath)
194		right := strings.ToLower(skills[j].SkillFilePath)
195		if left == right {
196			return skills[i].SkillFilePath < skills[j].SkillFilePath
197		}
198		return left < right
199	})
200
201	return skills
202}
203
204// ToPromptXML generates XML for injection into the system prompt.
205func ToPromptXML(skills []*Skill) string {
206	if len(skills) == 0 {
207		return ""
208	}
209
210	var sb strings.Builder
211	sb.WriteString("<available_skills>\n")
212	for _, s := range skills {
213		sb.WriteString("  <skill>\n")
214		fmt.Fprintf(&sb, "    <name>%s</name>\n", escape(s.Name))
215		fmt.Fprintf(&sb, "    <description>%s</description>\n", escape(s.Description))
216		fmt.Fprintf(&sb, "    <location>%s</location>\n", escape(s.SkillFilePath))
217		if s.Builtin {
218			sb.WriteString("    <type>builtin</type>\n")
219		}
220		sb.WriteString("  </skill>\n")
221	}
222	sb.WriteString("</available_skills>")
223	return sb.String()
224}
225
226func escape(s string) string {
227	return promptReplacer.Replace(s)
228}
229
230// Deduplicate removes duplicate skills by name. When duplicates exist, the
231// last occurrence wins. This means user skills (appended after builtins)
232// override builtin skills with the same name.
233func Deduplicate(all []*Skill) []*Skill {
234	seen := make(map[string]int, len(all))
235	for i, s := range all {
236		seen[s.Name] = i
237	}
238
239	result := make([]*Skill, 0, len(seen))
240	for i, s := range all {
241		if seen[s.Name] == i {
242			result = append(result, s)
243		}
244	}
245	return result
246}
247
248// Filter removes skills whose names appear in the disabled list.
249func Filter(all []*Skill, disabled []string) []*Skill {
250	if len(disabled) == 0 {
251		return all
252	}
253
254	disabledSet := make(map[string]bool, len(disabled))
255	for _, name := range disabled {
256		disabledSet[name] = true
257	}
258
259	result := make([]*Skill, 0, len(all))
260	for _, s := range all {
261		if !disabledSet[s.Name] {
262			result = append(result, s)
263		}
264	}
265	return result
266}