// 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 ( "errors" "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 Codey 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) raw, err := os.ReadFile(path) if err != nil { 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) } return Skill{}, fmt.Errorf("%s: %w", path, err) } skill, err := Parse(raw, slug) if err != nil { return Skill{}, fmt.Errorf("%s: %w", path, err) } abs, err := filepath.Abs(dir) if err != nil { abs = dir } skill.Dir, skill.Source = abs, source return skill, nil } // Parse reads the contents of a SKILL.md against the slug it claims to be. // Dir and Source are left to the caller: they are the only things that cannot // be known from the bytes. // // It is exported so that anything writing a skill file can check it against // the same rules that will judge it at startup. A skill fetched over the // network and written unread is one that fails at the next launch instead, // a long way from whatever actually went wrong. func Parse(content []byte, slug string) (Skill, error) { if !slugPattern.MatchString(slug) { return Skill{}, fmt.Errorf("%q is not a valid skill name; use lower-case letters, digits and hyphens", slug) } head, body, err := split(string(content)) if err != nil { return Skill{}, err } var fm frontmatter if err := yaml.Unmarshal([]byte(head), &fm); err != nil { return Skill{}, fmt.Errorf("frontmatter is not valid YAML: %w", err) } switch { case strings.TrimSpace(fm.Description) == "": return Skill{}, errors.New("frontmatter has no description; it is the only thing the model sees before invoking the skill") case strings.TrimSpace(fm.Name) == "": return Skill{}, fmt.Errorf("frontmatter has no name; it must be %q, matching the directory", slug) case strings.TrimSpace(fm.Name) != slug: return Skill{}, fmt.Errorf("frontmatter name is %q but the directory is %q; they must agree, and the directory wins", fm.Name, slug) } return Skill{ Name: slug, Description: strings.TrimSpace(fm.Description), Body: strings.TrimSpace(body), }, 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 }