skills.go

  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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
 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}