Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing 4 changed files with 448 additions and 3 deletions.
| @@ -2,13 +2,40 @@ module harness | ||
| 2 | 2 | |
| 3 | 3 | go 1.26.8 |
| 4 | 4 | |
| 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 | + ) | |
| 6 | 16 | |
| 7 | 17 | require ( |
| 18 | + github.com/atotto/clipboard v0.1.4 // indirect | |
| 19 | + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 8 | 20 | github.com/bahlo/generic-list-go v0.2.0 // indirect |
| 9 | - github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect | |
| 10 | 21 | 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 | |
| 11 | 35 | 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 | |
| 12 | 38 | 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 | |
| 14 | 41 | ) |
| @@ -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= | |
| 1 | 9 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= |
| 2 | 10 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= |
| 3 | 11 | github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= |
| 4 | 12 | github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= |
| 5 | 13 | github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= |
| 6 | 14 | 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= | |
| 7 | 35 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
| 8 | 36 | 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= | |
| 9 | 43 | github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= |
| 10 | 44 | 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= | |
| 11 | 63 | github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= |
| 12 | 64 | github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= |
| 13 | 65 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| 14 | 66 | 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= | |
| 15 | 71 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= |
| 16 | 72 | 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= | |
| 17 | 75 | go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= |
| 18 | 76 | 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= | |
| 19 | 87 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| 20 | 88 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| 21 | 89 | mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI= |
| @@ -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 | + } | |
| @@ -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 | + } | |