| 💾 Saved. d722711 k33g 7h ago | 1 | // Package skills discovers the markdown procedures in ./skills. |
| 2 | // |
| 3 | // A skill is a plain markdown file with a small front matter: |
| 4 | // |
| 5 | // --- |
| 6 | // name: go-rename |
| 7 | // description: rename a Go symbol everywhere it is used |
| 8 | // --- |
| 9 | // # Rename a Go symbol |
| 10 | // ... |
| 11 | // |
| 12 | // The point of the package is to turn that directory into ONE STRING: the |
| 13 | // description of the `read_skill` tool. The model never lists the directory |
| 14 | // itself — it reads the catalogue in the tool description, the same way it |
| 15 | // reads the description of `bash`. |
| 16 | package skills |
| 17 | |
| 18 | import ( |
| 19 | "os" |
| 20 | "path/filepath" |
| 21 | "sort" |
| 22 | "strings" |
| 23 | ) |
| 24 | |
| 25 | // Skill is one markdown procedure on disk. |
| 26 | type Skill struct { |
| 27 | Name string // from the front matter, falling back to the file name |
| 28 | Description string // the one-liner shown in the catalogue |
| 29 | Path string |
| 30 | } |
| 31 | |
| 32 | // skillFile is the file name of a skill stored in its own directory — the |
| 33 | // Agent Skills convention (`<skillsDir>/<name>/SKILL.md`), the layout the |
| 34 | // shipped examples use. |
| 35 | const skillFile = "SKILL.md" |
| 36 | |
| 37 | // defaultName is the name a skill gets when its front matter has none: the |
| 38 | // file name for a flat `<name>.md`, the directory name for `<name>/SKILL.md`. |
| 39 | func defaultName(path string) string { |
| 40 | if filepath.Base(path) == skillFile { |
| 41 | return filepath.Base(filepath.Dir(path)) |
| 42 | } |
| 43 | return strings.TrimSuffix(filepath.Base(path), ".md") |
| 44 | } |
| 45 | |
| 46 | // parseHeader reads the `name:` and `description:` lines of the front matter. |
| 47 | // It stops at the closing `---`: a `description:` in the body is not metadata. |
| 48 | func parseHeader(content, path string) Skill { |
| 49 | s := Skill{Name: defaultName(path), Path: path} |
| 50 | lines := strings.Split(content, "\n") |
| 51 | if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { |
| 52 | return s |
| 53 | } |
| 54 | for _, line := range lines[1:] { |
| 55 | if strings.TrimSpace(line) == "---" { |
| 56 | break |
| 57 | } |
| 58 | key, value, found := strings.Cut(line, ":") |
| 59 | if !found { |
| 60 | continue |
| 61 | } |
| 62 | value = strings.TrimSpace(value) |
| 63 | switch strings.TrimSpace(key) { |
| 64 | case "name": |
| 65 | if value != "" { |
| 66 | s.Name = value |
| 67 | } |
| 68 | case "description": |
| 69 | s.Description = value |
| 70 | } |
| 71 | } |
| 72 | return s |
| 73 | } |
| 74 | |
| 75 | // List returns the skills of dir, sorted by name. Two layouts are accepted, |
| 76 | // and may be mixed: a flat `<dir>/<name>.md`, and one directory per skill, |
| 77 | // `<dir>/<name>/SKILL.md`. A missing directory is not an error: it just means |
| 78 | // this agent has no skills. |
| 79 | func List(dir string) []Skill { |
| 80 | flat, err := filepath.Glob(filepath.Join(dir, "*.md")) |
| 81 | if err != nil { |
| 82 | return nil |
| 83 | } |
| 84 | nested, err := filepath.Glob(filepath.Join(dir, "*", skillFile)) |
| 85 | if err != nil { |
| 86 | return nil |
| 87 | } |
| 88 | var out []Skill |
| 89 | for _, p := range append(flat, nested...) { |
| 90 | content, err := os.ReadFile(p) |
| 91 | if err != nil { |
| 92 | continue |
| 93 | } |
| 94 | out = append(out, parseHeader(string(content), p)) |
| 95 | } |
| 96 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) |
| 97 | return out |
| 98 | } |
| 99 | |
| 100 | // Read returns the full markdown of one skill, whichever layout it uses: the |
| 101 | // flat `<name>.md` is tried first, then `<name>/SKILL.md`. |
| 102 | func Read(dir, name string) (string, error) { |
| 103 | // Keep the name a plain file name: no "../" escaping out of the directory. |
| 104 | name = filepath.Base(name) |
| 105 | content, err := os.ReadFile(filepath.Join(dir, name+".md")) |
| 106 | if err != nil { |
| 107 | if nested, nestedErr := os.ReadFile(filepath.Join(dir, name, skillFile)); nestedErr == nil { |
| 108 | return string(nested), nil |
| 109 | } |
| 110 | } |
| 111 | return string(content), err |
| 112 | } |
| 113 | |
| 114 | // Catalogue renders the list as the tool description the model will read. |
| 115 | // This IS the prompt engineering: what the model knows about the available |
| 116 | // skills is exactly these lines. |
| 117 | func Catalogue(list []Skill) string { |
| 118 | var b strings.Builder |
| 119 | b.WriteString("Load a skill: the step-by-step procedure to follow for this kind of task. " + |
| 120 | "Call it BEFORE doing the work, with the name of the matching skill, and then follow " + |
| 121 | "what it says. Available skills:\n") |
| 122 | for _, s := range list { |
| 123 | b.WriteString(" " + s.Name) |
| 124 | if s.Description != "" { |
| 125 | b.WriteString(" — " + s.Description) |
| 126 | } |
| 127 | b.WriteString("\n") |
| 128 | } |
| 129 | return b.String() |
| 130 | } |
| 131 | |
| 132 | // Names lists the skill names, for an error message that helps the model |
| 133 | // recover from a typo. |
| 134 | func Names(list []Skill) []string { |
| 135 | out := make([]string, 0, len(list)) |
| 136 | for _, s := range list { |
| 137 | out = append(out, s.Name) |
| 138 | } |
| 139 | return out |
| 140 | } |