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