diff options
Diffstat (limited to 'discover.go')
| -rw-r--r-- | discover.go | 125 |
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 +} |
