summaryrefslogtreecommitdiffstats
path: root/discover.go
diff options
context:
space:
mode:
Diffstat (limited to 'discover.go')
-rw-r--r--discover.go125
1 files changed, 125 insertions, 0 deletions
diff --git a/discover.go b/discover.go
new file mode 100644
index 0000000..ee9c2e6
--- /dev/null
+++ b/discover.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+type Skill struct {
+ Name string
+ // Path is the skill's directory relative to the source root.
+ Path string
+ Description string
+}
+
+var validName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
+
+// discover finds every skill in root: a directory holding a SKILL.md. It does
+// not look for skills nested inside another skill.
+func discover(root string) ([]Skill, error) {
+ var skills []Skill
+ seen := map[string]string{}
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() {
+ return nil
+ }
+ if path != root && (strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") {
+ return filepath.SkipDir
+ }
+ data, err := os.ReadFile(filepath.Join(path, "SKILL.md"))
+ if err != nil {
+ return nil
+ }
+ rel, _ := filepath.Rel(root, path)
+ fm := frontmatter(data)
+ name := fm["name"]
+ if name == "" {
+ name = filepath.Base(path)
+ }
+ if !validName.MatchString(name) {
+ return fmt.Errorf("%s/SKILL.md: invalid skill name %q", rel, name)
+ }
+ if prev, ok := seen[name]; ok {
+ return fmt.Errorf("skill %q is defined twice, in %s and %s", name, prev, rel)
+ }
+ seen[name] = rel
+ skills = append(skills, Skill{Name: name, Path: filepath.ToSlash(rel), Description: fm["description"]})
+ return filepath.SkipDir
+ })
+ sort.Slice(skills, func(i, j int) bool { return skills[i].Name < skills[j].Name })
+ return skills, err
+}
+
+// frontmatter reads the top-level scalar keys of a SKILL.md's YAML front
+// matter. That is all skills need from it, so it skips a YAML dependency.
+func frontmatter(data []byte) map[string]string {
+ fm := map[string]string{}
+ sc := bufio.NewScanner(bytes.NewReader(data))
+ if !sc.Scan() || strings.TrimSpace(sc.Text()) != "---" {
+ return fm
+ }
+ for sc.Scan() {
+ line := sc.Text()
+ if strings.TrimSpace(line) == "---" {
+ break
+ }
+ if line == "" || line[0] == ' ' || line[0] == '\t' {
+ continue
+ }
+ k, v, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ v = strings.TrimSpace(v)
+ if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
+ v = v[1 : len(v)-1]
+ }
+ fm[strings.TrimSpace(k)] = v
+ }
+ return fm
+}
+
+func selectSkills(found []Skill, only []string) ([]Skill, error) {
+ if len(only) == 0 {
+ return found, nil
+ }
+ byName := map[string]Skill{}
+ for _, s := range found {
+ byName[s.Name] = s
+ }
+ var out []Skill
+ for _, n := range only {
+ s, ok := byName[n]
+ if !ok {
+ return nil, fmt.Errorf("no skill named %q (found: %s)", n, skillNames(found))
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+func skillNames(skills []Skill) string {
+ names := make([]string, len(skills))
+ for i, s := range skills {
+ names[i] = s.Name
+ }
+ return strings.Join(names, ", ")
+}
+
+func skillPaths(skills []Skill) map[string]string {
+ m := make(map[string]string, len(skills))
+ for _, s := range skills {
+ m[s.Name] = s.Path
+ }
+ return m
+}