// Package skills discovers the markdown procedures in ./skills. // // A skill is a plain markdown file with a small front matter: // // --- // name: go-rename // description: rename a Go symbol everywhere it is used // --- // # Rename a Go symbol // ... // // The point of the package is to turn that directory into ONE STRING: the // description of the `read_skill` tool. The model never lists the directory // itself — it reads the catalogue in the tool description, the same way it // reads the description of `bash`. package skills import ( "os" "path/filepath" "sort" "strings" ) // Skill is one markdown procedure on disk. type Skill struct { Name string // from the front matter, falling back to the file name Description string // the one-liner shown in the catalogue Path string } // skillFile is the file name of a skill stored in its own directory — the // Agent Skills convention (`//SKILL.md`), the layout the // shipped examples use. const skillFile = "SKILL.md" // defaultName is the name a skill gets when its front matter has none: the // file name for a flat `.md`, the directory name for `/SKILL.md`. func defaultName(path string) string { if filepath.Base(path) == skillFile { return filepath.Base(filepath.Dir(path)) } return strings.TrimSuffix(filepath.Base(path), ".md") } // parseHeader reads the `name:` and `description:` lines of the front matter. // It stops at the closing `---`: a `description:` in the body is not metadata. func parseHeader(content, path string) Skill { s := Skill{Name: defaultName(path), Path: path} lines := strings.Split(content, "\n") if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { return s } for _, line := range lines[1:] { if strings.TrimSpace(line) == "---" { break } key, value, found := strings.Cut(line, ":") if !found { continue } value = strings.TrimSpace(value) switch strings.TrimSpace(key) { case "name": if value != "" { s.Name = value } case "description": s.Description = value } } return s } // List returns the skills of dir, sorted by name. Two layouts are accepted, // and may be mixed: a flat `/.md`, and one directory per skill, // `//SKILL.md`. A missing directory is not an error: it just means // this agent has no skills. func List(dir string) []Skill { flat, err := filepath.Glob(filepath.Join(dir, "*.md")) if err != nil { return nil } nested, err := filepath.Glob(filepath.Join(dir, "*", skillFile)) if err != nil { return nil } var out []Skill for _, p := range append(flat, nested...) { content, err := os.ReadFile(p) if err != nil { continue } out = append(out, parseHeader(string(content), p)) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // Read returns the full markdown of one skill, whichever layout it uses: the // flat `.md` is tried first, then `/SKILL.md`. func Read(dir, name string) (string, error) { // Keep the name a plain file name: no "../" escaping out of the directory. name = filepath.Base(name) content, err := os.ReadFile(filepath.Join(dir, name+".md")) if err != nil { if nested, nestedErr := os.ReadFile(filepath.Join(dir, name, skillFile)); nestedErr == nil { return string(nested), nil } } return string(content), err } // Catalogue renders the list as the tool description the model will read. // This IS the prompt engineering: what the model knows about the available // skills is exactly these lines. func Catalogue(list []Skill) string { var b strings.Builder b.WriteString("Load a skill: the step-by-step procedure to follow for this kind of task. " + "Call it BEFORE doing the work, with the name of the matching skill, and then follow " + "what it says. Available skills:\n") for _, s := range list { b.WriteString(" " + s.Name) if s.Description != "" { b.WriteString(" — " + s.Description) } b.WriteString("\n") } return b.String() } // Names lists the skill names, for an error message that helps the model // recover from a typo. func Names(list []Skill) []string { out := make([]string, 0, len(list)) for _, s := range list { out = append(out, s.Name) } return out }