detached cc6208e

Add recursive extension skill discovery

commit
cc6208eb406f10adf565a75340e7cccb4b34859f
author
Zeno Jiricek · · 2026-09-04 02:25 UTC
committer
Zeno Jiricek · · 2026-09-04 11:47 UTC
parent
e54d007
signature
unsigned
15 files · +480 -25
M docs/extensions.md +3 -1
@@ -60,7 +60,8 @@ Drop it in a directory with this `extension.json`:
6060
6161 `exec` is required for protocol extensions. If an extension only ships
6262 `theme.json` or `themes/theme.json`, no `exec` is required and zot does
63-not spawn a subprocess.
63+not spawn a subprocess. A skill-only bundle may instead provide a `skills`
64+array and is also never spawned.
6465
6566 `chmod +x hello.py`, install:
6667
@@ -130,6 +131,7 @@ manifest tells zot how to launch it:
130131 | `args` | optional. extra argv passed to `exec`. |
131132 | `language` | optional. informational only (`go`, `python`, `typescript`, ...). |
132133 | `description` | optional. shown in `zot ext list`. |
134+| `skills` | optional. directories (recursively scanned) or direct `SKILL.md` paths, relative to the manifest. |
133135 | `enabled` | optional, defaults to `true`. set to `false` to disable without removing. |
134136
135137 ## Lifecycle
@@ -60,7 +60,8 @@ Drop it in a directory with this `extension.json`:
60 60
61 `exec` is required for protocol extensions. If an extension only ships61 `exec` is required for protocol extensions. If an extension only ships
62 `theme.json` or `themes/theme.json`, no `exec` is required and zot does62 `theme.json` or `themes/theme.json`, no `exec` is required and zot does
63-not spawn a subprocess.63+not spawn a subprocess. A skill-only bundle may instead provide a `skills`
64+array and is also never spawned.
64 65
65 `chmod +x hello.py`, install:66 `chmod +x hello.py`, install:
66 67
@@ -130,6 +131,7 @@ manifest tells zot how to launch it:
130 | `args` | optional. extra argv passed to `exec`. |131 | `args` | optional. extra argv passed to `exec`. |
131 | `language` | optional. informational only (`go`, `python`, `typescript`, ...). |132 | `language` | optional. informational only (`go`, `python`, `typescript`, ...). |
132 | `description` | optional. shown in `zot ext list`. |133 | `description` | optional. shown in `zot ext list`. |
134+| `skills` | optional. directories (recursively scanned) or direct `SKILL.md` paths, relative to the manifest. |
133 | `enabled` | optional, defaults to `true`. set to `false` to disable without removing. |135 | `enabled` | optional, defaults to `true`. set to `false` to disable without removing. |
134 136
135 ## Lifecycle137 ## Lifecycle
M docs/skills.md +16 -5
@@ -1,7 +1,8 @@
11 # zot skills
22
33 A skill is a reusable instruction set written as a single
4-`SKILL.md` file with a YAML frontmatter header. Unless a skill disables
4+`SKILL.md` file with a YAML frontmatter header. Skills may be supplied by
5+an extension as well as by the normal skill directories. Unless a skill disables
56 model invocation, zot discovers it at startup and surfaces it to the model
67 in two ways:
78
@@ -34,7 +35,7 @@ When asked to review code, ...
3435
3536 | field | required | purpose |
3637 |---|---|---|
37-| `name` | optional | skill identifier; defaults to the directory name |
38+| `name` | optional | skill identifier; defaults to the normalized relative directory path |
3839 | `description` | required | one-line summary shown in the system prompt |
3940 | `disable-model-invocation` | optional | when `true`, hide the skill from the model's startup manifest; invoke it explicitly with `/skill:<name>` |
4041 | `allowed-tools` | optional | list of tool names the skill is meant to use; informational |
@@ -49,7 +50,7 @@ There's no template engine; the model sees what you write.
4950
5051 ## Discovery
5152
52-zot looks in these directories, in priority order, and registers the
53+zot recursively looks in these directories, in priority order, and registers the
5354 first `SKILL.md` it finds for each unique name:
5455
5556 | location | scope |
@@ -64,7 +65,16 @@ first `SKILL.md` it finds for each unique name:
6465 The compat paths are deliberate: a `SKILL.md` written for an existing
6566 skill ecosystem works in zot unchanged. Drop your existing
6667 `.claude/skills/` or `.agents/skills/` directories into a project and
67-zot will pick them up.
68+zot will pick them up. When no frontmatter `name` is present, nested path
69+components are lowercased and normalized to kebab-case and joined with `-`.
70+Only files named exactly `SKILL.md` are read.
71+
72+Extensions namespace their skills, for example `git-tools:writing-git-commits`.
73+Explicit `--ext` bundles take precedence over environment, project, global, and
74+compatibility sources. Duplicate names keep the higher-precedence file and
75+report a diagnostic. `--no-ext` omits implicit extension bundles;
76+`--no-ext --ext PATH` loads only the explicitly named bundle. `--no-skill`
77+disables all skills, including extension and built-in skills.
6878
6979 When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
7080 `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
@@ -113,10 +123,11 @@ are omitted from the model's startup context.
113123
114124 ## Examples
115125
116-See `examples/skills/` for two starter skills:
126+See `examples/skills/` for starter skills:
117127
118128 - `code-review/` — self-review pass on a recent diff
119129 - `test-fix/` — diagnose + minimally fix a failing test
130+- `recursive/` — nested path-derived naming and explicit-name discovery
120131
121132 ## Comparison to other discovery layouts
122133
@@ -1,7 +1,8 @@
1 # zot skills1 # zot skills
2 2
3 A skill is a reusable instruction set written as a single3 A skill is a reusable instruction set written as a single
4-`SKILL.md` file with a YAML frontmatter header. Unless a skill disables4+`SKILL.md` file with a YAML frontmatter header. Skills may be supplied by
5+an extension as well as by the normal skill directories. Unless a skill disables
5 model invocation, zot discovers it at startup and surfaces it to the model6 model invocation, zot discovers it at startup and surfaces it to the model
6 in two ways:7 in two ways:
7 8
@@ -34,7 +35,7 @@ When asked to review code, ...
34 35
35 | field | required | purpose |36 | field | required | purpose |
36 |---|---|---|37 |---|---|---|
37-| `name` | optional | skill identifier; defaults to the directory name |38+| `name` | optional | skill identifier; defaults to the normalized relative directory path |
38 | `description` | required | one-line summary shown in the system prompt |39 | `description` | required | one-line summary shown in the system prompt |
39 | `disable-model-invocation` | optional | when `true`, hide the skill from the model's startup manifest; invoke it explicitly with `/skill:<name>` |40 | `disable-model-invocation` | optional | when `true`, hide the skill from the model's startup manifest; invoke it explicitly with `/skill:<name>` |
40 | `allowed-tools` | optional | list of tool names the skill is meant to use; informational |41 | `allowed-tools` | optional | list of tool names the skill is meant to use; informational |
@@ -49,7 +50,7 @@ There's no template engine; the model sees what you write.
49 50
50 ## Discovery51 ## Discovery
51 52
52-zot looks in these directories, in priority order, and registers the53+zot recursively looks in these directories, in priority order, and registers the
53 first `SKILL.md` it finds for each unique name:54 first `SKILL.md` it finds for each unique name:
54 55
55 | location | scope |56 | location | scope |
@@ -64,7 +65,16 @@ first `SKILL.md` it finds for each unique name:
64 The compat paths are deliberate: a `SKILL.md` written for an existing65 The compat paths are deliberate: a `SKILL.md` written for an existing
65 skill ecosystem works in zot unchanged. Drop your existing66 skill ecosystem works in zot unchanged. Drop your existing
66 `.claude/skills/` or `.agents/skills/` directories into a project and67 `.claude/skills/` or `.agents/skills/` directories into a project and
67-zot will pick them up.68+zot will pick them up. When no frontmatter `name` is present, nested path
69+components are lowercased and normalized to kebab-case and joined with `-`.
70+Only files named exactly `SKILL.md` are read.
71+
72+Extensions namespace their skills, for example `git-tools:writing-git-commits`.
73+Explicit `--ext` bundles take precedence over environment, project, global, and
74+compatibility sources. Duplicate names keep the higher-precedence file and
75+report a diagnostic. `--no-ext` omits implicit extension bundles;
76+`--no-ext --ext PATH` loads only the explicitly named bundle. `--no-skill`
77+disables all skills, including extension and built-in skills.
68 78
69 When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to79 When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
70 `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`80 `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
@@ -113,10 +123,11 @@ are omitted from the model's startup context.
113 123
114 ## Examples124 ## Examples
115 125
116-See `examples/skills/` for two starter skills:126+See `examples/skills/` for starter skills:
117 127
118 - `code-review/` — self-review pass on a recent diff128 - `code-review/` — self-review pass on a recent diff
119 - `test-fix/` — diagnose + minimally fix a failing test129 - `test-fix/` — diagnose + minimally fix a failing test
130+- `recursive/` — nested path-derived naming and explicit-name discovery
120 131
121 ## Comparison to other discovery layouts132 ## Comparison to other discovery layouts
122 133
A examples/skills/extension-fixture/README.md +15 -0
@@ -0,0 +1,15 @@
1+# Skill-only extension fixture
2+
3+This is a standalone extension bundle containing only filesystem-backed skills.
4+It has no executable and therefore does not start a subprocess.
5+
6+Test it from the repository root with:
7+
8+```bash
9+go run ./cmd/zot --no-ext --ext ./examples/skills/extension-fixture
10+```
11+
12+Run `/skills` in zot. The discovered names are:
13+
14+- `demo-tools:writing-git-commits` — namespace plus path-derived name
15+- `demo-tools:secret-review` — namespace plus explicit frontmatter name
@@ -0,0 +1,15 @@
1+# Skill-only extension fixture
2+
3+This is a standalone extension bundle containing only filesystem-backed skills.
4+It has no executable and therefore does not start a subprocess.
5+
6+Test it from the repository root with:
7+
8+```bash
9+go run ./cmd/zot --no-ext --ext ./examples/skills/extension-fixture
10+```
11+
12+Run `/skills` in zot. The discovered names are:
13+
14+- `demo-tools:writing-git-commits` — namespace plus path-derived name
15+- `demo-tools:secret-review` — namespace plus explicit frontmatter name
A examples/skills/extension-fixture/extension.json +7 -0
@@ -0,0 +1,7 @@
1+{
2+ "name": "demo-tools",
3+ "version": "1.0.0",
4+ "description": "Example skill-only extension bundle",
5+ "skills": ["./skills"],
6+ "enabled": true
7+}
@@ -0,0 +1,7 @@
1+{
2+ "name": "demo-tools",
3+ "version": "1.0.0",
4+ "description": "Example skill-only extension bundle",
5+ "skills": ["./skills"],
6+ "enabled": true
7+}
A examples/skills/extension-fixture/skills/security/review-secrets/SKILL.md +14 -0
@@ -0,0 +1,14 @@
1+---
2+name: secret-review
3+description: Check a change for accidentally exposed credentials and private data.
4+allowed-tools: [read, bash]
5+---
6+
7+# Review for secrets
8+
9+Use this skill before publishing a change or opening a pull request.
10+
11+- Search the diff for API keys, tokens, private keys, passwords, and session data.
12+- Check newly added fixtures and examples as well as source code.
13+- Replace real-looking values with clearly synthetic placeholders.
14+- Never copy a discovered secret into a report or chat message.
@@ -0,0 +1,14 @@
1+---
2+name: secret-review
3+description: Check a change for accidentally exposed credentials and private data.
4+allowed-tools: [read, bash]
5+---
6+
7+# Review for secrets
8+
9+Use this skill before publishing a change or opening a pull request.
10+
11+- Search the diff for API keys, tokens, private keys, passwords, and session data.
12+- Check newly added fixtures and examples as well as source code.
13+- Replace real-looking values with clearly synthetic placeholders.
14+- Never copy a discovered secret into a report or chat message.
A examples/skills/extension-fixture/skills/writing-git-commits/SKILL.md +12 -0
@@ -0,0 +1,12 @@
1+---
2+description: Write clear, focused Git commit messages and keep commits easy to review.
3+---
4+
5+# Writing Git commits
6+
7+Use this skill when preparing a commit.
8+
9+1. Inspect the staged diff and identify the user-visible change.
10+2. Keep the subject concise, imperative, and specific.
11+3. Explain motivation and notable trade-offs in the body when needed.
12+4. Verify the final staged diff before committing.
@@ -0,0 +1,12 @@
1+---
2+description: Write clear, focused Git commit messages and keep commits easy to review.
3+---
4+
5+# Writing Git commits
6+
7+Use this skill when preparing a commit.
8+
9+1. Inspect the staged diff and identify the user-visible change.
10+2. Keep the subject concise, imperative, and specific.
11+3. Explain motivation and notable trade-offs in the body when needed.
12+4. Verify the final staged diff before committing.
A examples/skills/recursive/README.md +10 -0
@@ -0,0 +1,10 @@
1+# Recursive skill fixture
2+
3+This directory demonstrates recursive discovery. The files intentionally use
4+both path-derived naming and an explicit frontmatter name:
5+
6+- `writing-git-commits/SKILL.md` → `writing-git-commits`
7+- `security/review-secrets/SKILL.md` → `secret-review`
8+
9+Run zot from a checkout containing this directory as a skill source, or copy
10+its contents into `.zot/skills/`.
@@ -0,0 +1,10 @@
1+# Recursive skill fixture
2+
3+This directory demonstrates recursive discovery. The files intentionally use
4+both path-derived naming and an explicit frontmatter name:
5+
6+- `writing-git-commits/SKILL.md` → `writing-git-commits`
7+- `security/review-secrets/SKILL.md` → `secret-review`
8+
9+Run zot from a checkout containing this directory as a skill source, or copy
10+its contents into `.zot/skills/`.
A examples/skills/recursive/security/review-secrets/SKILL.md +15 -0
@@ -0,0 +1,15 @@
1+---
2+name: secret-review
3+description: Check a change for accidentally exposed credentials and private data.
4+allowed-tools: [read, bash]
5+---
6+
7+# Review for secrets
8+
9+Use this skill before publishing a change or opening a pull request.
10+
11+- Search the diff for API keys, tokens, private keys, passwords, and session data.
12+- Check newly added fixtures and examples as well as source code.
13+- Replace real-looking values with clearly synthetic placeholders.
14+- Report the file and line containing every suspicious value.
15+- Never copy a discovered secret into a report or chat message.
@@ -0,0 +1,15 @@
1+---
2+name: secret-review
3+description: Check a change for accidentally exposed credentials and private data.
4+allowed-tools: [read, bash]
5+---
6+
7+# Review for secrets
8+
9+Use this skill before publishing a change or opening a pull request.
10+
11+- Search the diff for API keys, tokens, private keys, passwords, and session data.
12+- Check newly added fixtures and examples as well as source code.
13+- Replace real-looking values with clearly synthetic placeholders.
14+- Report the file and line containing every suspicious value.
15+- Never copy a discovered secret into a report or chat message.
A examples/skills/recursive/writing-git-commits/SKILL.md +13 -0
@@ -0,0 +1,13 @@
1+---
2+description: Write clear, focused Git commit messages and keep commits easy to review.
3+---
4+
5+# Writing Git commits
6+
7+Use this skill when preparing a commit.
8+
9+1. Inspect the staged diff and identify the user-visible change.
10+2. Keep the subject concise, imperative, and specific.
11+3. Explain motivation and notable trade-offs in the body when needed.
12+4. Do not include generated files, credentials, or unrelated changes.
13+5. Verify the final staged diff before committing.
@@ -0,0 +1,13 @@
1+---
2+description: Write clear, focused Git commit messages and keep commits easy to review.
3+---
4+
5+# Writing Git commits
6+
7+Use this skill when preparing a commit.
8+
9+1. Inspect the staged diff and identify the user-visible change.
10+2. Keep the subject concise, imperative, and specific.
11+3. Explain motivation and notable trade-offs in the body when needed.
12+4. Do not include generated files, credentials, or unrelated changes.
13+5. Verify the final staged diff before committing.
M packages/agent/build.go +12 -1
@@ -9,6 +9,7 @@ import (
99 "strings"
1010
1111 zotdocs "github.com/patriceckhart/zot"
12+ "github.com/patriceckhart/zot/packages/agent/extensions"
1213 "github.com/patriceckhart/zot/packages/agent/skills"
1314 "github.com/patriceckhart/zot/packages/agent/tools"
1415 "github.com/patriceckhart/zot/packages/core"
@@ -595,7 +596,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
595596 )
596597 if !args.NoSkill {
597598 homeDir, _ := os.UserHomeDir()
598- discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
599+ var sources []skills.Source
600+ if args.WithSkills {
601+ for _, loc := range skills.SearchSources(ZotHome(), args.CWD, homeDir) {
602+ sources = append(sources, loc)
603+ }
604+ }
605+ if !args.NoExt || len(args.Exts) > 0 {
606+ extSources, _ := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt)
607+ sources = append(extSources, sources...)
608+ }
609+ discovered, _ = skills.DiscoverSources(sources, true)
599610 if len(discovered) > 0 {
600611 skillTool = skills.NewTool(discovered)
601612 reg[skillTool.Name()] = skillTool
@@ -9,6 +9,7 @@ import (
9 "strings"9 "strings"
10 10
11 zotdocs "github.com/patriceckhart/zot"11 zotdocs "github.com/patriceckhart/zot"
12+ "github.com/patriceckhart/zot/packages/agent/extensions"
12 "github.com/patriceckhart/zot/packages/agent/skills"13 "github.com/patriceckhart/zot/packages/agent/skills"
13 "github.com/patriceckhart/zot/packages/agent/tools"14 "github.com/patriceckhart/zot/packages/agent/tools"
14 "github.com/patriceckhart/zot/packages/core"15 "github.com/patriceckhart/zot/packages/core"
@@ -595,7 +596,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
595 )596 )
596 if !args.NoSkill {597 if !args.NoSkill {
597 homeDir, _ := os.UserHomeDir()598 homeDir, _ := os.UserHomeDir()
598- discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)599+ var sources []skills.Source
600+ if args.WithSkills {
601+ for _, loc := range skills.SearchSources(ZotHome(), args.CWD, homeDir) {
602+ sources = append(sources, loc)
603+ }
604+ }
605+ if !args.NoExt || len(args.Exts) > 0 {
606+ extSources, _ := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt)
607+ sources = append(extSources, sources...)
608+ }
609+ discovered, _ = skills.DiscoverSources(sources, true)
599 if len(discovered) > 0 {610 if len(discovered) > 0 {
600 skillTool = skills.NewTool(discovered)611 skillTool = skills.NewTool(discovered)
601 reg[skillTool.Name()] = skillTool612 reg[skillTool.Name()] = skillTool
M packages/agent/cli.go +9 -1
@@ -1405,7 +1405,15 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14051405 // model still sees them through the system-prompt
14061406 // manifest and the skill tool.
14071407 userHome, _ := os.UserHomeDir()
1408- list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
1408+ var sources []skills.Source
1409+ if args.WithSkills {
1410+ sources = append(sources, skills.SearchSources(ZotHome(), r.CWD, userHome)...)
1411+ }
1412+ if !args.NoExt || len(args.Exts) > 0 {
1413+ extSources, _ := extensions.PlanSkillSources(ZotHome(), r.CWD, args.Exts, !args.NoExt)
1414+ sources = append(extSources, sources...)
1415+ }
1416+ list, _ := skills.DiscoverSources(sources, true)
14091417 return skills.VisibleSkills(list)
14101418 },
14111419 NoYolo: args.NoYolo,
@@ -1405,7 +1405,15 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1405 // model still sees them through the system-prompt1405 // model still sees them through the system-prompt
1406 // manifest and the skill tool.1406 // manifest and the skill tool.
1407 userHome, _ := os.UserHomeDir()1407 userHome, _ := os.UserHomeDir()
1408- list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)1408+ var sources []skills.Source
1409+ if args.WithSkills {
1410+ sources = append(sources, skills.SearchSources(ZotHome(), r.CWD, userHome)...)
1411+ }
1412+ if !args.NoExt || len(args.Exts) > 0 {
1413+ extSources, _ := extensions.PlanSkillSources(ZotHome(), r.CWD, args.Exts, !args.NoExt)
1414+ sources = append(extSources, sources...)
1415+ }
1416+ list, _ := skills.DiscoverSources(sources, true)
1409 return skills.VisibleSkills(list)1417 return skills.VisibleSkills(list)
1410 },1418 },
1411 NoYolo: args.NoYolo,1419 NoYolo: args.NoYolo,
M packages/agent/extensions/manager.go +114 -9
@@ -29,6 +29,7 @@ import (
2929 "time"
3030
3131 "github.com/patriceckhart/zot/packages/agent/extproto"
32+ "github.com/patriceckhart/zot/packages/agent/skills"
3233 "github.com/patriceckhart/zot/packages/tui"
3334 )
3435
@@ -43,6 +44,7 @@ type Manifest struct {
4344 Language string `json:"language,omitempty"` // informational ("go", "python", "typescript", ...)
4445 Enabled *bool `json:"enabled,omitempty"` // nil = enabled
4546 Description string `json:"description,omitempty"`
47+ Skills []string `json:"skills,omitempty"`
4648 }
4749
4850 // IsEnabled returns the manifest's effective enabled state. Default
@@ -248,24 +250,127 @@ func (m *Manager) searchDirs() []string {
248250 return dirs
249251 }
250252
251-// loadOne reads a single extension's manifest and, if enabled,
252-// spawns its subprocess + completes the hello handshake.
253-func (m *Manager) loadOne(ctx context.Context, dir string) error {
254- manifestPath := filepath.Join(dir, "extension.json")
255- raw, err := os.ReadFile(manifestPath)
253+// PlanSkillSources reads extension manifests without starting processes and
254+// returns their filesystem-backed skill roots in precedence order. Explicit
255+// extensions win over project and global installations; project wins global.
256+func PlanSkillSources(zotHome, cwd string, explicit []string, includeImplicit bool) ([]skills.Source, []error) {
257+ var dirs []string
258+ for _, p := range explicit {
259+ abs, err := filepath.Abs(p)
260+ if err != nil {
261+ continue
262+ }
263+ dirs = append(dirs, abs)
264+ }
265+ if includeImplicit {
266+ if cwd != "" {
267+ dirs = append(dirs, filepath.Join(cwd, ".zot", "extensions"))
268+ }
269+ if zotHome != "" {
270+ dirs = append(dirs, filepath.Join(zotHome, "extensions"))
271+ }
272+ }
273+ seen := map[string]bool{}
274+ var out []skills.Source
275+ var errs []error
276+ for _, root := range dirs {
277+ entries, err := os.ReadDir(root)
278+ if err != nil {
279+ if os.IsNotExist(err) {
280+ continue
281+ }
282+ errs = append(errs, fmt.Errorf("%s: %w", root, err))
283+ continue
284+ }
285+ candidates := make([]string, 0, len(entries))
286+ for _, e := range entries {
287+ if e.IsDir() {
288+ candidates = append(candidates, filepath.Join(root, e.Name()))
289+ }
290+ }
291+ // An explicit --ext points at the extension itself, not its parent.
292+ if filepath.Base(root) != "extensions" && filepath.Base(root) != ".zot" {
293+ candidates = []string{root}
294+ }
295+ for _, dir := range candidates {
296+ mf, err := readManifest(dir)
297+ if err != nil {
298+ if !os.IsNotExist(err) {
299+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
300+ }
301+ continue
302+ }
303+ if mf.Name == "" {
304+ errs = append(errs, fmt.Errorf("%s: manifest: name is required", dir))
305+ continue
306+ }
307+ if seen[mf.Name] || !mf.IsEnabled() {
308+ continue
309+ }
310+ hasTheme := HasExtensionTheme(dir)
311+ if mf.Exec == "" && !hasTheme && len(mf.Skills) == 0 {
312+ errs = append(errs, fmt.Errorf("%s: manifest: exec, theme, or skills is required", dir))
313+ continue
314+ }
315+ seen[mf.Name] = true
316+ for _, entry := range mf.Skills {
317+ path, err := skillEntryPath(dir, entry)
318+ if err != nil {
319+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
320+ continue
321+ }
322+ label := "extension " + mf.Name
323+ out = append(out, skills.Source{Root: path, Label: label, Prefix: skills.KebabCase(mf.Name) + ":"})
324+ }
325+ }
326+ }
327+ return out, errs
328+}
329+
330+func readManifest(dir string) (Manifest, error) {
331+ raw, err := os.ReadFile(filepath.Join(dir, "extension.json"))
256332 if err != nil {
257- return fmt.Errorf("read manifest: %w", err)
333+ return Manifest{}, err
258334 }
259335 var mf Manifest
260336 if err := json.Unmarshal(raw, &mf); err != nil {
261- return fmt.Errorf("parse manifest: %w", err)
337+ return Manifest{}, fmt.Errorf("parse manifest: %w", err)
338+ }
339+ return mf, nil
340+}
341+
342+func skillEntryPath(dir, entry string) (string, error) {
343+ if strings.TrimSpace(entry) == "" {
344+ return "", errors.New("manifest: empty skill path")
345+ }
346+ path := filepath.Clean(filepath.Join(dir, entry))
347+ rel, err := filepath.Rel(dir, path)
348+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
349+ return "", fmt.Errorf("manifest: skill path %q escapes extension directory", entry)
350+ }
351+ info, err := os.Stat(path)
352+ if err != nil {
353+ return "", fmt.Errorf("manifest: skill path %q: %w", entry, err)
354+ }
355+ if !info.IsDir() && filepath.Base(path) != "SKILL.md" {
356+ return "", fmt.Errorf("manifest: skill path %q is not a directory or SKILL.md", entry)
357+ }
358+ return path, nil
359+}
360+
361+// loadOne reads a single extension's manifest and, if enabled,
362+// spawns its subprocess + completes the hello handshake.
363+func (m *Manager) loadOne(ctx context.Context, dir string) error {
364+ mf, err := readManifest(dir)
365+ if err != nil {
366+ return err
262367 }
263368 if mf.Name == "" {
264369 return errors.New("manifest: name is required")
265370 }
266371 hasTheme := HasExtensionTheme(dir)
267- if mf.Exec == "" && !hasTheme {
268- return errors.New("manifest: exec is required")
372+ if mf.Exec == "" && !hasTheme && len(mf.Skills) == 0 {
373+ return errors.New("manifest: exec, theme, or skills is required")
269374 }
270375 if !mf.IsEnabled() {
271376 // Quietly skip disabled extensions; zot ext list will show them.
@@ -29,6 +29,7 @@ import (
29 "time"29 "time"
30 30
31 "github.com/patriceckhart/zot/packages/agent/extproto"31 "github.com/patriceckhart/zot/packages/agent/extproto"
32+ "github.com/patriceckhart/zot/packages/agent/skills"
32 "github.com/patriceckhart/zot/packages/tui"33 "github.com/patriceckhart/zot/packages/tui"
33 )34 )
34 35
@@ -43,6 +44,7 @@ type Manifest struct {
43 Language string `json:"language,omitempty"` // informational ("go", "python", "typescript", ...)44 Language string `json:"language,omitempty"` // informational ("go", "python", "typescript", ...)
44 Enabled *bool `json:"enabled,omitempty"` // nil = enabled45 Enabled *bool `json:"enabled,omitempty"` // nil = enabled
45 Description string `json:"description,omitempty"`46 Description string `json:"description,omitempty"`
47+ Skills []string `json:"skills,omitempty"`
46 }48 }
47 49
48 // IsEnabled returns the manifest's effective enabled state. Default50 // IsEnabled returns the manifest's effective enabled state. Default
@@ -248,24 +250,127 @@ func (m *Manager) searchDirs() []string {
248 return dirs250 return dirs
249 }251 }
250 252
251-// loadOne reads a single extension's manifest and, if enabled,253+// PlanSkillSources reads extension manifests without starting processes and
252-// spawns its subprocess + completes the hello handshake.254+// returns their filesystem-backed skill roots in precedence order. Explicit
253-func (m *Manager) loadOne(ctx context.Context, dir string) error {255+// extensions win over project and global installations; project wins global.
254- manifestPath := filepath.Join(dir, "extension.json")256+func PlanSkillSources(zotHome, cwd string, explicit []string, includeImplicit bool) ([]skills.Source, []error) {
255- raw, err := os.ReadFile(manifestPath)257+ var dirs []string
258+ for _, p := range explicit {
259+ abs, err := filepath.Abs(p)
260+ if err != nil {
261+ continue
262+ }
263+ dirs = append(dirs, abs)
264+ }
265+ if includeImplicit {
266+ if cwd != "" {
267+ dirs = append(dirs, filepath.Join(cwd, ".zot", "extensions"))
268+ }
269+ if zotHome != "" {
270+ dirs = append(dirs, filepath.Join(zotHome, "extensions"))
271+ }
272+ }
273+ seen := map[string]bool{}
274+ var out []skills.Source
275+ var errs []error
276+ for _, root := range dirs {
277+ entries, err := os.ReadDir(root)
278+ if err != nil {
279+ if os.IsNotExist(err) {
280+ continue
281+ }
282+ errs = append(errs, fmt.Errorf("%s: %w", root, err))
283+ continue
284+ }
285+ candidates := make([]string, 0, len(entries))
286+ for _, e := range entries {
287+ if e.IsDir() {
288+ candidates = append(candidates, filepath.Join(root, e.Name()))
289+ }
290+ }
291+ // An explicit --ext points at the extension itself, not its parent.
292+ if filepath.Base(root) != "extensions" && filepath.Base(root) != ".zot" {
293+ candidates = []string{root}
294+ }
295+ for _, dir := range candidates {
296+ mf, err := readManifest(dir)
297+ if err != nil {
298+ if !os.IsNotExist(err) {
299+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
300+ }
301+ continue
302+ }
303+ if mf.Name == "" {
304+ errs = append(errs, fmt.Errorf("%s: manifest: name is required", dir))
305+ continue
306+ }
307+ if seen[mf.Name] || !mf.IsEnabled() {
308+ continue
309+ }
310+ hasTheme := HasExtensionTheme(dir)
311+ if mf.Exec == "" && !hasTheme && len(mf.Skills) == 0 {
312+ errs = append(errs, fmt.Errorf("%s: manifest: exec, theme, or skills is required", dir))
313+ continue
314+ }
315+ seen[mf.Name] = true
316+ for _, entry := range mf.Skills {
317+ path, err := skillEntryPath(dir, entry)
318+ if err != nil {
319+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
320+ continue
321+ }
322+ label := "extension " + mf.Name
323+ out = append(out, skills.Source{Root: path, Label: label, Prefix: skills.KebabCase(mf.Name) + ":"})
324+ }
325+ }
326+ }
327+ return out, errs
328+}
329+
330+func readManifest(dir string) (Manifest, error) {
331+ raw, err := os.ReadFile(filepath.Join(dir, "extension.json"))
256 if err != nil {332 if err != nil {
257- return fmt.Errorf("read manifest: %w", err)333+ return Manifest{}, err
258 }334 }
259 var mf Manifest335 var mf Manifest
260 if err := json.Unmarshal(raw, &mf); err != nil {336 if err := json.Unmarshal(raw, &mf); err != nil {
261- return fmt.Errorf("parse manifest: %w", err)337+ return Manifest{}, fmt.Errorf("parse manifest: %w", err)
338+ }
339+ return mf, nil
340+}
341+
342+func skillEntryPath(dir, entry string) (string, error) {
343+ if strings.TrimSpace(entry) == "" {
344+ return "", errors.New("manifest: empty skill path")
345+ }
346+ path := filepath.Clean(filepath.Join(dir, entry))
347+ rel, err := filepath.Rel(dir, path)
348+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
349+ return "", fmt.Errorf("manifest: skill path %q escapes extension directory", entry)
350+ }
351+ info, err := os.Stat(path)
352+ if err != nil {
353+ return "", fmt.Errorf("manifest: skill path %q: %w", entry, err)
354+ }
355+ if !info.IsDir() && filepath.Base(path) != "SKILL.md" {
356+ return "", fmt.Errorf("manifest: skill path %q is not a directory or SKILL.md", entry)
357+ }
358+ return path, nil
359+}
360+
361+// loadOne reads a single extension's manifest and, if enabled,
362+// spawns its subprocess + completes the hello handshake.
363+func (m *Manager) loadOne(ctx context.Context, dir string) error {
364+ mf, err := readManifest(dir)
365+ if err != nil {
366+ return err
262 }367 }
263 if mf.Name == "" {368 if mf.Name == "" {
264 return errors.New("manifest: name is required")369 return errors.New("manifest: name is required")
265 }370 }
266 hasTheme := HasExtensionTheme(dir)371 hasTheme := HasExtensionTheme(dir)
267- if mf.Exec == "" && !hasTheme {372+ if mf.Exec == "" && !hasTheme && len(mf.Skills) == 0 {
268- return errors.New("manifest: exec is required")373+ return errors.New("manifest: exec, theme, or skills is required")
269 }374 }
270 if !mf.IsEnabled() {375 if !mf.IsEnabled() {
271 // Quietly skip disabled extensions; zot ext list will show them.376 // Quietly skip disabled extensions; zot ext list will show them.
M packages/agent/extensions/manager_test.go +40 -0
@@ -6,6 +6,7 @@ import (
66 "os"
77
88 "github.com/patriceckhart/zot/packages/agent/extproto"
9+ "github.com/patriceckhart/zot/packages/agent/skills"
910 "path/filepath"
1011 "runtime"
1112 "strings"
@@ -218,6 +219,45 @@ func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
218219 }
219220 }
220221
222+func TestPlanSkillSources(t *testing.T) {
223+ tmp := t.TempDir()
224+ extDir := filepath.Join(tmp, "bundle")
225+ skillDir := filepath.Join(extDir, "skills", "Writing Git Commits")
226+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
227+ t.Fatal(err)
228+ }
229+ if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: commits\n---\nbody"), 0o644); err != nil {
230+ t.Fatal(err)
231+ }
232+ manifest := `{"name":"Git Tools","skills":["./skills"],"enabled":true}`
233+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
234+ t.Fatal(err)
235+ }
236+ sources, errs := PlanSkillSources("", "", []string{extDir}, false)
237+ if len(errs) != 0 || len(sources) != 1 {
238+ t.Fatalf("sources=%#v errs=%v", sources, errs)
239+ }
240+ got, errs := skills.DiscoverSources(sources, false)
241+ if len(errs) != 0 || len(got) != 1 || got[0].Name != "git-tools:writing-git-commits" {
242+ t.Fatalf("skills=%#v errs=%v", got, errs)
243+ }
244+}
245+
246+func TestPlanSkillSourcesRejectsTraversal(t *testing.T) {
247+ tmp := t.TempDir()
248+ extDir := filepath.Join(tmp, "bundle")
249+ if err := os.MkdirAll(extDir, 0o755); err != nil {
250+ t.Fatal(err)
251+ }
252+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"bad","skills":["../outside"]}`), 0o644); err != nil {
253+ t.Fatal(err)
254+ }
255+ _, errs := PlanSkillSources("", "", []string{extDir}, false)
256+ if len(errs) == 0 || !strings.Contains(errs[0].Error(), "escapes") {
257+ t.Fatalf("errs=%v", errs)
258+ }
259+}
260+
221261 func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
222262 tmp := t.TempDir()
223263 extDir := filepath.Join(tmp, "extensions", "theme-only")
@@ -6,6 +6,7 @@ import (
6 "os"6 "os"
7 7
8 "github.com/patriceckhart/zot/packages/agent/extproto"8 "github.com/patriceckhart/zot/packages/agent/extproto"
9+ "github.com/patriceckhart/zot/packages/agent/skills"
9 "path/filepath"10 "path/filepath"
10 "runtime"11 "runtime"
11 "strings"12 "strings"
@@ -218,6 +219,45 @@ func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
218 }219 }
219 }220 }
220 221
222+func TestPlanSkillSources(t *testing.T) {
223+ tmp := t.TempDir()
224+ extDir := filepath.Join(tmp, "bundle")
225+ skillDir := filepath.Join(extDir, "skills", "Writing Git Commits")
226+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
227+ t.Fatal(err)
228+ }
229+ if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: commits\n---\nbody"), 0o644); err != nil {
230+ t.Fatal(err)
231+ }
232+ manifest := `{"name":"Git Tools","skills":["./skills"],"enabled":true}`
233+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
234+ t.Fatal(err)
235+ }
236+ sources, errs := PlanSkillSources("", "", []string{extDir}, false)
237+ if len(errs) != 0 || len(sources) != 1 {
238+ t.Fatalf("sources=%#v errs=%v", sources, errs)
239+ }
240+ got, errs := skills.DiscoverSources(sources, false)
241+ if len(errs) != 0 || len(got) != 1 || got[0].Name != "git-tools:writing-git-commits" {
242+ t.Fatalf("skills=%#v errs=%v", got, errs)
243+ }
244+}
245+
246+func TestPlanSkillSourcesRejectsTraversal(t *testing.T) {
247+ tmp := t.TempDir()
248+ extDir := filepath.Join(tmp, "bundle")
249+ if err := os.MkdirAll(extDir, 0o755); err != nil {
250+ t.Fatal(err)
251+ }
252+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"bad","skills":["../outside"]}`), 0o644); err != nil {
253+ t.Fatal(err)
254+ }
255+ _, errs := PlanSkillSources("", "", []string{extDir}, false)
256+ if len(errs) == 0 || !strings.Contains(errs[0].Error(), "escapes") {
257+ t.Fatalf("errs=%v", errs)
258+ }
259+}
260+
221 func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {261 func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
222 tmp := t.TempDir()262 tmp := t.TempDir()
223 extDir := filepath.Join(tmp, "extensions", "theme-only")263 extDir := filepath.Join(tmp, "extensions", "theme-only")
M packages/agent/skills/skills.go +145 -8
@@ -32,6 +32,14 @@ import (
3232 )
3333
3434 // Skill is one discovered SKILL.md file.
35+// Source describes one directory of skills. Sources are evaluated in order;
36+// the first skill with a canonical name wins.
37+type Source struct {
38+ Root string
39+ Label string
40+ Prefix string
41+}
42+
3543 type Skill struct {
3644 // Name is the skill identifier — what the model uses when it
3745 // invokes the `skill` tool. Taken from the frontmatter `name`
@@ -102,18 +110,48 @@ func VisibleSkills(in []*Skill) []*Skill {
102110 // Errors per skill are returned alongside the partial result so a
103111 // single broken file doesn't suppress the rest.
104112 func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) {
113+ var sources []Source
114+ if includeUser {
115+ for _, loc := range searchDirs(zotHome, cwd, userHome) {
116+ sources = append(sources, Source{Root: loc.dir, Label: loc.label})
117+ }
118+ }
119+ discovered, errs := DiscoverSources(sources, includeUser)
120+ // Preserve the historical Discover contract: duplicate ordinary skills
121+ // were silently ignored. New callers using DiscoverSources receive the
122+ // richer shadowing diagnostics.
123+ filtered := errs[:0]
124+ for _, err := range errs {
125+ if !strings.Contains(err.Error(), " shadowed: ") {
126+ filtered = append(filtered, err)
127+ }
128+ }
129+ return discovered, filtered
130+}
131+
132+// DiscoverSources recursively discovers SKILL.md files from sources in
133+// precedence order, then appends built-ins when includeUser is true or false.
134+// It returns non-fatal diagnostics for malformed files and shadowed names.
135+func DiscoverSources(sources []Source, includeBuiltins bool) ([]*Skill, []error) {
105136 var errs []error
106137 seen := map[string]*Skill{}
107- if includeUser {
108- errs = append(errs, scanUserSkills(zotHome, cwd, userHome, seen)...)
138+ for _, source := range sources {
139+ found, scanErrs := scanSource(source)
140+ errs = append(errs, scanErrs...)
141+ for _, s := range found {
142+ if prior, dup := seen[s.Name]; dup {
143+ errs = append(errs, fmt.Errorf("skill %q shadowed: selected %s; ignored %s", s.Name, prior.Path, s.Path))
144+ continue
145+ }
146+ seen[s.Name] = s
147+ }
109148 }
110- // Built-ins fill in any name the user didn't already provide
111- // (or every name, when includeUser is false).
112- for _, s := range loadBuiltins() {
113- if _, dup := seen[s.Name]; dup {
114- continue
149+ if includeBuiltins {
150+ for _, s := range loadBuiltins() {
151+ if _, dup := seen[s.Name]; !dup {
152+ seen[s.Name] = s
153+ }
115154 }
116- seen[s.Name] = s
117155 }
118156 out := make([]*Skill, 0, len(seen))
119157 for _, s := range seen {
@@ -123,6 +161,96 @@ func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []erro
123161 return out, errs
124162 }
125163
164+func scanSource(source Source) ([]*Skill, []error) {
165+ var out []*Skill
166+ var errs []error
167+ info, err := os.Stat(source.Root)
168+ if err != nil {
169+ if !os.IsNotExist(err) {
170+ errs = append(errs, fmt.Errorf("%s: %w", source.Root, err))
171+ }
172+ return out, errs
173+ }
174+ if !info.IsDir() {
175+ if filepath.Base(source.Root) != "SKILL.md" {
176+ return out, errs
177+ }
178+ s, e := load(source.Root, source.Label)
179+ if e != nil {
180+ return out, []error{fmt.Errorf("%s: %w", source.Root, e)}
181+ }
182+ if s.Name == "" {
183+ s.Name = kebabCase(strings.TrimSuffix(filepath.Base(filepath.Dir(source.Root)), ""))
184+ }
185+ if source.Prefix != "" {
186+ s.Name = source.Prefix + s.Name
187+ }
188+ return []*Skill{s}, nil
189+ }
190+ err = filepath.WalkDir(source.Root, func(path string, d os.DirEntry, walkErr error) error {
191+ if walkErr != nil {
192+ errs = append(errs, fmt.Errorf("%s: %w", path, walkErr))
193+ return nil
194+ }
195+ if d.IsDir() || d.Name() != "SKILL.md" {
196+ return nil
197+ }
198+ s, e := load(path, source.Label)
199+ if e != nil {
200+ errs = append(errs, fmt.Errorf("%s: %w", path, e))
201+ return nil
202+ }
203+ if s.Name == "" {
204+ rel, e := filepath.Rel(source.Root, filepath.Dir(path))
205+ if e != nil {
206+ errs = append(errs, e)
207+ return nil
208+ }
209+ parts := []string{}
210+ if rel != "." {
211+ for _, p := range strings.Split(filepath.ToSlash(rel), "/") {
212+ if k := kebabCase(p); k != "" {
213+ parts = append(parts, k)
214+ }
215+ }
216+ }
217+ if len(parts) == 0 {
218+ parts = append(parts, kebabCase(filepath.Base(filepath.Dir(path))))
219+ }
220+ s.Name = strings.Join(parts, "-")
221+ }
222+ if source.Prefix != "" {
223+ s.Name = source.Prefix + s.Name
224+ }
225+ out = append(out, s)
226+ return nil
227+ })
228+ if err != nil {
229+ errs = append(errs, fmt.Errorf("scan %s: %w", source.Root, err))
230+ }
231+ return out, errs
232+}
233+
234+// KebabCase converts a path component or display name to a stable skill name.
235+func KebabCase(s string) string { return kebabCase(s) }
236+
237+func kebabCase(s string) string {
238+ var b strings.Builder
239+ sep := false
240+ for _, r := range strings.ToLower(s) {
241+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
242+ if sep && b.Len() > 0 {
243+ b.WriteByte('-')
244+ }
245+ b.WriteRune(r)
246+ sep = false
247+ } else {
248+ sep = true
249+ }
250+ }
251+ return strings.Trim(b.String(), "-")
252+}
253+
126254 // scanUserSkills walks the user-skill search dirs and populates
127255 // `seen` with first-match-wins per name. Split out so Discover's
128256 // includeUser=false path doesn't have to skip over a giant block.
@@ -229,6 +357,15 @@ type location struct {
229357 label string
230358 }
231359
360+// SearchSources returns the ordinary skill roots in precedence order.
361+func SearchSources(zotHome, cwd, userHome string) []Source {
362+ out := make([]Source, 0)
363+ for _, loc := range searchDirs(zotHome, cwd, userHome) {
364+ out = append(out, Source{Root: loc.dir, Label: loc.label})
365+ }
366+ return out
367+}
368+
232369 func searchDirs(zotHome, cwd, userHome string) []location {
233370 var out []location
234371 add := func(dir, label string) {
@@ -32,6 +32,14 @@ import (
32 )32 )
33 33
34 // Skill is one discovered SKILL.md file.34 // Skill is one discovered SKILL.md file.
35+// Source describes one directory of skills. Sources are evaluated in order;
36+// the first skill with a canonical name wins.
37+type Source struct {
38+ Root string
39+ Label string
40+ Prefix string
41+}
42+
35 type Skill struct {43 type Skill struct {
36 // Name is the skill identifier — what the model uses when it44 // Name is the skill identifier — what the model uses when it
37 // invokes the `skill` tool. Taken from the frontmatter `name`45 // invokes the `skill` tool. Taken from the frontmatter `name`
@@ -102,18 +110,48 @@ func VisibleSkills(in []*Skill) []*Skill {
102 // Errors per skill are returned alongside the partial result so a110 // Errors per skill are returned alongside the partial result so a
103 // single broken file doesn't suppress the rest.111 // single broken file doesn't suppress the rest.
104 func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) {112 func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) {
113+ var sources []Source
114+ if includeUser {
115+ for _, loc := range searchDirs(zotHome, cwd, userHome) {
116+ sources = append(sources, Source{Root: loc.dir, Label: loc.label})
117+ }
118+ }
119+ discovered, errs := DiscoverSources(sources, includeUser)
120+ // Preserve the historical Discover contract: duplicate ordinary skills
121+ // were silently ignored. New callers using DiscoverSources receive the
122+ // richer shadowing diagnostics.
123+ filtered := errs[:0]
124+ for _, err := range errs {
125+ if !strings.Contains(err.Error(), " shadowed: ") {
126+ filtered = append(filtered, err)
127+ }
128+ }
129+ return discovered, filtered
130+}
131+
132+// DiscoverSources recursively discovers SKILL.md files from sources in
133+// precedence order, then appends built-ins when includeUser is true or false.
134+// It returns non-fatal diagnostics for malformed files and shadowed names.
135+func DiscoverSources(sources []Source, includeBuiltins bool) ([]*Skill, []error) {
105 var errs []error136 var errs []error
106 seen := map[string]*Skill{}137 seen := map[string]*Skill{}
107- if includeUser {138+ for _, source := range sources {
108- errs = append(errs, scanUserSkills(zotHome, cwd, userHome, seen)...)139+ found, scanErrs := scanSource(source)
140+ errs = append(errs, scanErrs...)
141+ for _, s := range found {
142+ if prior, dup := seen[s.Name]; dup {
143+ errs = append(errs, fmt.Errorf("skill %q shadowed: selected %s; ignored %s", s.Name, prior.Path, s.Path))
144+ continue
145+ }
146+ seen[s.Name] = s
147+ }
109 }148 }
110- // Built-ins fill in any name the user didn't already provide149+ if includeBuiltins {
111- // (or every name, when includeUser is false).150+ for _, s := range loadBuiltins() {
112- for _, s := range loadBuiltins() {151+ if _, dup := seen[s.Name]; !dup {
113- if _, dup := seen[s.Name]; dup {152+ seen[s.Name] = s
114- continue153+ }
115 }154 }
116- seen[s.Name] = s
117 }155 }
118 out := make([]*Skill, 0, len(seen))156 out := make([]*Skill, 0, len(seen))
119 for _, s := range seen {157 for _, s := range seen {
@@ -123,6 +161,96 @@ func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []erro
123 return out, errs161 return out, errs
124 }162 }
125 163
164+func scanSource(source Source) ([]*Skill, []error) {
165+ var out []*Skill
166+ var errs []error
167+ info, err := os.Stat(source.Root)
168+ if err != nil {
169+ if !os.IsNotExist(err) {
170+ errs = append(errs, fmt.Errorf("%s: %w", source.Root, err))
171+ }
172+ return out, errs
173+ }
174+ if !info.IsDir() {
175+ if filepath.Base(source.Root) != "SKILL.md" {
176+ return out, errs
177+ }
178+ s, e := load(source.Root, source.Label)
179+ if e != nil {
180+ return out, []error{fmt.Errorf("%s: %w", source.Root, e)}
181+ }
182+ if s.Name == "" {
183+ s.Name = kebabCase(strings.TrimSuffix(filepath.Base(filepath.Dir(source.Root)), ""))
184+ }
185+ if source.Prefix != "" {
186+ s.Name = source.Prefix + s.Name
187+ }
188+ return []*Skill{s}, nil
189+ }
190+ err = filepath.WalkDir(source.Root, func(path string, d os.DirEntry, walkErr error) error {
191+ if walkErr != nil {
192+ errs = append(errs, fmt.Errorf("%s: %w", path, walkErr))
193+ return nil
194+ }
195+ if d.IsDir() || d.Name() != "SKILL.md" {
196+ return nil
197+ }
198+ s, e := load(path, source.Label)
199+ if e != nil {
200+ errs = append(errs, fmt.Errorf("%s: %w", path, e))
201+ return nil
202+ }
203+ if s.Name == "" {
204+ rel, e := filepath.Rel(source.Root, filepath.Dir(path))
205+ if e != nil {
206+ errs = append(errs, e)
207+ return nil
208+ }
209+ parts := []string{}
210+ if rel != "." {
211+ for _, p := range strings.Split(filepath.ToSlash(rel), "/") {
212+ if k := kebabCase(p); k != "" {
213+ parts = append(parts, k)
214+ }
215+ }
216+ }
217+ if len(parts) == 0 {
218+ parts = append(parts, kebabCase(filepath.Base(filepath.Dir(path))))
219+ }
220+ s.Name = strings.Join(parts, "-")
221+ }
222+ if source.Prefix != "" {
223+ s.Name = source.Prefix + s.Name
224+ }
225+ out = append(out, s)
226+ return nil
227+ })
228+ if err != nil {
229+ errs = append(errs, fmt.Errorf("scan %s: %w", source.Root, err))
230+ }
231+ return out, errs
232+}
233+
234+// KebabCase converts a path component or display name to a stable skill name.
235+func KebabCase(s string) string { return kebabCase(s) }
236+
237+func kebabCase(s string) string {
238+ var b strings.Builder
239+ sep := false
240+ for _, r := range strings.ToLower(s) {
241+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
242+ if sep && b.Len() > 0 {
243+ b.WriteByte('-')
244+ }
245+ b.WriteRune(r)
246+ sep = false
247+ } else {
248+ sep = true
249+ }
250+ }
251+ return strings.Trim(b.String(), "-")
252+}
253+
126 // scanUserSkills walks the user-skill search dirs and populates254 // scanUserSkills walks the user-skill search dirs and populates
127 // `seen` with first-match-wins per name. Split out so Discover's255 // `seen` with first-match-wins per name. Split out so Discover's
128 // includeUser=false path doesn't have to skip over a giant block.256 // includeUser=false path doesn't have to skip over a giant block.
@@ -229,6 +357,15 @@ type location struct {
229 label string357 label string
230 }358 }
231 359
360+// SearchSources returns the ordinary skill roots in precedence order.
361+func SearchSources(zotHome, cwd, userHome string) []Source {
362+ out := make([]Source, 0)
363+ for _, loc := range searchDirs(zotHome, cwd, userHome) {
364+ out = append(out, Source{Root: loc.dir, Label: loc.label})
365+ }
366+ return out
367+}
368+
232 func searchDirs(zotHome, cwd, userHome string) []location {369 func searchDirs(zotHome, cwd, userHome string) []location {
233 var out []location370 var out []location
234 add := func(dir, label string) {371 add := func(dir, label string) {
M packages/agent/skills/skills_test.go +55 -0
@@ -3,6 +3,7 @@ package skills
33 import (
44 "os"
55 "path/filepath"
6+ "strings"
67 "testing"
78 )
89
@@ -94,6 +95,60 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
9495 }
9596 }
9697
98+func TestKebabCase(t *testing.T) {
99+ cases := map[string]string{
100+ "writing-git-commits": "writing-git-commits",
101+ "Writing Git Commits": "writing-git-commits",
102+ "git_commits": "git-commits",
103+ "git.commits": "git-commits",
104+ "Git/Commits": "git-commits",
105+ }
106+ for in, want := range cases {
107+ if got := KebabCase(in); got != want {
108+ t.Errorf("KebabCase(%q) = %q, want %q", in, got, want)
109+ }
110+ }
111+}
112+
113+func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {
114+ root := t.TempDir()
115+ path := filepath.Join(root, "Something", "Writing Git Commits")
116+ if err := os.MkdirAll(path, 0o755); err != nil {
117+ t.Fatal(err)
118+ }
119+ body := "---\ndescription: nested\n---\nbody"
120+ if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte(body), 0o644); err != nil {
121+ t.Fatal(err)
122+ }
123+ got, errs := DiscoverSources([]Source{{Root: root, Label: "extension Git Tools", Prefix: "git-tools:"}}, false)
124+ if len(errs) != 0 {
125+ t.Fatalf("errs: %v", errs)
126+ }
127+ if len(got) != 1 || got[0].Name != "git-tools:something-writing-git-commits" {
128+ t.Fatalf("skills = %#v", got)
129+ }
130+ if got[0].Source != "extension Git Tools" || got[0].Body != "body" {
131+ t.Errorf("skill metadata = %#v", got[0])
132+ }
133+}
134+
135+func TestDiscoverSourcesDuplicateDiagnostic(t *testing.T) {
136+ a, b := t.TempDir(), t.TempDir()
137+ for _, root := range []string{a, b} {
138+ dir := filepath.Join(root, "review")
139+ if err := os.MkdirAll(dir, 0o755); err != nil {
140+ t.Fatal(err)
141+ }
142+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
143+ t.Fatal(err)
144+ }
145+ }
146+ got, errs := DiscoverSources([]Source{{Root: a}, {Root: b}}, false)
147+ if len(got) != 1 || len(errs) != 1 || !strings.Contains(errs[0].Error(), "shadowed") {
148+ t.Fatalf("got=%#v errs=%v", got, errs)
149+ }
150+}
151+
97152 func TestVisibleSkillsHidesBuiltins(t *testing.T) {
98153 in := []*Skill{
99154 {Name: "user-one"},
@@ -3,6 +3,7 @@ package skills
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6+ "strings"
6 "testing"7 "testing"
7 )8 )
8 9
@@ -94,6 +95,60 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
94 }95 }
95 }96 }
96 97
98+func TestKebabCase(t *testing.T) {
99+ cases := map[string]string{
100+ "writing-git-commits": "writing-git-commits",
101+ "Writing Git Commits": "writing-git-commits",
102+ "git_commits": "git-commits",
103+ "git.commits": "git-commits",
104+ "Git/Commits": "git-commits",
105+ }
106+ for in, want := range cases {
107+ if got := KebabCase(in); got != want {
108+ t.Errorf("KebabCase(%q) = %q, want %q", in, got, want)
109+ }
110+ }
111+}
112+
113+func TestDiscoverSourcesRecursiveAndPrefixed(t *testing.T) {
114+ root := t.TempDir()
115+ path := filepath.Join(root, "Something", "Writing Git Commits")
116+ if err := os.MkdirAll(path, 0o755); err != nil {
117+ t.Fatal(err)
118+ }
119+ body := "---\ndescription: nested\n---\nbody"
120+ if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte(body), 0o644); err != nil {
121+ t.Fatal(err)
122+ }
123+ got, errs := DiscoverSources([]Source{{Root: root, Label: "extension Git Tools", Prefix: "git-tools:"}}, false)
124+ if len(errs) != 0 {
125+ t.Fatalf("errs: %v", errs)
126+ }
127+ if len(got) != 1 || got[0].Name != "git-tools:something-writing-git-commits" {
128+ t.Fatalf("skills = %#v", got)
129+ }
130+ if got[0].Source != "extension Git Tools" || got[0].Body != "body" {
131+ t.Errorf("skill metadata = %#v", got[0])
132+ }
133+}
134+
135+func TestDiscoverSourcesDuplicateDiagnostic(t *testing.T) {
136+ a, b := t.TempDir(), t.TempDir()
137+ for _, root := range []string{a, b} {
138+ dir := filepath.Join(root, "review")
139+ if err := os.MkdirAll(dir, 0o755); err != nil {
140+ t.Fatal(err)
141+ }
142+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\n---\nbody"), 0o644); err != nil {
143+ t.Fatal(err)
144+ }
145+ }
146+ got, errs := DiscoverSources([]Source{{Root: a}, {Root: b}}, false)
147+ if len(got) != 1 || len(errs) != 1 || !strings.Contains(errs[0].Error(), "shadowed") {
148+ t.Fatalf("got=%#v errs=%v", got, errs)
149+ }
150+}
151+
97 func TestVisibleSkillsHidesBuiltins(t *testing.T) {152 func TestVisibleSkillsHidesBuiltins(t *testing.T) {
98 in := []*Skill{153 in := []*Skill{
99 {Name: "user-one"},154 {Name: "user-one"},

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