detached 7345a0b

Merge pull request #28 from bnema/fix/nested-agent-skill-discovery

fix(skills): discover nested user skill files
commit
7345a0b8c00f6a143f16521ffa1aed548eeea7bf
author
bnema · · 2026-08-05 13:52 UTC
committer
GitHub · · 2026-08-05 13:52 UTC
merge of
4ed7779 + ad7540b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
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:
985985 - `./.claude/skills/<name>/SKILL.md`, `~/.claude/skills/<name>/SKILL.md` (Claude-compatible layout)
986986 - `./.agents/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` (agent-compatible layout)
987987
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+
988990 See [docs/skills.md](docs/skills.md) for the frontmatter fields, authoring tips, and example skills under `examples/skills/`.
989991
990992 ## 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:
6464 The compat paths are deliberate: a `SKILL.md` written for an existing
6565 skill ecosystem works in zot unchanged. Drop your existing
6666 `.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.
6872
6973 Enabled extensions may bundle skills by declaring relative directories in
7074 `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 existing64 The compat paths are deliberate: a `SKILL.md` written for an existing
65 skill ecosystem works in zot unchanged. Drop your existing65 skill ecosystem works in zot unchanged. Drop your existing
66 `.claude/skills/` or `.agents/skills/` directories into a project and66 `.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 in73 Enabled extensions may bundle skills by declaring relative directories in
70 `extension.json`, for example `"skills": ["skills"]`, with files at74 `extension.json`, for example `"skills": ["skills"]`, with files at
M packages/agent/skills/skills.go +120 -36
@@ -20,11 +20,15 @@
2020 // ~/.agents/skills/<name>/SKILL.md — global (agent-compat)
2121 //
2222 // 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).
2427 package skills
2528
2629 import (
2730 "fmt"
31+ "io/fs"
2832 "os"
2933 "path/filepath"
3034 "sort"
@@ -38,6 +42,11 @@ type Skill struct {
3842 // field; falls back to the directory basename.
3943 Name string
4044
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+
4150 // Description is the one-line summary shown to the model in the
4251 // system-prompt manifest.
4352 Description string
@@ -130,15 +139,9 @@ func DiscoverWithBundled(zotHome, cwd, userHome string, includeUser bool, bundle
130139 }
131140 // Built-ins fill in any name the user or an extension didn't provide.
132141 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)
141143 }
144+ out := uniqueSkills(seen)
142145 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
143146 return out, errs
144147 }
@@ -152,10 +155,7 @@ func LoadBundled(bundled []BundledSkillDir) ([]*Skill, []error) {
152155 for _, dir := range bundled {
153156 errs = append(errs, scanBundledSkills(dir, seen)...)
154157 }
155- out := make([]*Skill, 0, len(seen))
156- for _, s := range seen {
157- out = append(out, s)
158- }
158+ out := uniqueSkills(seen)
159159 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
160160 return out, errs
161161 }
@@ -219,9 +219,7 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error {
219219 if s.Name == "" {
220220 s.Name = fallbackName
221221 }
222- if _, dup := seen[s.Name]; !dup {
223- seen[s.Name] = s
224- }
222+ registerSkill(seen, s)
225223 }
226224 skillPath := filepath.Join(dir.Dir, "SKILL.md")
227225 if info, err := os.Stat(skillPath); err == nil {
@@ -248,40 +246,116 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error {
248246 return errs
249247 }
250248
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.
254253 func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []error {
255254 var errs []error
256255 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
264276 }
265- path := filepath.Join(loc.dir, e.Name(), "SKILL.md")
277+
266278 s, err := load(path, loc.label)
267279 if err != nil {
268280 if !os.IsNotExist(err) {
269281 errs = append(errs, fmt.Errorf("%s: %w", path, err))
270282 }
271- continue
283+ return nil
272284 }
273285 if s.Name == "" {
274- s.Name = e.Name()
286+ s.Name = filepath.Base(filepath.Dir(path))
275287 }
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+ })
281292 }
282293 return errs
283294 }
284295
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+
285359 // SystemPromptAddendum returns the text to append to the system
286360 // prompt when at least one skill is loaded. Empty string if none.
287361 //
@@ -337,13 +411,23 @@ func skillSourcePointer(s *Skill, home string) string {
337411 return p
338412 }
339413
340-// FindByName returns the skill with the given name, or nil.
414+// FindByName returns the skill with the given name or alias, or nil.
341415 func FindByName(skills []*Skill, name string) *Skill {
342416 for _, s := range skills {
343- if s.Name == name {
417+ if s != nil && s.Name == name {
344418 return s
345419 }
346420 }
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+ }
347431 return nil
348432 }
349433
@@ -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 of22 // 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 skills27 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 string43 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 the50 // Description is the one-line summary shown to the model in the
42 // system-prompt manifest.51 // system-prompt manifest.
43 Description string52 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, errs146 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, errs160 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 = fallbackName220 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 errs246 return errs
249 }247 }
250 248
251-// scanUserSkills walks the user-skill search dirs and populates249+// scanUserSkills walks the user-skill search dirs recursively and
252-// `seen` with first-match-wins per name. Split out so Discover's250+// 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 []error254 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 fine258+ 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- continue262+ }
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- continue283+ 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 won289+ registerSkill(seen, s)
278- }290+ return nil
279- seen[s.Name] = s291+ })
280- }
281 }292 }
282 return errs293 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 system359 // 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 p411 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 s418 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 nil431 return nil
348 }432 }
349 433
M packages/agent/skills/skills_test.go +41 -0
@@ -164,6 +164,47 @@ func TestVisibleSkillsHidesBuiltins(t *testing.T) {
164164 }
165165 }
166166
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+
167208 func TestSystemPromptAddendum(t *testing.T) {
168209 skills := []*Skill{
169210 {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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close