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