aboutsummaryrefslogtreecommitdiffstats
path: root/discover.go
blob: ee9c2e6eb9eafcb3c0b10dbc7601bc87bba788c3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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
}