Skip to content

Commit cf40a5c

Browse files
authored
Merge pull request #2571 from dgageot/board/simplifying-skills-package-without-break-aff91d91
refactor(skills): split package into focused files
2 parents e59e163 + 44868e4 commit cf40a5c

5 files changed

Lines changed: 405 additions & 383 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: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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
138+
// format). Symlinks are explicitly skipped here because flat-mode skills are
139+
// expected to live as plain directories under the search root.
140+
func loadSkillsFlat(dir string) []Skill {
141+
entries, err := os.ReadDir(dir)
142+
if err != nil {
143+
return nil
144+
}
145+
146+
var skills []Skill
147+
for _, entry := range entries {
148+
if !entry.IsDir() || isHidden(entry) || isSymlink(entry) {
149+
continue
150+
}
151+
path := filepath.Join(dir, entry.Name(), skillFile)
152+
if skill, ok := loadSkillFile(path, entry.Name()); ok {
153+
skills = append(skills, skill)
154+
}
155+
}
156+
return skills
157+
}
158+
159+
// loadSkillsRecursive walks dir for SKILL.md files (Codex format).
160+
//
161+
// filepath.WalkDir does not follow symlinks inside the tree (it uses
162+
// fs.DirEntry which is Lstat-based), so a symlink-to-directory has
163+
// IsDir() == false and is naturally not entered. The visited map is
164+
// defensive: it catches the (rare) cases where the same real directory
165+
// is reachable via two non-symlink paths — e.g. Linux bind mounts, or
166+
// when the search root itself is a symlink whose target also appears
167+
// as a sibling deeper in the tree.
168+
func loadSkillsRecursive(dir string) []Skill {
169+
visited := make(map[string]bool)
170+
if realDir, err := filepath.EvalSymlinks(dir); err == nil {
171+
visited[realDir] = true
172+
}
173+
174+
var skills []Skill
175+
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
176+
if err != nil {
177+
return nil
178+
}
179+
180+
if d.IsDir() {
181+
if path == dir {
182+
return nil
183+
}
184+
if isHidden(d) {
185+
return fs.SkipDir
186+
}
187+
// De-duplicate by real path so symlink cycles are skipped.
188+
realPath, err := filepath.EvalSymlinks(path)
189+
if err == nil {
190+
if visited[realPath] {
191+
return fs.SkipDir
192+
}
193+
visited[realPath] = true
194+
}
195+
return nil
196+
}
197+
198+
if d.Name() != skillFile {
199+
return nil
200+
}
201+
dirName := filepath.Base(filepath.Dir(path))
202+
if skill, ok := loadSkillFile(path, dirName); ok {
203+
skills = append(skills, skill)
204+
}
205+
return nil
206+
})
207+
return skills
208+
}
209+
210+
// loadSkillFile reads and parses a SKILL.md file. dirName is used as the
211+
// skill name when the frontmatter does not declare one.
212+
func loadSkillFile(path, dirName string) (Skill, bool) {
213+
content, err := os.ReadFile(path)
214+
if err != nil {
215+
return Skill{}, false
216+
}
217+
218+
skill, ok := parseFrontmatter(string(content))
219+
if !ok {
220+
return Skill{}, false
221+
}
222+
223+
skill.Name = cmp.Or(skill.Name, dirName)
224+
if skill.Name == "" || skill.Description == "" {
225+
return Skill{}, false
226+
}
227+
228+
skill.FilePath = path
229+
skill.BaseDir = filepath.Dir(path)
230+
skill.Local = true
231+
return skill, true
232+
}
233+
234+
func isHidden(entry fs.DirEntry) bool {
235+
name := entry.Name()
236+
return name != "" && name[0] == '.'
237+
}
238+
239+
func isSymlink(entry fs.DirEntry) bool {
240+
return entry.Type()&os.ModeSymlink != 0
241+
}

pkg/skills/remote.go

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import (
1010
"path/filepath"
1111
"regexp"
1212
"strings"
13-
14-
"github.com/docker/docker-agent/pkg/paths"
1513
)
1614

1715
// remoteIndex represents the index.json served at /.well-known/skills/index.json
@@ -25,19 +23,11 @@ type remoteSkillEntry struct {
2523
Files []string `json:"files"`
2624
}
2725

28-
func defaultCache() *diskCache {
29-
return newDiskCache(filepath.Join(paths.GetCacheDir(), "skills"))
30-
}
31-
32-
// loadRemoteSkills fetches skills from a remote URL per the well-known skills discovery spec.
33-
// It fetches /.well-known/skills/index.json, then prefetches all listed files
34-
// into a disk cache so the agent can read them without network requests during
35-
// task execution.
36-
func loadRemoteSkills(baseURL string) []Skill {
37-
return loadRemoteSkillsWithCache(context.Background(), baseURL, defaultCache())
38-
}
39-
40-
func loadRemoteSkillsWithCache(ctx context.Context, baseURL string, cache *diskCache) []Skill {
26+
// loadRemoteSkills fetches skills from a remote URL per the well-known skills
27+
// discovery spec. It fetches /.well-known/skills/index.json, then prefetches
28+
// all listed files into the given disk cache so the agent can read them
29+
// without network requests during task execution.
30+
func loadRemoteSkills(ctx context.Context, baseURL string, cache *diskCache) []Skill {
4131
baseURL = strings.TrimRight(baseURL, "/")
4232
indexURL := baseURL + "/.well-known/skills/index.json"
4333

0 commit comments

Comments
 (0)