// Package skills discovers instruction sets stored as Markdown on disk.
//
// A skill is a directory containing SKILL.md: YAML frontmatter naming and
// describing it, followed by the instructions. The description is all the
// model sees until the skill is invoked, so it carries the whole triggering
// decision; the body can be any length.
package skills
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// Dirname is the directory, under the home directory and under the working
// root, that skills are read from.
const Dirname = ".agents/skills"
// filename is the one file a skill directory must contain.
const filename = "SKILL.md"
// slugPattern is what a directory name must match to be a skill. The slug
// becomes a slash command and a JSON enum value, and neither wants spaces.
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// Skill is one discovered instruction set.
type Skill struct {
Name string // slug, taken from the directory name
Description string // the one line the model sees before invoking
Body string // SKILL.md with the frontmatter removed
Dir string // absolute path to the skill directory
Source string // "personal" or "project"
}
// frontmatter is the subset of SKILL.md's YAML header this harness reads.
// Unknown keys are ignored rather than rejected, so a skill written for
// another harness still loads here.
type frontmatter struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
}
// Discover scans the personal and project roots, project shadowing personal on
// a slug collision, and returns the skills sorted by name.
//
// Every failure is a problem rather than an error: a harness that will not open
// because of a stray file in the home directory is worse than one that runs
// without the skill it was going to load. A root that does not exist is not
// even a problem — neither exists on a fresh machine, and that is the normal
// case.
func Discover(home, workroot string) ([]Skill, []error) {
byName := map[string]Skill{}
var problems []error
// Later roots win, so the project root is scanned second.
for _, r := range []struct{ dir, source string }{
{filepath.Join(home, Dirname), "personal"},
{filepath.Join(workroot, Dirname), "project"},
} {
found, probs := scan(r.dir, r.source)
problems = append(problems, probs...)
for _, s := range found {
byName[s.Name] = s
}
}
out := make([]Skill, 0, len(byName))
for _, s := range byName {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, problems
}
// scan reads one root, one level deep.
func scan(root, source string) ([]Skill, []error) {
entries, err := os.ReadDir(root)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, []error{fmt.Errorf("%s: %w", root, err)}
}
var found []Skill
var problems []error
for _, e := range entries {
dir := filepath.Join(root, e.Name())
path := filepath.Join(dir, filename)
// os.Stat follows symlinks, so this both decides "is this a skill
// directory?" and tolerates a symlinked-in skill directory (e.g. from
// a dotfiles repo) in the same check. A non-directory entry, or a
// symlink to a non-directory, fails this stat (ENOTDIR) and is
// skipped exactly like a directory with no SKILL.md: not a skill,
// and not a complaint either, since the entry may be anything at all.
if _, err := os.Stat(path); err != nil {
continue
}
s, err := load(dir, e.Name(), source)
if err != nil {
problems = append(problems, err)
continue
}
found = append(found, s)
}
return found, problems
}
// load parses one SKILL.md. slug is the directory name, which is the authority
// for the skill's name.
func load(dir, slug, source string) (Skill, error) {
path := filepath.Join(dir, filename)
if !slugPattern.MatchString(slug) {
return Skill{}, fmt.Errorf("%s: directory name %q is not a valid skill name; use lower-case letters, digits and hyphens", path, slug)
}
raw, err := os.ReadFile(path)
if err != nil {
return Skill{}, fmt.Errorf("%s: %w", path, err)
}
head, body, err := split(string(raw))
if err != nil {
return Skill{}, fmt.Errorf("%s: %w", path, err)
}
var fm frontmatter
if err := yaml.Unmarshal([]byte(head), &fm); err != nil {
return Skill{}, fmt.Errorf("%s: frontmatter is not valid YAML: %w", path, err)
}
switch {
case strings.TrimSpace(fm.Description) == "":
return Skill{}, fmt.Errorf("%s: frontmatter has no description; it is the only thing the model sees before invoking the skill", path)
case strings.TrimSpace(fm.Name) == "":
return Skill{}, fmt.Errorf("%s: frontmatter has no name; it must be %q, matching the directory", path, slug)
case strings.TrimSpace(fm.Name) != slug:
return Skill{}, fmt.Errorf("%s: frontmatter name is %q but the directory is %q; they must agree, and the directory wins", path, fm.Name, slug)
}
abs, err := filepath.Abs(dir)
if err != nil {
abs = dir
}
return Skill{
Name: slug,
Description: strings.TrimSpace(fm.Description),
Body: strings.TrimSpace(body),
Dir: abs,
Source: source,
}, nil
}
// split separates the leading --- fenced YAML block from the body.
func split(s string) (head, body string, err error) {
s = strings.ReplaceAll(s, "\r\n", "\n")
if !strings.HasPrefix(s, "---\n") {
return "", "", fmt.Errorf("does not begin with a --- frontmatter block")
}
rest := s[len("---\n"):]
end := strings.Index(rest, "\n---")
if end < 0 {
return "", "", fmt.Errorf("frontmatter block is never closed with ---")
}
head = rest[:end]
body = rest[end+len("\n---"):]
if i := strings.IndexByte(body, '\n'); i >= 0 {
body = body[i+1:]
} else {
body = ""
}
return head, body, nil
}