Vectrum
Sign In
180 lines · 6.0 KB · Go
// 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"},
	} {
		if !filepath.IsAbs(r.dir) {
			// home or workroot was empty or relative, so this root would
			// resolve against the process's working directory instead of a
			// real personal or project root. An unknown home is not a user
			// error, so this is silently skipped rather than reported.
			continue
		}
		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
}