fix(skills): discover nested user skill files
- commit
- ad7540ba7d54c66a265f64c94a45df9ae38f948d
- author
- brice · · 2026-08-05 13:43 UTC
- parent
- 4ed7779
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +204 -37
M README.md +2 -0
| @@ -985,6 +985,8 @@ By default zot loads built-in skills plus user-installed skills from: | |||
| 985 | 985 | - `./.claude/skills/<name>/SKILL.md`, `~/.claude/skills/<name>/SKILL.md` (Claude-compatible layout) | |
| 986 | 986 | - `./.agents/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` (agent-compatible layout) | |
| 987 | 987 | ||
| 988 | + | User skill roots are scanned recursively. A nested skill keeps its frontmatter `name` and can also be loaded by its slash-separated path relative to the skill root, such as `systems-backend/subskills/golang-patterns`. | |
| 989 | + | ||
| 988 | 990 | See [docs/skills.md](docs/skills.md) for the frontmatter fields, authoring tips, and example skills under `examples/skills/`. | |
| 989 | 991 | ||
| 990 | 992 | ## Telegram bot (bridge) | |
| @@ -985,6 +985,8 @@ By default zot loads built-in skills plus user-installed skills from: | |||||
| 985 | - `./.claude/skills/<name>/SKILL.md`, `~/.claude/skills/<name>/SKILL.md` (Claude-compatible layout) | 985 | - `./.claude/skills/<name>/SKILL.md`, `~/.claude/skills/<name>/SKILL.md` (Claude-compatible layout) | ||
| 986 | - `./.agents/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` (agent-compatible layout) | 986 | - `./.agents/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` (agent-compatible layout) | ||
| 987 | 987 | ||||
| 988 | + | User skill roots are scanned recursively. A nested skill keeps its frontmatter `name` and can also be loaded by its slash-separated path relative to the skill root, such as `systems-backend/subskills/golang-patterns`. | |||
| 989 | + | ||||
| 988 | See [docs/skills.md](docs/skills.md) for the frontmatter fields, authoring tips, and example skills under `examples/skills/`. | 990 | See [docs/skills.md](docs/skills.md) for the frontmatter fields, authoring tips, and example skills under `examples/skills/`. | ||
| 989 | 991 | ||||
| 990 | ## Telegram bot (bridge) | 992 | ## Telegram bot (bridge) | ||
M docs/skills.md +5 -1
| @@ -64,7 +64,11 @@ first `SKILL.md` it finds for each unique name: | |||
| 64 | 64 | The compat paths are deliberate: a `SKILL.md` written for an existing | |
| 65 | 65 | skill ecosystem works in zot unchanged. Drop your existing | |
| 66 | 66 | `.claude/skills/` or `.agents/skills/` directories into a project and | |
| 67 | - | zot will pick them up. | |
| 67 | + | zot will pick them up. User skill roots are scanned recursively, so | |
| 68 | + | nested files such as `~/.agents/skills/systems-backend/subskills/golang-patterns/SKILL.md` | |
| 69 | + | are discovered too. The frontmatter `name` is the canonical identifier; | |
| 70 | + | for nested user skills, the slash-separated directory path relative to the | |
| 71 | + | skill root is also accepted by the `skill` tool and `/skill:` commands. | |
| 68 | 72 | ||
| 69 | 73 | Enabled extensions may bundle skills by declaring relative directories in | |
| 70 | 74 | `extension.json`, for example `"skills": ["skills"]`, with files at | |
| @@ -64,7 +64,11 @@ first `SKILL.md` it finds for each unique name: | |||||
| 64 | The compat paths are deliberate: a `SKILL.md` written for an existing | 64 | The compat paths are deliberate: a `SKILL.md` written for an existing | ||
| 65 | skill ecosystem works in zot unchanged. Drop your existing | 65 | skill ecosystem works in zot unchanged. Drop your existing | ||
| 66 | `.claude/skills/` or `.agents/skills/` directories into a project and | 66 | `.claude/skills/` or `.agents/skills/` directories into a project and | ||
| 67 | - | zot will pick them up. | 67 | + | zot will pick them up. User skill roots are scanned recursively, so |
| 68 | + | nested files such as `~/.agents/skills/systems-backend/subskills/golang-patterns/SKILL.md` | |||
| 69 | + | are discovered too. The frontmatter `name` is the canonical identifier; | |||
| 70 | + | for nested user skills, the slash-separated directory path relative to the | |||
| 71 | + | skill root is also accepted by the `skill` tool and `/skill:` commands. | |||
| 68 | 72 | ||||
| 69 | Enabled extensions may bundle skills by declaring relative directories in | 73 | Enabled extensions may bundle skills by declaring relative directories in | ||
| 70 | `extension.json`, for example `"skills": ["skills"]`, with files at | 74 | `extension.json`, for example `"skills": ["skills"]`, with files at | ||
M packages/agent/skills/skills.go +120 -36
| @@ -20,11 +20,15 @@ | |||
| 20 | 20 | // ~/.agents/skills/<name>/SKILL.md — global (agent-compat) | |
| 21 | 21 | // | |
| 22 | 22 | // The compat paths are deliberate: a SKILL.md written for any of | |
| 23 | - | // the related ecosystems works in zot unchanged. | |
| 23 | + | // the related ecosystems works in zot unchanged. User skill roots are | |
| 24 | + | // walked recursively, and nested files can also be addressed by their | |
| 25 | + | // slash-separated path relative to the root (for example, | |
| 26 | + | // systems-backend/subskills/golang-patterns). | |
| 24 | 27 | package skills | |
| 25 | 28 | ||
| 26 | 29 | import ( | |
| 27 | 30 | "fmt" | |
| 31 | + | "io/fs" | |
| 28 | 32 | "os" | |
| 29 | 33 | "path/filepath" | |
| 30 | 34 | "sort" | |
| @@ -38,6 +42,11 @@ type Skill struct { | |||
| 38 | 42 | // field; falls back to the directory basename. | |
| 39 | 43 | Name string | |
| 40 | 44 | ||
| 45 | + | // Aliases are alternate identifiers accepted by FindByName. Nested | |
| 46 | + | // user skills include their slash-separated path relative to the | |
| 47 | + | // search root, so routers can refer to them by file-system path. | |
| 48 | + | Aliases []string | |
| 49 | + | ||
| 41 | 50 | // Description is the one-line summary shown to the model in the | |
| 42 | 51 | // system-prompt manifest. | |
| 43 | 52 | Description string | |
| @@ -130,15 +139,9 @@ func DiscoverWithBundled(zotHome, cwd, userHome string, includeUser bool, bundle | |||
| 130 | 139 | } | |
| 131 | 140 | // Built-ins fill in any name the user or an extension didn't provide. | |
| 132 | 141 | for _, s := range loadBuiltins() { | |
| 133 | - | if _, dup := seen[s.Name]; dup { | |
| 134 | - | continue | |
| 135 | - | } | |
| 136 | - | seen[s.Name] = s | |
| 137 | - | } | |
| 138 | - | out := make([]*Skill, 0, len(seen)) | |
| 139 | - | for _, s := range seen { | |
| 140 | - | out = append(out, s) | |
| 142 | + | registerSkill(seen, s) | |
| 141 | 143 | } | |
| 144 | + | out := uniqueSkills(seen) | |
| 142 | 145 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 143 | 146 | return out, errs | |
| 144 | 147 | } | |
| @@ -152,10 +155,7 @@ func LoadBundled(bundled []BundledSkillDir) ([]*Skill, []error) { | |||
| 152 | 155 | for _, dir := range bundled { | |
| 153 | 156 | errs = append(errs, scanBundledSkills(dir, seen)...) | |
| 154 | 157 | } | |
| 155 | - | out := make([]*Skill, 0, len(seen)) | |
| 156 | - | for _, s := range seen { | |
| 157 | - | out = append(out, s) | |
| 158 | - | } | |
| 158 | + | out := uniqueSkills(seen) | |
| 159 | 159 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 160 | 160 | return out, errs | |
| 161 | 161 | } | |
| @@ -219,9 +219,7 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error { | |||
| 219 | 219 | if s.Name == "" { | |
| 220 | 220 | s.Name = fallbackName | |
| 221 | 221 | } | |
| 222 | - | if _, dup := seen[s.Name]; !dup { | |
| 223 | - | seen[s.Name] = s | |
| 224 | - | } | |
| 222 | + | registerSkill(seen, s) | |
| 225 | 223 | } | |
| 226 | 224 | skillPath := filepath.Join(dir.Dir, "SKILL.md") | |
| 227 | 225 | if info, err := os.Stat(skillPath); err == nil { | |
| @@ -248,40 +246,116 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error { | |||
| 248 | 246 | return errs | |
| 249 | 247 | } | |
| 250 | 248 | ||
| 251 | - | // scanUserSkills walks the user-skill search dirs and populates | |
| 252 | - | // `seen` with first-match-wins per name. Split out so Discover's | |
| 253 | - | // includeUser=false path doesn't have to skip over a giant block. | |
| 249 | + | // scanUserSkills walks the user-skill search dirs recursively and | |
| 250 | + | // populates `seen` with first-match-wins per name or alias. Split out | |
| 251 | + | // so Discover's includeUser=false path doesn't have to skip over a | |
| 252 | + | // giant block. | |
| 254 | 253 | func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []error { | |
| 255 | 254 | var errs []error | |
| 256 | 255 | for _, loc := range searchDirs(zotHome, cwd, userHome) { | |
| 257 | - | entries, err := os.ReadDir(loc.dir) | |
| 258 | - | if err != nil { | |
| 259 | - | continue // missing dir is fine | |
| 260 | - | } | |
| 261 | - | for _, e := range entries { | |
| 262 | - | if !e.IsDir() { | |
| 263 | - | continue | |
| 256 | + | _ = filepath.WalkDir(loc.dir, func(path string, entry fs.DirEntry, walkErr error) error { | |
| 257 | + | if walkErr != nil { | |
| 258 | + | if !os.IsNotExist(walkErr) { | |
| 259 | + | errs = append(errs, fmt.Errorf("%s: %w", path, walkErr)) | |
| 260 | + | } | |
| 261 | + | return nil | |
| 262 | + | } | |
| 263 | + | if entry.IsDir() || entry.Name() != "SKILL.md" { | |
| 264 | + | return nil | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | rel, err := filepath.Rel(loc.dir, filepath.Dir(path)) | |
| 268 | + | if err != nil { | |
| 269 | + | errs = append(errs, fmt.Errorf("%s: determine skill name: %w", path, err)) | |
| 270 | + | return nil | |
| 271 | + | } | |
| 272 | + | if rel == "." { | |
| 273 | + | // Keep the existing convention that a search root is a | |
| 274 | + | // collection of skill directories, not a skill itself. | |
| 275 | + | return nil | |
| 264 | 276 | } | |
| 265 | - | path := filepath.Join(loc.dir, e.Name(), "SKILL.md") | |
| 277 | + | ||
| 266 | 278 | s, err := load(path, loc.label) | |
| 267 | 279 | if err != nil { | |
| 268 | 280 | if !os.IsNotExist(err) { | |
| 269 | 281 | errs = append(errs, fmt.Errorf("%s: %w", path, err)) | |
| 270 | 282 | } | |
| 271 | - | continue | |
| 283 | + | return nil | |
| 272 | 284 | } | |
| 273 | 285 | if s.Name == "" { | |
| 274 | - | s.Name = e.Name() | |
| 286 | + | s.Name = filepath.Base(filepath.Dir(path)) | |
| 275 | 287 | } | |
| 276 | - | if _, dup := seen[s.Name]; dup { | |
| 277 | - | continue // higher-priority location already won | |
| 278 | - | } | |
| 279 | - | seen[s.Name] = s | |
| 280 | - | } | |
| 288 | + | addRelativeAlias(s, loc.dir, path) | |
| 289 | + | registerSkill(seen, s) | |
| 290 | + | return nil | |
| 291 | + | }) | |
| 281 | 292 | } | |
| 282 | 293 | return errs | |
| 283 | 294 | } | |
| 284 | 295 | ||
| 296 | + | // addRelativeAlias makes a nested skill addressable by the directory path | |
| 297 | + | // used by common skill routers, for example | |
| 298 | + | // systems-backend/subskills/golang-patterns. | |
| 299 | + | func addRelativeAlias(s *Skill, root, path string) { | |
| 300 | + | if s == nil { | |
| 301 | + | return | |
| 302 | + | } | |
| 303 | + | rel, err := filepath.Rel(root, filepath.Dir(path)) | |
| 304 | + | if err != nil || rel == "." || rel == "" { | |
| 305 | + | return | |
| 306 | + | } | |
| 307 | + | alias := filepath.ToSlash(rel) | |
| 308 | + | if alias == s.Name { | |
| 309 | + | return | |
| 310 | + | } | |
| 311 | + | for _, existing := range s.Aliases { | |
| 312 | + | if existing == alias { | |
| 313 | + | return | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | s.Aliases = append(s.Aliases, alias) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // registerSkill records a skill under its canonical name and aliases. A | |
| 320 | + | // collision on any identifier means a higher-priority skill already owns | |
| 321 | + | // that identifier, so the new skill is skipped as one unit. | |
| 322 | + | func registerSkill(seen map[string]*Skill, s *Skill) bool { | |
| 323 | + | if s == nil || s.Name == "" { | |
| 324 | + | return false | |
| 325 | + | } | |
| 326 | + | keys := append([]string{s.Name}, s.Aliases...) | |
| 327 | + | for _, key := range keys { | |
| 328 | + | if key == "" { | |
| 329 | + | continue | |
| 330 | + | } | |
| 331 | + | if _, exists := seen[key]; exists { | |
| 332 | + | return false | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | for _, key := range keys { | |
| 336 | + | if key != "" { | |
| 337 | + | seen[key] = s | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | return true | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func uniqueSkills(seen map[string]*Skill) []*Skill { | |
| 344 | + | out := make([]*Skill, 0, len(seen)) | |
| 345 | + | added := make(map[*Skill]struct{}, len(seen)) | |
| 346 | + | for _, s := range seen { | |
| 347 | + | if s == nil { | |
| 348 | + | continue | |
| 349 | + | } | |
| 350 | + | if _, exists := added[s]; exists { | |
| 351 | + | continue | |
| 352 | + | } | |
| 353 | + | added[s] = struct{}{} | |
| 354 | + | out = append(out, s) | |
| 355 | + | } | |
| 356 | + | return out | |
| 357 | + | } | |
| 358 | + | ||
| 285 | 359 | // SystemPromptAddendum returns the text to append to the system | |
| 286 | 360 | // prompt when at least one skill is loaded. Empty string if none. | |
| 287 | 361 | // | |
| @@ -337,13 +411,23 @@ func skillSourcePointer(s *Skill, home string) string { | |||
| 337 | 411 | return p | |
| 338 | 412 | } | |
| 339 | 413 | ||
| 340 | - | // FindByName returns the skill with the given name, or nil. | |
| 414 | + | // FindByName returns the skill with the given name or alias, or nil. | |
| 341 | 415 | func FindByName(skills []*Skill, name string) *Skill { | |
| 342 | 416 | for _, s := range skills { | |
| 343 | - | if s.Name == name { | |
| 417 | + | if s != nil && s.Name == name { | |
| 344 | 418 | return s | |
| 345 | 419 | } | |
| 346 | 420 | } | |
| 421 | + | for _, s := range skills { | |
| 422 | + | if s == nil { | |
| 423 | + | continue | |
| 424 | + | } | |
| 425 | + | for _, alias := range s.Aliases { | |
| 426 | + | if alias == name { | |
| 427 | + | return s | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | } | |
| 347 | 431 | return nil | |
| 348 | 432 | } | |
| 349 | 433 | ||
| @@ -20,11 +20,15 @@ | |||||
| 20 | // ~/.agents/skills/<name>/SKILL.md — global (agent-compat) | 20 | // ~/.agents/skills/<name>/SKILL.md — global (agent-compat) | ||
| 21 | // | 21 | // | ||
| 22 | // The compat paths are deliberate: a SKILL.md written for any of | 22 | // The compat paths are deliberate: a SKILL.md written for any of | ||
| 23 | - | // the related ecosystems works in zot unchanged. | 23 | + | // the related ecosystems works in zot unchanged. User skill roots are |
| 24 | + | // walked recursively, and nested files can also be addressed by their | |||
| 25 | + | // slash-separated path relative to the root (for example, | |||
| 26 | + | // systems-backend/subskills/golang-patterns). | |||
| 24 | package skills | 27 | package skills | ||
| 25 | 28 | ||||
| 26 | import ( | 29 | import ( | ||
| 27 | "fmt" | 30 | "fmt" | ||
| 31 | + | "io/fs" | |||
| 28 | "os" | 32 | "os" | ||
| 29 | "path/filepath" | 33 | "path/filepath" | ||
| 30 | "sort" | 34 | "sort" | ||
| @@ -38,6 +42,11 @@ type Skill struct { | |||||
| 38 | // field; falls back to the directory basename. | 42 | // field; falls back to the directory basename. | ||
| 39 | Name string | 43 | Name string | ||
| 40 | 44 | ||||
| 45 | + | // Aliases are alternate identifiers accepted by FindByName. Nested | |||
| 46 | + | // user skills include their slash-separated path relative to the | |||
| 47 | + | // search root, so routers can refer to them by file-system path. | |||
| 48 | + | Aliases []string | |||
| 49 | + | ||||
| 41 | // Description is the one-line summary shown to the model in the | 50 | // Description is the one-line summary shown to the model in the | ||
| 42 | // system-prompt manifest. | 51 | // system-prompt manifest. | ||
| 43 | Description string | 52 | Description string | ||
| @@ -130,15 +139,9 @@ func DiscoverWithBundled(zotHome, cwd, userHome string, includeUser bool, bundle | |||||
| 130 | } | 139 | } | ||
| 131 | // Built-ins fill in any name the user or an extension didn't provide. | 140 | // Built-ins fill in any name the user or an extension didn't provide. | ||
| 132 | for _, s := range loadBuiltins() { | 141 | for _, s := range loadBuiltins() { | ||
| 133 | - | if _, dup := seen[s.Name]; dup { | 142 | + | registerSkill(seen, s) |
| 134 | - | continue | |||
| 135 | - | } | |||
| 136 | - | seen[s.Name] = s | |||
| 137 | - | } | |||
| 138 | - | out := make([]*Skill, 0, len(seen)) | |||
| 139 | - | for _, s := range seen { | |||
| 140 | - | out = append(out, s) | |||
| 141 | } | 143 | } | ||
| 144 | + | out := uniqueSkills(seen) | |||
| 142 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | 145 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | ||
| 143 | return out, errs | 146 | return out, errs | ||
| 144 | } | 147 | } | ||
| @@ -152,10 +155,7 @@ func LoadBundled(bundled []BundledSkillDir) ([]*Skill, []error) { | |||||
| 152 | for _, dir := range bundled { | 155 | for _, dir := range bundled { | ||
| 153 | errs = append(errs, scanBundledSkills(dir, seen)...) | 156 | errs = append(errs, scanBundledSkills(dir, seen)...) | ||
| 154 | } | 157 | } | ||
| 155 | - | out := make([]*Skill, 0, len(seen)) | 158 | + | out := uniqueSkills(seen) |
| 156 | - | for _, s := range seen { | |||
| 157 | - | out = append(out, s) | |||
| 158 | - | } | |||
| 159 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | 159 | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | ||
| 160 | return out, errs | 160 | return out, errs | ||
| 161 | } | 161 | } | ||
| @@ -219,9 +219,7 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error { | |||||
| 219 | if s.Name == "" { | 219 | if s.Name == "" { | ||
| 220 | s.Name = fallbackName | 220 | s.Name = fallbackName | ||
| 221 | } | 221 | } | ||
| 222 | - | if _, dup := seen[s.Name]; !dup { | 222 | + | registerSkill(seen, s) |
| 223 | - | seen[s.Name] = s | |||
| 224 | - | } | |||
| 225 | } | 223 | } | ||
| 226 | skillPath := filepath.Join(dir.Dir, "SKILL.md") | 224 | skillPath := filepath.Join(dir.Dir, "SKILL.md") | ||
| 227 | if info, err := os.Stat(skillPath); err == nil { | 225 | if info, err := os.Stat(skillPath); err == nil { | ||
| @@ -248,40 +246,116 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error { | |||||
| 248 | return errs | 246 | return errs | ||
| 249 | } | 247 | } | ||
| 250 | 248 | ||||
| 251 | - | // scanUserSkills walks the user-skill search dirs and populates | 249 | + | // scanUserSkills walks the user-skill search dirs recursively and |
| 252 | - | // `seen` with first-match-wins per name. Split out so Discover's | 250 | + | // populates `seen` with first-match-wins per name or alias. Split out |
| 253 | - | // includeUser=false path doesn't have to skip over a giant block. | 251 | + | // so Discover's includeUser=false path doesn't have to skip over a |
| 252 | + | // giant block. | |||
| 254 | func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []error { | 253 | func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []error { | ||
| 255 | var errs []error | 254 | var errs []error | ||
| 256 | for _, loc := range searchDirs(zotHome, cwd, userHome) { | 255 | for _, loc := range searchDirs(zotHome, cwd, userHome) { | ||
| 257 | - | entries, err := os.ReadDir(loc.dir) | 256 | + | _ = filepath.WalkDir(loc.dir, func(path string, entry fs.DirEntry, walkErr error) error { |
| 258 | - | if err != nil { | 257 | + | if walkErr != nil { |
| 259 | - | continue // missing dir is fine | 258 | + | if !os.IsNotExist(walkErr) { |
| 260 | - | } | 259 | + | errs = append(errs, fmt.Errorf("%s: %w", path, walkErr)) |
| 261 | - | for _, e := range entries { | 260 | + | } |
| 262 | - | if !e.IsDir() { | 261 | + | return nil |
| 263 | - | continue | 262 | + | } |
| 263 | + | if entry.IsDir() || entry.Name() != "SKILL.md" { | |||
| 264 | + | return nil | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | rel, err := filepath.Rel(loc.dir, filepath.Dir(path)) | |||
| 268 | + | if err != nil { | |||
| 269 | + | errs = append(errs, fmt.Errorf("%s: determine skill name: %w", path, err)) | |||
| 270 | + | return nil | |||
| 271 | + | } | |||
| 272 | + | if rel == "." { | |||
| 273 | + | // Keep the existing convention that a search root is a | |||
| 274 | + | // collection of skill directories, not a skill itself. | |||
| 275 | + | return nil | |||
| 264 | } | 276 | } | ||
| 265 | - | path := filepath.Join(loc.dir, e.Name(), "SKILL.md") | 277 | + | |
| 266 | s, err := load(path, loc.label) | 278 | s, err := load(path, loc.label) | ||
| 267 | if err != nil { | 279 | if err != nil { | ||
| 268 | if !os.IsNotExist(err) { | 280 | if !os.IsNotExist(err) { | ||
| 269 | errs = append(errs, fmt.Errorf("%s: %w", path, err)) | 281 | errs = append(errs, fmt.Errorf("%s: %w", path, err)) | ||
| 270 | } | 282 | } | ||
| 271 | - | continue | 283 | + | return nil |
| 272 | } | 284 | } | ||
| 273 | if s.Name == "" { | 285 | if s.Name == "" { | ||
| 274 | - | s.Name = e.Name() | 286 | + | s.Name = filepath.Base(filepath.Dir(path)) |
| 275 | } | 287 | } | ||
| 276 | - | if _, dup := seen[s.Name]; dup { | 288 | + | addRelativeAlias(s, loc.dir, path) |
| 277 | - | continue // higher-priority location already won | 289 | + | registerSkill(seen, s) |
| 278 | - | } | 290 | + | return nil |
| 279 | - | seen[s.Name] = s | 291 | + | }) |
| 280 | - | } | |||
| 281 | } | 292 | } | ||
| 282 | return errs | 293 | return errs | ||
| 283 | } | 294 | } | ||
| 284 | 295 | ||||
| 296 | + | // addRelativeAlias makes a nested skill addressable by the directory path | |||
| 297 | + | // used by common skill routers, for example | |||
| 298 | + | // systems-backend/subskills/golang-patterns. | |||
| 299 | + | func addRelativeAlias(s *Skill, root, path string) { | |||
| 300 | + | if s == nil { | |||
| 301 | + | return | |||
| 302 | + | } | |||
| 303 | + | rel, err := filepath.Rel(root, filepath.Dir(path)) | |||
| 304 | + | if err != nil || rel == "." || rel == "" { | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | alias := filepath.ToSlash(rel) | |||
| 308 | + | if alias == s.Name { | |||
| 309 | + | return | |||
| 310 | + | } | |||
| 311 | + | for _, existing := range s.Aliases { | |||
| 312 | + | if existing == alias { | |||
| 313 | + | return | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | s.Aliases = append(s.Aliases, alias) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // registerSkill records a skill under its canonical name and aliases. A | |||
| 320 | + | // collision on any identifier means a higher-priority skill already owns | |||
| 321 | + | // that identifier, so the new skill is skipped as one unit. | |||
| 322 | + | func registerSkill(seen map[string]*Skill, s *Skill) bool { | |||
| 323 | + | if s == nil || s.Name == "" { | |||
| 324 | + | return false | |||
| 325 | + | } | |||
| 326 | + | keys := append([]string{s.Name}, s.Aliases...) | |||
| 327 | + | for _, key := range keys { | |||
| 328 | + | if key == "" { | |||
| 329 | + | continue | |||
| 330 | + | } | |||
| 331 | + | if _, exists := seen[key]; exists { | |||
| 332 | + | return false | |||
| 333 | + | } | |||
| 334 | + | } | |||
| 335 | + | for _, key := range keys { | |||
| 336 | + | if key != "" { | |||
| 337 | + | seen[key] = s | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | return true | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | func uniqueSkills(seen map[string]*Skill) []*Skill { | |||
| 344 | + | out := make([]*Skill, 0, len(seen)) | |||
| 345 | + | added := make(map[*Skill]struct{}, len(seen)) | |||
| 346 | + | for _, s := range seen { | |||
| 347 | + | if s == nil { | |||
| 348 | + | continue | |||
| 349 | + | } | |||
| 350 | + | if _, exists := added[s]; exists { | |||
| 351 | + | continue | |||
| 352 | + | } | |||
| 353 | + | added[s] = struct{}{} | |||
| 354 | + | out = append(out, s) | |||
| 355 | + | } | |||
| 356 | + | return out | |||
| 357 | + | } | |||
| 358 | + | ||||
| 285 | // SystemPromptAddendum returns the text to append to the system | 359 | // SystemPromptAddendum returns the text to append to the system | ||
| 286 | // prompt when at least one skill is loaded. Empty string if none. | 360 | // prompt when at least one skill is loaded. Empty string if none. | ||
| 287 | // | 361 | // | ||
| @@ -337,13 +411,23 @@ func skillSourcePointer(s *Skill, home string) string { | |||||
| 337 | return p | 411 | return p | ||
| 338 | } | 412 | } | ||
| 339 | 413 | ||||
| 340 | - | // FindByName returns the skill with the given name, or nil. | 414 | + | // FindByName returns the skill with the given name or alias, or nil. |
| 341 | func FindByName(skills []*Skill, name string) *Skill { | 415 | func FindByName(skills []*Skill, name string) *Skill { | ||
| 342 | for _, s := range skills { | 416 | for _, s := range skills { | ||
| 343 | - | if s.Name == name { | 417 | + | if s != nil && s.Name == name { |
| 344 | return s | 418 | return s | ||
| 345 | } | 419 | } | ||
| 346 | } | 420 | } | ||
| 421 | + | for _, s := range skills { | |||
| 422 | + | if s == nil { | |||
| 423 | + | continue | |||
| 424 | + | } | |||
| 425 | + | for _, alias := range s.Aliases { | |||
| 426 | + | if alias == name { | |||
| 427 | + | return s | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | } | |||
| 347 | return nil | 431 | return nil | ||
| 348 | } | 432 | } | ||
| 349 | 433 | ||||
M packages/agent/skills/skills_test.go +41 -0
| @@ -164,6 +164,47 @@ func TestVisibleSkillsHidesBuiltins(t *testing.T) { | |||
| 164 | 164 | } | |
| 165 | 165 | } | |
| 166 | 166 | ||
| 167 | + | func TestDiscoverRecursesNestedAgentSkillsAndSupportsPathAlias(t *testing.T) { | |
| 168 | + | t.Setenv("ZOT_AGENT_SKILLS", "") | |
| 169 | + | tmp := t.TempDir() | |
| 170 | + | home := filepath.Join(tmp, "home") | |
| 171 | + | cwd := filepath.Join(tmp, "project") | |
| 172 | + | root := filepath.Join(home, ".agents", "skills") | |
| 173 | + | parentDir := filepath.Join(root, "systems-backend") | |
| 174 | + | nestedDir := filepath.Join(parentDir, "subskills", "golang-patterns") | |
| 175 | + | if err := os.MkdirAll(nestedDir, 0o755); err != nil { | |
| 176 | + | t.Fatal(err) | |
| 177 | + | } | |
| 178 | + | if err := os.WriteFile(filepath.Join(parentDir, "SKILL.md"), []byte("---\nname: systems-backend\ndescription: router\n---\nrouter body\n"), 0o644); err != nil { | |
| 179 | + | t.Fatal(err) | |
| 180 | + | } | |
| 181 | + | if err := os.WriteFile(filepath.Join(nestedDir, "SKILL.md"), []byte("---\nname: golang-patterns\ndescription: Go patterns\ndisable-model-invocation: true\n---\nGo body\n"), 0o644); err != nil { | |
| 182 | + | t.Fatal(err) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | list, errs := Discover("", cwd, home, true) | |
| 186 | + | if len(errs) != 0 { | |
| 187 | + | t.Fatalf("Discover() errors = %v", errs) | |
| 188 | + | } | |
| 189 | + | if got := FindByName(list, "systems-backend"); got == nil || got.Source != "global (agents)" { | |
| 190 | + | t.Fatalf("parent skill = %#v", got) | |
| 191 | + | } | |
| 192 | + | nested := FindByName(list, "golang-patterns") | |
| 193 | + | if nested == nil { | |
| 194 | + | t.Fatalf("nested skill was not discovered: %#v", list) | |
| 195 | + | } | |
| 196 | + | if nested.Source != "global (agents)" || nested.Path != filepath.Join(nestedDir, "SKILL.md") { | |
| 197 | + | t.Fatalf("nested skill metadata = %#v", nested) | |
| 198 | + | } | |
| 199 | + | if nested.Body != "Go body" { | |
| 200 | + | t.Fatalf("nested skill body = %q", nested.Body) | |
| 201 | + | } | |
| 202 | + | alias := "systems-backend/subskills/golang-patterns" | |
| 203 | + | if got := FindByName(list, alias); got != nested { | |
| 204 | + | t.Fatalf("FindByName(%q) = %#v, want nested skill %#v", alias, got, nested) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 167 | 208 | func TestSystemPromptAddendum(t *testing.T) { | |
| 168 | 209 | skills := []*Skill{ | |
| 169 | 210 | {Name: "built-a", Description: "Do A.", Builtin: true}, | |
| @@ -164,6 +164,47 @@ func TestVisibleSkillsHidesBuiltins(t *testing.T) { | |||||
| 164 | } | 164 | } | ||
| 165 | } | 165 | } | ||
| 166 | 166 | ||||
| 167 | + | func TestDiscoverRecursesNestedAgentSkillsAndSupportsPathAlias(t *testing.T) { | |||
| 168 | + | t.Setenv("ZOT_AGENT_SKILLS", "") | |||
| 169 | + | tmp := t.TempDir() | |||
| 170 | + | home := filepath.Join(tmp, "home") | |||
| 171 | + | cwd := filepath.Join(tmp, "project") | |||
| 172 | + | root := filepath.Join(home, ".agents", "skills") | |||
| 173 | + | parentDir := filepath.Join(root, "systems-backend") | |||
| 174 | + | nestedDir := filepath.Join(parentDir, "subskills", "golang-patterns") | |||
| 175 | + | if err := os.MkdirAll(nestedDir, 0o755); err != nil { | |||
| 176 | + | t.Fatal(err) | |||
| 177 | + | } | |||
| 178 | + | if err := os.WriteFile(filepath.Join(parentDir, "SKILL.md"), []byte("---\nname: systems-backend\ndescription: router\n---\nrouter body\n"), 0o644); err != nil { | |||
| 179 | + | t.Fatal(err) | |||
| 180 | + | } | |||
| 181 | + | if err := os.WriteFile(filepath.Join(nestedDir, "SKILL.md"), []byte("---\nname: golang-patterns\ndescription: Go patterns\ndisable-model-invocation: true\n---\nGo body\n"), 0o644); err != nil { | |||
| 182 | + | t.Fatal(err) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | list, errs := Discover("", cwd, home, true) | |||
| 186 | + | if len(errs) != 0 { | |||
| 187 | + | t.Fatalf("Discover() errors = %v", errs) | |||
| 188 | + | } | |||
| 189 | + | if got := FindByName(list, "systems-backend"); got == nil || got.Source != "global (agents)" { | |||
| 190 | + | t.Fatalf("parent skill = %#v", got) | |||
| 191 | + | } | |||
| 192 | + | nested := FindByName(list, "golang-patterns") | |||
| 193 | + | if nested == nil { | |||
| 194 | + | t.Fatalf("nested skill was not discovered: %#v", list) | |||
| 195 | + | } | |||
| 196 | + | if nested.Source != "global (agents)" || nested.Path != filepath.Join(nestedDir, "SKILL.md") { | |||
| 197 | + | t.Fatalf("nested skill metadata = %#v", nested) | |||
| 198 | + | } | |||
| 199 | + | if nested.Body != "Go body" { | |||
| 200 | + | t.Fatalf("nested skill body = %q", nested.Body) | |||
| 201 | + | } | |||
| 202 | + | alias := "systems-backend/subskills/golang-patterns" | |||
| 203 | + | if got := FindByName(list, alias); got != nested { | |||
| 204 | + | t.Fatalf("FindByName(%q) = %#v, want nested skill %#v", alias, got, nested) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 167 | func TestSystemPromptAddendum(t *testing.T) { | 208 | func TestSystemPromptAddendum(t *testing.T) { | ||
| 168 | skills := []*Skill{ | 209 | skills := []*Skill{ | ||
| 169 | {Name: "built-a", Description: "Do A.", Builtin: true}, | 210 | {Name: "built-a", Description: "Do A.", Builtin: true}, | ||
A packages/agent/skills/tool_test.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package skills | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestToolLoadsSkillByPathAlias(t *testing.T) { | |
| 13 | + | tool := NewTool([]*Skill{{ | |
| 14 | + | Name: "golang-patterns", | |
| 15 | + | Aliases: []string{"systems-backend/subskills/golang-patterns"}, | |
| 16 | + | Body: "Go body", | |
| 17 | + | }}) | |
| 18 | + | ||
| 19 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"name":"systems-backend/subskills/golang-patterns"}`), nil) | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatalf("Execute() error = %v", err) | |
| 22 | + | } | |
| 23 | + | if result.IsError { | |
| 24 | + | t.Fatalf("Execute() returned an error result: %#v", result) | |
| 25 | + | } | |
| 26 | + | if len(result.Content) != 1 { | |
| 27 | + | t.Fatalf("Execute() content = %#v", result.Content) | |
| 28 | + | } | |
| 29 | + | text, ok := result.Content[0].(provider.TextBlock) | |
| 30 | + | if !ok { | |
| 31 | + | t.Fatalf("Execute() content type = %T", result.Content[0]) | |
| 32 | + | } | |
| 33 | + | if !strings.Contains(text.Text, "Go body") { | |
| 34 | + | t.Fatalf("Execute() text = %q", text.Text) | |
| 35 | + | } | |
| 36 | + | } | |
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package skills | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestToolLoadsSkillByPathAlias(t *testing.T) { | |||
| 13 | + | tool := NewTool([]*Skill{{ | |||
| 14 | + | Name: "golang-patterns", | |||
| 15 | + | Aliases: []string{"systems-backend/subskills/golang-patterns"}, | |||
| 16 | + | Body: "Go body", | |||
| 17 | + | }}) | |||
| 18 | + | ||||
| 19 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"name":"systems-backend/subskills/golang-patterns"}`), nil) | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatalf("Execute() error = %v", err) | |||
| 22 | + | } | |||
| 23 | + | if result.IsError { | |||
| 24 | + | t.Fatalf("Execute() returned an error result: %#v", result) | |||
| 25 | + | } | |||
| 26 | + | if len(result.Content) != 1 { | |||
| 27 | + | t.Fatalf("Execute() content = %#v", result.Content) | |||
| 28 | + | } | |||
| 29 | + | text, ok := result.Content[0].(provider.TextBlock) | |||
| 30 | + | if !ok { | |||
| 31 | + | t.Fatalf("Execute() content type = %T", result.Content[0]) | |||
| 32 | + | } | |||
| 33 | + | if !strings.Contains(text.Text, "Go body") { | |||
| 34 | + | t.Fatalf("Execute() text = %q", text.Text) | |||
| 35 | + | } | |||
| 36 | + | } | |||