Skip to content

Commit 94f3b37

Browse files
committed
refactor(skills): split package into focused files
skills.go (402 -> 82 lines) now only holds the public API: the Skill type and the Load entry point. The implementation is split into focused files: - local.go: filesystem skill discovery, with a declarative localSearchPaths table that makes the precedence rules (codex, claude, agents, project) easy to read at a glance. findGitRoot+projectSearchDirs share a single walk-up. - frontmatter.go: YAML-ish frontmatter parsing, split into parseTopLevelLine and parseIndentedLine for clarity. Other small simplifications: - Load now sorts its result by name for deterministic output. - Load avoids the double map -> slice -> map round-trip via loadLocalSkillsInto. - Skill.IsFork uses a value receiver for consistency with the rest of the API. - isHTTPSource extracted from Load. No public API or behavior change; all existing tests pass. Assisted-By: docker-agent
1 parent a2eeb69 commit 94f3b37

3 files changed

Lines changed: 368 additions & 334 deletions

File tree

pkg/skills/frontmatter.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package skills
2+
3+
import "strings"
4+
5+
// parseFrontmatter extracts and parses the YAML-like frontmatter from a
6+
// markdown file. Instead of using a full YAML parser (which rejects unquoted
7+
// colons in values), we do simple line-by-line key: value splitting on the
8+
// first ": ". This is more robust for the simple frontmatter format used by
9+
// skill files.
10+
func parseFrontmatter(content string) (Skill, bool) {
11+
content = strings.ReplaceAll(content, "\r\n", "\n")
12+
content = strings.ReplaceAll(content, "\r", "\n")
13+
14+
if !strings.HasPrefix(content, "---") {
15+
return Skill{}, false
16+
}
17+
18+
endIndex := strings.Index(content[3:], "\n---")
19+
if endIndex == -1 {
20+
return Skill{}, false
21+
}
22+
23+
block := content[4 : endIndex+3]
24+
25+
var skill Skill
26+
var currentKey string // tracks multi-line keys like "metadata" or "allowed-tools"
27+
28+
for line := range strings.SplitSeq(block, "\n") {
29+
// Indented lines belong to the current multi-line key.
30+
if line != "" && (line[0] == ' ' || line[0] == '\t') {
31+
parseIndentedLine(&skill, currentKey, strings.TrimSpace(line))
32+
continue
33+
}
34+
35+
currentKey = ""
36+
key, value, ok := splitKeyValue(line)
37+
if !ok {
38+
continue
39+
}
40+
41+
if multi := parseTopLevelLine(&skill, key, value); multi {
42+
currentKey = key
43+
}
44+
}
45+
46+
return skill, true
47+
}
48+
49+
// parseTopLevelLine assigns a frontmatter key/value to the right Skill field.
50+
// It returns true if the key opens a multi-line block whose subsequent
51+
// indented lines must be collected by parseIndentedLine.
52+
func parseTopLevelLine(skill *Skill, key, value string) bool {
53+
switch key {
54+
case "name":
55+
skill.Name = unquote(value)
56+
case "description":
57+
skill.Description = unquote(value)
58+
case "license":
59+
skill.License = unquote(value)
60+
case "compatibility":
61+
skill.Compatibility = unquote(value)
62+
case "context":
63+
skill.Context = unquote(value)
64+
case "model":
65+
skill.Model = unquote(value)
66+
case "metadata":
67+
// metadata is always multi-line.
68+
return true
69+
case "allowed-tools":
70+
if value == "" {
71+
// Block form: subsequent indented "- item" lines.
72+
return true
73+
}
74+
// Inline comma-separated list.
75+
for item := range strings.SplitSeq(value, ",") {
76+
if t := unquote(strings.TrimSpace(item)); t != "" {
77+
skill.AllowedTools = append(skill.AllowedTools, t)
78+
}
79+
}
80+
}
81+
return false
82+
}
83+
84+
// parseIndentedLine accumulates an indented child line into the multi-line
85+
// block identified by parentKey.
86+
func parseIndentedLine(skill *Skill, parentKey, line string) {
87+
switch parentKey {
88+
case "metadata":
89+
if k, v, ok := splitKeyValue(line); ok {
90+
if skill.Metadata == nil {
91+
skill.Metadata = make(map[string]string)
92+
}
93+
skill.Metadata[k] = unquote(v)
94+
}
95+
case "allowed-tools":
96+
if item, ok := strings.CutPrefix(line, "- "); ok {
97+
skill.AllowedTools = append(skill.AllowedTools, unquote(strings.TrimSpace(item)))
98+
}
99+
}
100+
}
101+
102+
// splitKeyValue splits a line on the first ": " into key and value.
103+
// It also handles bare "key:" (no value) for keys that introduce a block.
104+
func splitKeyValue(line string) (string, string, bool) {
105+
if key, value, ok := strings.Cut(line, ": "); ok {
106+
return key, value, true
107+
}
108+
if strings.HasSuffix(line, ":") {
109+
return line[:len(line)-1], "", true
110+
}
111+
return "", "", false
112+
}
113+
114+
// unquote strips matching surrounding single or double quotes.
115+
func unquote(s string) string {
116+
if len(s) >= 2 {
117+
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
118+
return s[1 : len(s)-1]
119+
}
120+
}
121+
return s
122+
}

