Vectrum
Sign In

skills: discover SKILL.md under ~/.agents/skills and .agents/skills

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Jjonicommitted 1 parent 30bb8e2commita947840

Showing 4 changed files with 448 additions and 3 deletions.

+30−3go.modView File
@@ -2,13 +2,40 @@ module harness
22
33 go 1.26.8
44
5- require github.com/invopop/jsonschema v0.14.0
5+ require (
6+ github.com/bmatcuk/doublestar/v4 v4.10.0
7+ github.com/charmbracelet/bubbles v1.0.0
8+ github.com/charmbracelet/bubbletea v1.3.10
9+ github.com/charmbracelet/lipgloss v1.1.0
10+ github.com/charmbracelet/x/ansi v0.11.6
11+ github.com/invopop/jsonschema v0.14.0
12+ github.com/muesli/termenv v0.16.0
13+ gopkg.in/yaml.v3 v3.0.1
14+ mvdan.cc/sh/v3 v3.14.1
15+ )
616
717 require (
18+ github.com/atotto/clipboard v0.1.4 // indirect
19+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
820 github.com/bahlo/generic-list-go v0.2.0 // indirect
9- github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
1021 github.com/buger/jsonparser v1.1.2 // indirect
22+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
23+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
24+ github.com/charmbracelet/x/term v0.2.2 // indirect
25+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
26+ github.com/clipperhouse/stringish v0.1.1 // indirect
27+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
28+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
29+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
30+ github.com/mattn/go-isatty v0.0.20 // indirect
31+ github.com/mattn/go-localereader v0.0.1 // indirect
32+ github.com/mattn/go-runewidth v0.0.19 // indirect
33+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
34+ github.com/muesli/cancelreader v0.2.2 // indirect
1135 github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
36+ github.com/rivo/uniseg v0.4.7 // indirect
37+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
1238 go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
13- mvdan.cc/sh/v3 v3.14.1 // indirect
39+ golang.org/x/sys v0.47.0 // indirect
40+ golang.org/x/text v0.3.8 // indirect
1441 )
+68−0go.sumView File
@@ -1,21 +1,89 @@
1+ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
2+ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
3+ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
4+ github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
5+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
6+ github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
7+ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
8+ github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
19 github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
210 github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
311 github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
412 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
513 github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
614 github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
15+ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
16+ github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
17+ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
18+ github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
19+ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
20+ github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
21+ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
22+ github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
23+ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
24+ github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
25+ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
26+ github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
27+ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
28+ github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
29+ github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
30+ github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
31+ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
32+ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
33+ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
34+ github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
735 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
836 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
37+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
38+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
39+ github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
40+ github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
41+ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
42+ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
943 github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
1044 github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
45+ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
46+ github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
47+ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
48+ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
49+ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
50+ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
51+ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
52+ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
53+ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
54+ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
55+ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
56+ github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
57+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
58+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
59+ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
60+ github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
61+ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
62+ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
1163 github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
1264 github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
1365 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1466 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
67+ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
68+ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
69+ github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
70+ github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
1571 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
1672 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
73+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
74+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
1775 go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
1876 go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
77+ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
78+ golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
79+ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
80+ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
81+ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
82+ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
83+ golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
84+ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
85+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
86+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1987 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
2088 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
2189 mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
+172−0internal/skills/skills.goaddedView File
@@ -0,0 +1,172 @@
1+ // Package skills discovers instruction sets stored as Markdown on disk.
2+ //
3+ // A skill is a directory containing SKILL.md: YAML frontmatter naming and
4+ // describing it, followed by the instructions. The description is all the
5+ // model sees until the skill is invoked, so it carries the whole triggering
6+ // decision; the body can be any length.
7+ package skills
8+
9+ import (
10+ "fmt"
11+ "os"
12+ "path/filepath"
13+ "regexp"
14+ "sort"
15+ "strings"
16+
17+ "gopkg.in/yaml.v3"
18+ )
19+
20+ // Dirname is the directory, under the home directory and under the working
21+ // root, that skills are read from.
22+ const Dirname = ".agents/skills"
23+
24+ // filename is the one file a skill directory must contain.
25+ const filename = "SKILL.md"
26+
27+ // slugPattern is what a directory name must match to be a skill. The slug
28+ // becomes a slash command and a JSON enum value, and neither wants spaces.
29+ var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
30+
31+ // Skill is one discovered instruction set.
32+ type Skill struct {
33+ Name string // slug, taken from the directory name
34+ Description string // the one line the model sees before invoking
35+ Body string // SKILL.md with the frontmatter removed
36+ Dir string // absolute path to the skill directory
37+ Source string // "personal" or "project"
38+ }
39+
40+ // frontmatter is the subset of SKILL.md's YAML header this harness reads.
41+ // Unknown keys are ignored rather than rejected, so a skill written for
42+ // another harness still loads here.
43+ type frontmatter struct {
44+ Name string `yaml:"name"`
45+ Description string `yaml:"description"`
46+ }
47+
48+ // Discover scans the personal and project roots, project shadowing personal on
49+ // a slug collision, and returns the skills sorted by name.
50+ //
51+ // Every failure is a problem rather than an error: a harness that will not open
52+ // because of a stray file in the home directory is worse than one that runs
53+ // without the skill it was going to load. A root that does not exist is not
54+ // even a problem — neither exists on a fresh machine, and that is the normal
55+ // case.
56+ func Discover(home, workroot string) ([]Skill, []error) {
57+ byName := map[string]Skill{}
58+ var problems []error
59+
60+ // Later roots win, so the project root is scanned second.
61+ for _, r := range []struct{ dir, source string }{
62+ {filepath.Join(home, Dirname), "personal"},
63+ {filepath.Join(workroot, Dirname), "project"},
64+ } {
65+ found, probs := scan(r.dir, r.source)
66+ problems = append(problems, probs...)
67+ for _, s := range found {
68+ byName[s.Name] = s
69+ }
70+ }
71+
72+ out := make([]Skill, 0, len(byName))
73+ for _, s := range byName {
74+ out = append(out, s)
75+ }
76+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
77+ return out, problems
78+ }
79+
80+ // scan reads one root, one level deep.
81+ func scan(root, source string) ([]Skill, []error) {
82+ entries, err := os.ReadDir(root)
83+ if os.IsNotExist(err) {
84+ return nil, nil
85+ }
86+ if err != nil {
87+ return nil, []error{fmt.Errorf("%s: %w", root, err)}
88+ }
89+
90+ var found []Skill
91+ var problems []error
92+ for _, e := range entries {
93+ if !e.IsDir() {
94+ continue
95+ }
96+ dir := filepath.Join(root, e.Name())
97+ path := filepath.Join(dir, filename)
98+ if _, err := os.Stat(path); err != nil {
99+ // A directory without a SKILL.md is not a skill, and not a
100+ // complaint either: it may be anything at all.
101+ continue
102+ }
103+ s, err := load(dir, e.Name(), source)
104+ if err != nil {
105+ problems = append(problems, err)
106+ continue
107+ }
108+ found = append(found, s)
109+ }
110+ return found, problems
111+ }
112+
113+ // load parses one SKILL.md. slug is the directory name, which is the authority
114+ // for the skill's name.
115+ func load(dir, slug, source string) (Skill, error) {
116+ path := filepath.Join(dir, filename)
117+ if !slugPattern.MatchString(slug) {
118+ return Skill{}, fmt.Errorf("%s: directory name %q is not a valid skill name; use lower-case letters, digits and hyphens", path, slug)
119+ }
120+ raw, err := os.ReadFile(path)
121+ if err != nil {
122+ return Skill{}, fmt.Errorf("%s: %w", path, err)
123+ }
124+ head, body, err := split(string(raw))
125+ if err != nil {
126+ return Skill{}, fmt.Errorf("%s: %w", path, err)
127+ }
128+ var fm frontmatter
129+ if err := yaml.Unmarshal([]byte(head), &fm); err != nil {
130+ return Skill{}, fmt.Errorf("%s: frontmatter is not valid YAML: %w", path, err)
131+ }
132+ switch {
133+ case strings.TrimSpace(fm.Description) == "":
134+ return Skill{}, fmt.Errorf("%s: frontmatter has no description; it is the only thing the model sees before invoking the skill", path)
135+ case strings.TrimSpace(fm.Name) == "":
136+ return Skill{}, fmt.Errorf("%s: frontmatter has no name; it must be %q, matching the directory", path, slug)
137+ case strings.TrimSpace(fm.Name) != slug:
138+ 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)
139+ }
140+ abs, err := filepath.Abs(dir)
141+ if err != nil {
142+ abs = dir
143+ }
144+ return Skill{
145+ Name: slug,
146+ Description: strings.TrimSpace(fm.Description),
147+ Body: strings.TrimSpace(body),
148+ Dir: abs,
149+ Source: source,
150+ }, nil
151+ }
152+
153+ // split separates the leading --- fenced YAML block from the body.
154+ func split(s string) (head, body string, err error) {
155+ s = strings.ReplaceAll(s, "\r\n", "\n")
156+ if !strings.HasPrefix(s, "---\n") {
157+ return "", "", fmt.Errorf("does not begin with a --- frontmatter block")
158+ }
159+ rest := s[len("---\n"):]
160+ end := strings.Index(rest, "\n---")
161+ if end < 0 {
162+ return "", "", fmt.Errorf("frontmatter block is never closed with ---")
163+ }
164+ head = rest[:end]
165+ body = rest[end+len("\n---"):]
166+ if i := strings.IndexByte(body, '\n'); i >= 0 {
167+ body = body[i+1:]
168+ } else {
169+ body = ""
170+ }
171+ return head, body, nil
172+ }
+178−0internal/skills/skills_test.goaddedView File
@@ -0,0 +1,178 @@
1+ package skills
2+
3+ import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ )
9+
10+ // write creates <root>/<slug>/SKILL.md with the given contents.
11+ func write(t *testing.T, root, slug, body string) string {
12+ t.Helper()
13+ dir := filepath.Join(root, slug)
14+ if err := os.MkdirAll(dir, 0o755); err != nil {
15+ t.Fatal(err)
16+ }
17+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
18+ t.Fatal(err)
19+ }
20+ return dir
21+ }
22+
23+ const wellFormed = `---
24+ name: commit-helper
25+ description: Use when writing commit messages
26+ ---
27+
28+ Read the last twenty commits for tone.
29+ `
30+
31+ func TestDiscoverWellFormed(t *testing.T) {
32+ home, work := t.TempDir(), t.TempDir()
33+ dir := write(t, filepath.Join(home, ".agents", "skills"), "commit-helper", wellFormed)
34+
35+ found, problems := Discover(home, work)
36+ if len(problems) != 0 {
37+ t.Fatalf("problems: %v", problems)
38+ }
39+ if len(found) != 1 {
40+ t.Fatalf("got %d skills, want 1", len(found))
41+ }
42+ s := found[0]
43+ if s.Name != "commit-helper" {
44+ t.Errorf("Name = %q", s.Name)
45+ }
46+ if s.Description != "Use when writing commit messages" {
47+ t.Errorf("Description = %q", s.Description)
48+ }
49+ if s.Source != "personal" {
50+ t.Errorf("Source = %q, want personal", s.Source)
51+ }
52+ if s.Dir != dir {
53+ t.Errorf("Dir = %q, want %q", s.Dir, dir)
54+ }
55+ if !strings.Contains(s.Body, "last twenty commits") {
56+ t.Errorf("Body = %q", s.Body)
57+ }
58+ if strings.Contains(s.Body, "description:") {
59+ t.Error("Body still contains the frontmatter")
60+ }
61+ }
62+
63+ func TestProjectShadowsPersonal(t *testing.T) {
64+ home, work := t.TempDir(), t.TempDir()
65+ write(t, filepath.Join(home, ".agents", "skills"), "dup", `---
66+ name: dup
67+ description: personal one
68+ ---
69+ personal body
70+ `)
71+ write(t, filepath.Join(work, ".agents", "skills"), "dup", `---
72+ name: dup
73+ description: project one
74+ ---
75+ project body
76+ `)
77+
78+ found, problems := Discover(home, work)
79+ if len(problems) != 0 {
80+ t.Fatalf("problems: %v", problems)
81+ }
82+ if len(found) != 1 {
83+ t.Fatalf("got %d skills, want 1", len(found))
84+ }
85+ if found[0].Description != "project one" || found[0].Source != "project" {
86+ t.Errorf("personal skill was not shadowed: %+v", found[0])
87+ }
88+ }
89+
90+ func TestMalformedSkillsAreProblemsAndDoNotBlockOthers(t *testing.T) {
91+ home, work := t.TempDir(), t.TempDir()
92+ root := filepath.Join(home, ".agents", "skills")
93+ write(t, root, "good", wellFormed2())
94+ write(t, root, "no-frontmatter", "just a body, no frontmatter\n")
95+ write(t, root, "no-description", "---\nname: no-description\n---\nbody\n")
96+ write(t, root, "wrong-name", "---\nname: something-else\ndescription: d\n---\nbody\n")
97+ write(t, root, "Bad_Slug", "---\nname: Bad_Slug\ndescription: d\n---\nbody\n")
98+
99+ found, problems := Discover(home, work)
100+ if len(found) != 1 || found[0].Name != "good" {
101+ t.Fatalf("got %+v, want only the good skill", found)
102+ }
103+ if len(problems) != 4 {
104+ t.Fatalf("got %d problems, want 4: %v", len(problems), problems)
105+ }
106+ // Every problem names the offending path, or it is not actionable.
107+ for _, p := range problems {
108+ if !strings.Contains(p.Error(), root) {
109+ t.Errorf("problem does not name a path: %v", p)
110+ }
111+ }
112+ }
113+
114+ func wellFormed2() string {
115+ return "---\nname: good\ndescription: a good skill\n---\nbody\n"
116+ }
117+
118+ func TestDescriptionScalarStyles(t *testing.T) {
119+ // A folded and a quoted description must parse to the same text as a plain
120+ // one. This is the whole reason for the yaml dependency.
121+ cases := map[string]string{
122+ "plain": "description: Use when writing commit messages",
123+ "quoted": `description: "Use when writing commit messages"`,
124+ "folded": "description: >-\n Use when writing\n commit messages",
125+ }
126+ for slug, line := range cases {
127+ t.Run(slug, func(t *testing.T) {
128+ home, work := t.TempDir(), t.TempDir()
129+ write(t, filepath.Join(home, ".agents", "skills"), slug,
130+ "---\nname: "+slug+"\n"+line+"\n---\nbody\n")
131+
132+ found, problems := Discover(home, work)
133+ if len(problems) != 0 {
134+ t.Fatalf("problems: %v", problems)
135+ }
136+ if len(found) != 1 || found[0].Description != "Use when writing commit messages" {
137+ t.Fatalf("Description = %+v", found)
138+ }
139+ })
140+ }
141+ }
142+
143+ func TestNoRootsIsSilent(t *testing.T) {
144+ found, problems := Discover(t.TempDir(), t.TempDir())
145+ if len(found) != 0 || len(problems) != 0 {
146+ t.Fatalf("found %v, problems %v; want both empty", found, problems)
147+ }
148+ }
149+
150+ func TestNestedSkillIsNotDiscovered(t *testing.T) {
151+ home, work := t.TempDir(), t.TempDir()
152+ write(t, filepath.Join(home, ".agents", "skills", "outer"), "inner", wellFormed2())
153+
154+ found, problems := Discover(home, work)
155+ if len(found) != 0 {
156+ t.Fatalf("found %+v, want none", found)
157+ }
158+ // "outer" has no SKILL.md of its own; that is not a problem, just not a skill.
159+ if len(problems) != 0 {
160+ t.Fatalf("problems: %v", problems)
161+ }
162+ }
163+
164+ func TestSortedByName(t *testing.T) {
165+ home, work := t.TempDir(), t.TempDir()
166+ root := filepath.Join(home, ".agents", "skills")
167+ for _, slug := range []string{"zebra", "alpha", "middle"} {
168+ write(t, root, slug, "---\nname: "+slug+"\ndescription: d\n---\nbody\n")
169+ }
170+ found, _ := Discover(home, work)
171+ var got []string
172+ for _, s := range found {
173+ got = append(got, s.Name)
174+ }
175+ if strings.Join(got, ",") != "alpha,middle,zebra" {
176+ t.Errorf("order = %v", got)
177+ }
178+ }