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
14 "gopkg.in/yaml.v3"
15)
16
17const (
18 SkillFileName = "SKILL.md"
19 MaxNameLength = 64
20 MaxDescriptionLength = 1024
21 MaxCompatibilityLength = 500
22)
23
24var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
25
26// Skill represents a parsed SKILL.md file.
27type Skill struct {
28 Name string `yaml:"name" json:"name"`
29 Description string `yaml:"description" json:"description"`
30 License string `yaml:"license,omitempty" json:"license,omitempty"`
31 Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
32 Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
33 Instructions string `yaml:"-" json:"instructions"`
34 Path string `yaml:"-" json:"path"`
35 SkillFilePath string `yaml:"-" json:"skill_file_path"`
36}
37
38// Validate checks if the skill meets spec requirements.
39func (s *Skill) Validate() error {
40 var errs []error
41
42 if s.Name == "" {
43 errs = append(errs, errors.New("name is required"))
44 } else {
45 if len(s.Name) > MaxNameLength {
46 errs = append(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
47 }
48 if !namePattern.MatchString(s.Name) {
49 errs = append(errs, errors.New("name must be alphanumeric with hyphens, no leading/trailing/consecutive hyphens"))
50 }
51 if s.Path != "" && !strings.EqualFold(filepath.Base(s.Path), s.Name) {
52 errs = append(errs, fmt.Errorf("name %q must match directory %q", s.Name, filepath.Base(s.Path)))
53 }
54 }
55
56 if s.Description == "" {
57 errs = append(errs, errors.New("description is required"))
58 } else if len(s.Description) > MaxDescriptionLength {
59 errs = append(errs, fmt.Errorf("description exceeds %d characters", MaxDescriptionLength))
60 }
61
62 if len(s.Compatibility) > MaxCompatibilityLength {
63 errs = append(errs, fmt.Errorf("compatibility exceeds %d characters", MaxCompatibilityLength))
64 }
65
66 return errors.Join(errs...)
67}
68
69// Parse parses a SKILL.md file.
70func Parse(path string) (*Skill, error) {
71 content, err := os.ReadFile(path)
72 if err != nil {
73 return nil, err
74 }
75
76 frontmatter, body, err := splitFrontmatter(string(content))
77 if err != nil {
78 return nil, err
79 }
80
81 var skill Skill
82 if err := yaml.Unmarshal([]byte(frontmatter), &skill); err != nil {
83 return nil, fmt.Errorf("parsing frontmatter: %w", err)
84 }
85
86 skill.Instructions = strings.TrimSpace(body)
87 skill.Path = filepath.Dir(path)
88 skill.SkillFilePath = path
89
90 return &skill, nil
91}
92
93// splitFrontmatter extracts YAML frontmatter and body from markdown content.
94func splitFrontmatter(content string) (frontmatter, body string, err error) {
95 // Normalize line endings to \n for consistent parsing.
96 content = strings.ReplaceAll(content, "\r\n", "\n")
97 if !strings.HasPrefix(content, "---\n") {
98 return "", "", errors.New("no YAML frontmatter found")
99 }
100
101 rest := strings.TrimPrefix(content, "---\n")
102 before, after, ok := strings.Cut(rest, "\n---")
103 if !ok {
104 return "", "", errors.New("unclosed frontmatter")
105 }
106
107 return before, after, nil
108}
109
110// Discover finds all valid skills in the given paths.
111func Discover(paths []string) []*Skill {
112 var skills []*Skill
113 seen := make(map[string]bool)
114
115 for _, base := range paths {
116 filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
117 if err != nil {
118 return nil
119 }
120 if d.IsDir() || d.Name() != SkillFileName || seen[path] {
121 return nil
122 }
123 seen[path] = true
124 skill, err := Parse(path)
125 if err != nil {
126 slog.Warn("Failed to parse skill file", "path", path, "error", err)
127 return nil
128 }
129 if err := skill.Validate(); err != nil {
130 slog.Warn("Skill validation failed", "path", path, "error", err)
131 return nil
132 }
133 slog.Info("Successfully loaded skill", "name", skill.Name, "path", path)
134 skills = append(skills, skill)
135 return nil
136 })
137 }
138
139 return skills
140}
141
142// ToPromptXML generates XML for injection into the system prompt.
143func ToPromptXML(skills []*Skill) string {
144 if len(skills) == 0 {
145 return ""
146 }
147
148 var sb strings.Builder
149 sb.WriteString("<available_skills>\n")
150 for _, s := range skills {
151 sb.WriteString(" <skill>\n")
152 fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
153 fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
154 fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
155 sb.WriteString(" </skill>\n")
156 }
157 sb.WriteString("</available_skills>")
158 return sb.String()
159}
160
161func escape(s string) string {
162 r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'")
163 return r.Replace(s)
164}