1// Package skills implements the Agent Skills open standard.
2// See https://agentskills.io for the specification.
3package skills
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "log/slog"
10 "os"
11 "path/filepath"
12 "regexp"
13 "slices"
14 "sort"
15 "strings"
16 "sync"
17
18 "github.com/charlievieth/fastwalk"
19 "github.com/charmbracelet/crush/internal/pubsub"
20 "gopkg.in/yaml.v3"
21)
22
23const (
24 SkillFileName = "SKILL.md"
25 MaxNameLength = 64
26 MaxDescriptionLength = 1024
27 MaxCompatibilityLength = 500
28)
29
30var (
31 namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
32 promptReplacer = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
33)
34
35// Skill represents a parsed SKILL.md file.
36type Skill struct {
37 Name string `yaml:"name" json:"name"`
38 Description string `yaml:"description" json:"description"`
39 License string `yaml:"license,omitempty" json:"license,omitempty"`
40 Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
41 Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
42 Instructions string `yaml:"-" json:"instructions"`
43 Path string `yaml:"-" json:"path"`
44 SkillFilePath string `yaml:"-" json:"skill_file_path"`
45 Builtin bool `yaml:"-" json:"builtin"`
46}
47
48// DiscoveryState represents the outcome of discovering a single skill file.
49type DiscoveryState int
50
51const (
52 // StateNormal indicates the skill was parsed and validated successfully.
53 StateNormal DiscoveryState = iota
54 // StateError indicates discovery encountered a scan/parse/validate error.
55 StateError
56)
57
58// SkillState represents the latest discovery status of a skill file.
59type SkillState struct {
60 Name string
61 Path string
62 State DiscoveryState
63 Err error
64}
65
66// Event is published when skill discovery completes.
67type Event struct {
68 States []*SkillState
69}
70
71var broker = pubsub.NewBroker[Event]()
72
73// SubscribeEvents returns a channel that receives events when skill discovery state changes.
74func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
75 return broker.Subscribe(ctx)
76}
77
78// Validate checks if the skill meets spec requirements.
79func (s *Skill) Validate() error {
80 var errs []error
81
82 if s.Name == "" {
83 errs = append(errs, errors.New("name is required"))
84 } else {
85 if len(s.Name) > MaxNameLength {
86 errs = append(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
87 }
88 if !namePattern.MatchString(s.Name) {
89 errs = append(errs, errors.New("name must be alphanumeric with hyphens, no leading/trailing/consecutive hyphens"))
90 }
91 if s.Path != "" && !strings.EqualFold(filepath.Base(s.Path), s.Name) {
92 errs = append(errs, fmt.Errorf("name %q must match directory %q", s.Name, filepath.Base(s.Path)))
93 }
94 }
95
96 if s.Description == "" {
97 errs = append(errs, errors.New("description is required"))
98 } else if len(s.Description) > MaxDescriptionLength {
99 errs = append(errs, fmt.Errorf("description exceeds %d characters", MaxDescriptionLength))
100 }
101
102 if len(s.Compatibility) > MaxCompatibilityLength {
103 errs = append(errs, fmt.Errorf("compatibility exceeds %d characters", MaxCompatibilityLength))
104 }
105
106 return errors.Join(errs...)
107}
108
109// Parse parses a SKILL.md file from disk.
110func Parse(path string) (*Skill, error) {
111 content, err := os.ReadFile(path)
112 if err != nil {
113 return nil, err
114 }
115
116 skill, err := ParseContent(content)
117 if err != nil {
118 return nil, err
119 }
120
121 skill.Path = filepath.Dir(path)
122 skill.SkillFilePath = path
123
124 return skill, nil
125}
126
127// ParseContent parses a SKILL.md from raw bytes.
128func ParseContent(content []byte) (*Skill, error) {
129 frontmatter, body, err := splitFrontmatter(string(content))
130 if err != nil {
131 return nil, err
132 }
133
134 var skill Skill
135 if err := yaml.Unmarshal([]byte(frontmatter), &skill); err != nil {
136 return nil, fmt.Errorf("parsing frontmatter: %w", err)
137 }
138
139 skill.Instructions = strings.TrimSpace(body)
140
141 return &skill, nil
142}
143
144// splitFrontmatter extracts YAML frontmatter and body from markdown content.
145func splitFrontmatter(content string) (frontmatter, body string, err error) {
146 // Strip UTF-8 BOM for compatibility with editors that include it.
147 content = strings.TrimPrefix(content, "\uFEFF")
148 // Normalize line endings to \n for consistent parsing.
149 content = strings.ReplaceAll(content, "\r\n", "\n")
150 content = strings.ReplaceAll(content, "\r", "\n")
151
152 lines := strings.Split(content, "\n")
153 start := slices.IndexFunc(lines, func(line string) bool {
154 return strings.TrimSpace(line) != ""
155 })
156 if start == -1 || strings.TrimSpace(lines[start]) != "---" {
157 return "", "", errors.New("no YAML frontmatter found")
158 }
159
160 endOffset := slices.IndexFunc(lines[start+1:], func(line string) bool {
161 return strings.TrimSpace(line) == "---"
162 })
163 if endOffset == -1 {
164 return "", "", errors.New("unclosed frontmatter")
165 }
166 end := start + 1 + endOffset
167
168 frontmatter = strings.Join(lines[start+1:end], "\n")
169 body = strings.Join(lines[end+1:], "\n")
170 return frontmatter, body, nil
171}
172
173// Discover finds all valid skills in the given paths.
174func Discover(paths []string) []*Skill {
175 skills, _ := DiscoverWithStates(paths)
176 return skills
177}
178
179// DiscoverWithStates finds all valid skills in the given paths and also
180// returns a per-file state slice describing parse/validation outcomes. Useful
181// for diagnostics and UI reporting.
182func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
183 var skills []*Skill
184 var states []*SkillState
185 var mu sync.Mutex
186 seen := make(map[string]bool)
187 addState := func(name, path string, state DiscoveryState, err error) {
188 mu.Lock()
189 states = append(states, &SkillState{
190 Name: name,
191 Path: path,
192 State: state,
193 Err: err,
194 })
195 mu.Unlock()
196 }
197
198 for _, base := range paths {
199 // We use fastwalk with Follow: true instead of filepath.WalkDir because
200 // WalkDir doesn't follow symlinked directories at any depthโonly entry
201 // points. This ensures skills in symlinked subdirectories are discovered.
202 // fastwalk is concurrent, so we protect shared state (seen, skills) with mu.
203 conf := fastwalk.Config{
204 Follow: true,
205 ToSlash: fastwalk.DefaultToSlash(),
206 }
207 err := fastwalk.Walk(&conf, base, func(path string, d os.DirEntry, err error) error {
208 if err != nil {
209 slog.Warn("Failed to walk skills path entry", "base", base, "path", path, "error", err)
210 addState("", path, StateError, err)
211 return nil
212 }
213 if d.IsDir() || d.Name() != SkillFileName {
214 return nil
215 }
216 mu.Lock()
217 if seen[path] {
218 mu.Unlock()
219 return nil
220 }
221 seen[path] = true
222 mu.Unlock()
223 skill, err := Parse(path)
224 if err != nil {
225 slog.Warn("Failed to parse skill file", "path", path, "error", err)
226 addState("", path, StateError, err)
227 return nil
228 }
229 if err := skill.Validate(); err != nil {
230 slog.Warn("Skill validation failed", "path", path, "error", err)
231 addState(skill.Name, path, StateError, err)
232 return nil
233 }
234 slog.Debug("Successfully loaded skill", "name", skill.Name, "path", path)
235 mu.Lock()
236 skills = append(skills, skill)
237 mu.Unlock()
238 addState(skill.Name, path, StateNormal, nil)
239 return nil
240 })
241 if err != nil && !os.IsNotExist(err) {
242 slog.Warn("Failed to walk skills path", "path", base, "error", err)
243 }
244 }
245
246 // fastwalk traversal order is non-deterministic, so sort for stable output.
247 sort.SliceStable(skills, func(i, j int) bool {
248 left := strings.ToLower(skills[i].SkillFilePath)
249 right := strings.ToLower(skills[j].SkillFilePath)
250 if left == right {
251 return skills[i].SkillFilePath < skills[j].SkillFilePath
252 }
253 return left < right
254 })
255
256 broker.Publish(pubsub.UpdatedEvent, Event{States: states})
257 return skills, states
258}
259
260// ToPromptXML generates XML for injection into the system prompt.
261func ToPromptXML(skills []*Skill) string {
262 if len(skills) == 0 {
263 return ""
264 }
265
266 var sb strings.Builder
267 sb.WriteString("<available_skills>\n")
268 for _, s := range skills {
269 sb.WriteString(" <skill>\n")
270 fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
271 fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
272 fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
273 if s.Builtin {
274 sb.WriteString(" <type>builtin</type>\n")
275 }
276 sb.WriteString(" </skill>\n")
277 }
278 sb.WriteString("</available_skills>")
279 return sb.String()
280}
281
282func escape(s string) string {
283 return promptReplacer.Replace(s)
284}
285
286// Deduplicate removes duplicate skills by name. When duplicates exist, the
287// last occurrence wins. This means user skills (appended after builtins)
288// override builtin skills with the same name.
289func Deduplicate(all []*Skill) []*Skill {
290 seen := make(map[string]int, len(all))
291 for i, s := range all {
292 seen[s.Name] = i
293 }
294
295 result := make([]*Skill, 0, len(seen))
296 for i, s := range all {
297 if seen[s.Name] == i {
298 result = append(result, s)
299 }
300 }
301 return result
302}
303
304// ApproxTokenCount returns a rough estimate of how many tokens a string
305// occupies when sent to an LLM. Uses the common ~4-chars-per-token heuristic
306// that approximates GPT/Claude tokenizers well enough for diagnostic logging.
307func ApproxTokenCount(s string) int {
308 if s == "" {
309 return 0
310 }
311 return (len(s) + 3) / 4
312}
313
314// Filter removes skills whose names appear in the disabled list.
315func Filter(all []*Skill, disabled []string) []*Skill {
316 if len(disabled) == 0 {
317 return all
318 }
319
320 disabledSet := make(map[string]bool, len(disabled))
321 for _, name := range disabled {
322 disabledSet[name] = true
323 }
324
325 result := make([]*Skill, 0, len(all))
326 for _, s := range all {
327 if !disabledSet[s.Name] {
328 result = append(result, s)
329 }
330 }
331 return result
332}