pkg/skills/local.go

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
package skills
2+
3+
import (
4+
"cmp"
5+
"io/fs"
6+
"os"
7+
"path/filepath"
8+
"slices"
9+
10+
"github.com/docker/docker-agent/pkg/paths"
11+
)
12+
13+
const skillFile = "SKILL.md"
14+
15+
// localSearchPath describes one directory to scan for local skills, and
16+
// whether subdirectories should be walked recursively (Codex/agents format)
17+
// or only as immediate children of the search root (Claude format).
18+
type localSearchPath struct {
19+
dir string
20+
recursive bool
21+
}
22+
23+
// localSearchPaths returns every directory to scan for local skills, in
24+
// load order. Entries appearing later in the list override skills with the
25+
// same name from earlier entries:
26+
//
27+
// 1. Global directories (under $HOME).
28+
// 2. .claude/skills under cwd (Claude project format, flat).
29+
// 3. .agents/skills from the git root down to cwd (closest wins).
30+
func localSearchPaths() []localSearchPath {
31+
var searchPaths []localSearchPath
32+
33+
if home := paths.GetHomeDir(); home != "" {
34+
searchPaths = append(searchPaths,
35+
localSearchPath{filepath.Join(home, ".codex", "skills"), true},
36+
localSearchPath{filepath.Join(home, ".claude", "skills"), false},
37+
localSearchPath{filepath.Join(home, ".agents", "skills"), true},
38+
)
39+
}
40+
41+
cwd, err := os.Getwd()
42+
if err != nil {
43+
return searchPaths
44+
}
45+
46+
searchPaths = append(searchPaths, localSearchPath{filepath.Join(cwd, ".claude", "skills"), false})
47+
for _, dir := range projectSearchDirs(cwd) {
48+
searchPaths = append(searchPaths, localSearchPath{filepath.Join(dir, ".agents", "skills"), false})
49+
}
50+
return searchPaths
51+
}
52+
53+
// loadLocalSkillsInto populates skillMap with every local skill, with later
54+
// search paths overriding earlier ones.
55+
func loadLocalSkillsInto(skillMap map[string]Skill) {
56+
for _, p := range localSearchPaths() {
57+
for _, skill := range loadSkillsFromDir(p.dir, p.recursive) {
58+
skillMap[skill.Name] = skill
59+
}
60+
}
61+
}
62+
63+
// projectSearchDirs returns directories from the enclosing git root down to
64+
// cwd (inclusive), ordered from root to cwd. If cwd is not inside a git
65+
// repository, it returns just cwd.
66+
//
67+
// The ordering matters: later (deeper) entries override skills from parents,
68+
// so a project subdirectory can shadow a skill defined at the repo root.
69+
func projectSearchDirs(cwd string) []string {
70+
abs, err := filepath.Abs(cwd)
71+
if err != nil {
72+
return []string{cwd}
73+
}
74+
75+
// Walk up from cwd, recording each dir, until we either hit a git
76+
// marker or run out of parents. This collects findGitRoot's result
77+
// and the dir list in a single traversal.
78+
var dirs []string
79+
var gitRoot string
80+
for current := abs; ; {
81+
dirs = append(dirs, current)
82+
if hasGitMarker(current) {
83+
gitRoot = current
84+
break
85+
}
86+
parent := filepath.Dir(current)
87+
if parent == current {
88+
break
89+
}
90+
current = parent
91+
}
92+
93+
if gitRoot == "" {
94+
return []string{abs}
95+
}
96+
97+
slices.Reverse(dirs)
98+
return dirs
99+
}
100+
101+
// hasGitMarker reports whether dir contains a .git directory or .git file
102+
// (the latter is used by worktrees and submodules).
103+
func hasGitMarker(dir string) bool {
104+
info, err := os.Stat(filepath.Join(dir, ".git"))
105+
if err != nil {
106+
return false
107+
}
108+
return info.IsDir() || info.Mode().IsRegular()
109+
}
110+
111+
// findGitRoot finds the enclosing git repository root, or returns "" if dir
112+
// is not inside a git repository. Kept as a thin wrapper for test coverage
113+
// and callers that don't need the full directory list.
114+
func findGitRoot(dir string) string {
115+
for current := dir; ; {
116+
if hasGitMarker(current) {
117+
return current
118+
}
119+
parent := filepath.Dir(current)
120+
if parent == current {
121+
return ""
122+
}
123+
current = parent
124+
}
125+
}
126+
127+
// loadSkillsFromDir loads skills from a directory.
128+
// If recursive is true, it walks all subdirectories looking for SKILL.md.
129+
// If recursive is false, it only looks at immediate subdirectories.
130+
func loadSkillsFromDir(dir string, recursive bool) []Skill {
131+
if recursive {
132+
return loadSkillsRecursive(dir)
133+
}
134+
return loadSkillsFlat(dir)
135+
}
136+
137+
// loadSkillsFlat loads skills from immediate subdirectories only (Claude format).
138+
func loadSkillsFlat(dir string) []Skill {
139+
entries, err := os.ReadDir(dir)
140+
if err != nil {
141+
return nil
142+
}
143+
144+
var skills []Skill
145+
for _, entry := range entries {
146+
if !entry.IsDir() || isHidden(entry) || isSymlink(entry) {
147+
continue
148+
}
149+
path := filepath.Join(dir, entry.Name(), skillFile)
150+
if skill, ok := loadSkillFile(path, entry.Name()); ok {
151+
skills = append(skills, skill)
152+
}
153+
}
154+
return skills
155+
}
156+
157+
// loadSkillsRecursive walks dir for SKILL.md files (Codex format), tracking
158+
// real directory paths so symlink cycles can't loop forever.
159+
func loadSkillsRecursive(dir string) []Skill {
160+
visited := make(map[string]bool)
161+
if realDir, err := filepath.EvalSymlinks(dir); err == nil {
162+
visited[realDir] = true
163+
}
164+
165+
var skills []Skill
166+
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
167+
if err != nil {
168+
return nil
169+
}
170+
171+
if d.IsDir() {
172+
if path == dir {
173+
return nil
174+
}
175+
if isHidden(d) {
176+
return fs.SkipDir
177+
}
178+
// De-duplicate by real path so symlink cycles are skipped.
179+
realPath, err := filepath.EvalSymlinks(path)
180+
if err == nil {
181+
if visited[realPath] {
182+
return fs.SkipDir
183+
}
184+
visited[realPath] = true
185+
}
186+
return nil
187+
}
188+
189+
if d.Name() != skillFile {
190+
return nil
191+
}
192+
dirName := filepath.Base(filepath.Dir(path))
193+
if skill, ok := loadSkillFile(path, dirName); ok {
194+
skills = append(skills, skill)
195+
}
196+
return nil
197+
})
198+
return skills
199+
}
200+
201+
// loadSkillFile reads and parses a SKILL.md file. dirName is used as the
202+
// skill name when the frontmatter does not declare one.
203+
func loadSkillFile(path, dirName string) (Skill, bool) {
204+
content, err := os.ReadFile(path)
205+
if err != nil {
206+
return Skill{}, false
207+
}
208+
209+
skill, ok := parseFrontmatter(string(content))
210+
if !ok {
211+
return Skill{}, false
212+
}
213+
214+
skill.Name = cmp.Or(skill.Name, dirName)
215+
if skill.Name == "" || skill.Description == "" {
216+
return Skill{}, false
217+
}
218+
219+
skill.FilePath = path
220+
skill.BaseDir = filepath.Dir(path)
221+
skill.Local = true
222+
return skill, true
223+
}
224+
225+
func isHidden(entry fs.DirEntry) bool {
226+
name := entry.Name()
227+
return name != "" && name[0] == '.'
228+
}
229+
230+
func isSymlink(entry fs.DirEntry) bool {
231+
return entry.Type()&os.ModeSymlink != 0
232+
}

0 commit comments

Comments
 (0)