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("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
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}