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