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}
39
40// Validate checks if the skill meets spec requirements.
41func (s *Skill) Validate() error {
42 var errs []error
43
44 if s.Name == "" {
45 errs = append(errs, errors.New("name is required"))
46 } else {
47 if len(s.Name) > MaxNameLength {
48 errs = append(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
49 }
50 if !namePattern.MatchString(s.Name) {
51 errs = append(errs, errors.New("name must be alphanumeric with hyphens, no leading/trailing/consecutive hyphens"))
52 }
53 if s.Path != "" && !strings.EqualFold(filepath.Base(s.Path), s.Name) {
54 errs = append(errs, fmt.Errorf("name %q must match directory %q", s.Name, filepath.Base(s.Path)))
55 }
56 }
57
58 if s.Description == "" {
59 errs = append(errs, errors.New("description is required"))
60 } else if len(s.Description) > MaxDescriptionLength {
61 errs = append(errs, fmt.Errorf("description exceeds %d characters", MaxDescriptionLength))
62 }
63
64 if len(s.Compatibility) > MaxCompatibilityLength {
65 errs = append(errs, fmt.Errorf("compatibility exceeds %d characters", MaxCompatibilityLength))
66 }
67
68 return errors.Join(errs...)
69}
70
71// Parse parses a SKILL.md file.
72func Parse(path string) (*Skill, error) {
73 content, err := os.ReadFile(path)
74 if err != nil {
75 return nil, err
76 }
77
78 frontmatter, body, err := splitFrontmatter(string(content))
79 if err != nil {
80 return nil, err
81 }
82
83 var skill Skill
84 if err := yaml.Unmarshal([]byte(frontmatter), &skill); err != nil {
85 return nil, fmt.Errorf("parsing frontmatter: %w", err)
86 }
87
88 skill.Instructions = strings.TrimSpace(body)
89 skill.Path = filepath.Dir(path)
90 skill.SkillFilePath = path
91
92 return &skill, nil
93}
94
95// splitFrontmatter extracts YAML frontmatter and body from markdown content.
96func splitFrontmatter(content string) (frontmatter, body string, err error) {
97 // Normalize line endings to \n for consistent parsing.
98 content = strings.ReplaceAll(content, "\r\n", "\n")
99 if !strings.HasPrefix(content, "---\n") {
100 return "", "", errors.New("no YAML frontmatter found")
101 }
102
103 rest := strings.TrimPrefix(content, "---\n")
104 before, after, ok := strings.Cut(rest, "\n---")
105 if !ok {
106 return "", "", errors.New("unclosed frontmatter")
107 }
108
109 return before, after, nil
110}
111
112// Discover finds all valid skills in the given paths.
113func Discover(paths []string) []*Skill {
114 var skills []*Skill
115 var mu sync.Mutex
116 seen := make(map[string]bool)
117
118 for _, base := range paths {
119 // We use fastwalk with Follow: true instead of filepath.WalkDir because
120 // WalkDir doesn't follow symlinked directories at any depthโonly entry
121 // points. This ensures skills in symlinked subdirectories are discovered.
122 // fastwalk is concurrent, so we protect shared state (seen, skills) with mu.
123 conf := fastwalk.Config{
124 Follow: true,
125 ToSlash: fastwalk.DefaultToSlash(),
126 }
127 fastwalk.Walk(&conf, base, func(path string, d os.DirEntry, err error) error {
128 if err != nil {
129 return nil
130 }
131 if d.IsDir() || d.Name() != SkillFileName {
132 return nil
133 }
134 mu.Lock()
135 if seen[path] {
136 mu.Unlock()
137 return nil
138 }
139 seen[path] = true
140 mu.Unlock()
141 skill, err := Parse(path)
142 if err != nil {
143 slog.Warn("Failed to parse skill file", "path", path, "error", err)
144 return nil
145 }
146 if err := skill.Validate(); err != nil {
147 slog.Warn("Skill validation failed", "path", path, "error", err)
148 return nil
149 }
150 slog.Info("Successfully loaded skill", "name", skill.Name, "path", path)
151 mu.Lock()
152 skills = append(skills, skill)
153 mu.Unlock()
154 return nil
155 })
156 }
157
158 return skills
159}
160
161// ToPromptXML generates XML for injection into the system prompt.
162func ToPromptXML(skills []*Skill) string {
163 if len(skills) == 0 {
164 return ""
165 }
166
167 var sb strings.Builder
168 sb.WriteString("<available_skills>\n")
169 for _, s := range skills {
170 sb.WriteString(" <skill>\n")
171 fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
172 fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
173 fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
174 sb.WriteString(" </skill>\n")
175 }
176 sb.WriteString("</available_skills>")
177 return sb.String()
178}
179
180func escape(s string) string {
181 r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
182 return r.Replace(s)
183}