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}
 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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
182	return r.Replace(s)
183}