detached 222a62c

feat: skills — reusable instructions discovered from SKILL.md files

A skill is a single SKILL.md file with a YAML frontmatter header,
discovered from well-known directories at startup. Two integration
points:

  1. The system prompt gains a short manifest listing each skill's
     name + one-line description. Cheap (a few dozen tokens).
  2. A built-in `skill` tool lets the model load any one skill's
     full body on demand and follow the instructions there.

The on-demand-load model keeps token usage cheap: only the
manifest goes into every request; the body is fetched as a tool
result the one or two turns the model actually needs it.

Discovery (priority order — first match wins per name):
  ./.zot/skills/<name>/SKILL.md            project (native)
  $ZOT_HOME/skills/<name>/SKILL.md         global (native)
  ./.claude/skills/<name>/SKILL.md         project (claude-compat)
  ~/.claude/skills/<name>/SKILL.md         global (claude-compat)
  ./.agents/skills/<name>/SKILL.md         project (agent-compat)
  ~/.agents/skills/<name>/SKILL.md         global (agent-compat)

Compat paths are deliberate: any SKILL.md written for a related
ecosystem works in zot unchanged.

Frontmatter fields:
  name           optional; defaults to directory name
  description    required; shown in the system prompt
  allowed-tools  optional list; informational (no enforcement)
  permissions    optional per-tool patterns; informational

allowed-tools and permissions are parsed but not enforced this
version. They render in the body so the model can self-regulate.

What landed:

- internal/skills: discovery + frontmatter parsing (no yaml dep —
  hand-rolled subset for the limited shape skills use), the on-
  demand `skill` tool implementing core.Tool, system-prompt
  addendum, FindByName lookup helper. Real unit tests cover all
  five locations + dedup priority + parser corner cases.

- internal/agent/build.go: Resolve discovers skills, registers the
  skill tool when at least one was found, appends the manifest to
  the system prompt's append list. Resolved gains a SkillTool
  field so the tui can read the live set.

- internal/agent/modes/skills_dialog.go: /skills picker with two
  modes — list view (cursor + paging) and body view (markdown-
  rendered with scroll). Refreshes its snapshot each open via
  cfg.SkillSnapshot so edits to a SKILL.md during a session are
  reflected immediately.

- /skills slash command + entry in slashCatalog.

- examples/skills/code-review and examples/skills/test-fix as
  starter skills demonstrating procedural style + frontmatter.

- docs/skills.md: full reference covering discovery, frontmatter,
  inspection, authoring tips, and ecosystem compat.

End-to-end verified against the live anthropic backend:

  prompt: "What skills do you have available?"
  -> "- code-review\n- test-fix"

  prompt: "Use the skill tool to load the code-review skill,
           then summarize step 1."
  -> [tool_call] skill({"name":"code-review"})
  -> [tool_result] body returned
  -> "Step 1 is to establish what changed by running git status..."
commit
222a62c70f7176f217dca5434156c1a4243c4c72
author
patriceckhart · · 2026-04-19 12:32 UTC
parent
1c74882
signature
unsigned
12 files · +1047 -2
M README.md +1 -0
@@ -8,6 +8,7 @@ yet another coding agent harness, lightweight and written (vibe-slopped) in go.
88 - three run modes (interactive tui, print, json).
99 - built-in telegram bot.
1010 - extensions in any language via subprocess + json-rpc; see [docs/extensions.md](docs/extensions.md).
11+- reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md).
1112 - no community atm.
1213
1314 ## install
@@ -8,6 +8,7 @@ yet another coding agent harness, lightweight and written (vibe-slopped) in go.
8 - three run modes (interactive tui, print, json).8 - three run modes (interactive tui, print, json).
9 - built-in telegram bot.9 - built-in telegram bot.
10 - extensions in any language via subprocess + json-rpc; see [docs/extensions.md](docs/extensions.md).10 - extensions in any language via subprocess + json-rpc; see [docs/extensions.md](docs/extensions.md).
11+- reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md).
11 - no community atm.12 - no community atm.
12 13
13 ## install14 ## install
A docs/skills.md +118 -0
@@ -0,0 +1,118 @@
1+# zot skills
2+
3+A skill is a reusable instruction set written as a single
4+`SKILL.md` file with a YAML frontmatter header. zot discovers skills
5+at startup and surfaces them to the model in two ways:
6+
7+1. The system prompt gains a short manifest:
8+ `Available skills: ... - code-review — Run a self-review pass...`
9+2. A built-in `skill` tool lets the model load any one skill's full
10+ body on demand.
11+
12+The on-demand-load model keeps token usage cheap: only the manifest
13+goes into every request; the body is fetched as a tool result the
14+one or two turns the model actually needs it.
15+
16+## Anatomy
17+
18+```markdown
19+---
20+name: code-review
21+description: Run a thorough self-review pass on a recent change.
22+allowed-tools: [read, bash]
23+permissions:
24+ bash: ["git diff*", "git log*"]
25+---
26+
27+# Code review
28+
29+When asked to review code, ...
30+```
31+
32+### Frontmatter fields
33+
34+| field | required | purpose |
35+|---|---|---|
36+| `name` | optional | skill identifier; defaults to the directory name |
37+| `description` | required | one-line summary shown in the system prompt |
38+| `allowed-tools` | optional | list of tool names the skill is meant to use; informational |
39+| `permissions` | optional | per-tool patterns; informational |
40+
41+`allowed-tools` and `permissions` are **parsed but not enforced** in
42+this version. They appear in the rendered skill body so the model can
43+see them and self-regulate. Future versions may enforce.
44+
45+The body (everything after the second `---`) is plain markdown.
46+There's no template engine; the model sees what you write.
47+
48+## Discovery
49+
50+zot looks in these directories, in priority order, and registers the
51+first `SKILL.md` it finds for each unique name:
52+
53+| location | scope |
54+|---|---|
55+| `./.zot/skills/<name>/SKILL.md` | project (native) |
56+| `$ZOT_HOME/skills/<name>/SKILL.md` | global (native) |
57+| `./.claude/skills/<name>/SKILL.md` | project (claude-compat) |
58+| `~/.claude/skills/<name>/SKILL.md` | global (claude-compat) |
59+| `./.agents/skills/<name>/SKILL.md` | project (agent-compat) |
60+| `~/.agents/skills/<name>/SKILL.md` | global (agent-compat) |
61+
62+The compat paths are deliberate: a `SKILL.md` written for an existing
63+skill ecosystem works in zot unchanged. Drop your existing
64+`.claude/skills/` or `.agents/skills/` directories into a project and
65+zot will pick them up.
66+
67+`$ZOT_HOME` defaults to `~/Library/Application Support/zot/` on macOS,
68+`$XDG_STATE_HOME/zot` on Linux, `%LOCALAPPDATA%\zot` on Windows.
69+
70+## Inspecting installed skills
71+
72+In zot, run `/skills`. A picker lists every discovered skill with its
73+description and source path. Press enter on a row to view the full
74+body inline. Press esc to go back.
75+
76+## How the model uses a skill
77+
78+1. The system prompt tells the model that skills exist and what
79+ their names + descriptions are.
80+2. The model recognises a request that maps to a known skill and
81+ calls the `skill` tool with `name: "<skill-name>"`.
82+3. The `skill` tool returns the markdown body as the tool result.
83+4. The model follows the body's instructions.
84+
85+You can prompt the model directly to use a skill (e.g. "use the
86+code-review skill") but you don't have to — the descriptions in the
87+manifest are enough for it to choose on its own.
88+
89+## Writing good skills
90+
91+- **Be procedural.** Number steps. Tell the model what to do in what
92+ order. Skills are habits, not knowledge dumps.
93+- **Be precise about boundaries.** "Stop after step 4" is more
94+ effective than "don't go too far".
95+- **Trim aggressively.** A 200-line skill bloats every turn the
96+ model uses it. Aim for 20–80 lines.
97+- **One skill per behaviour.** Don't pack three workflows into one
98+ SKILL.md; the model picks one path. Two separate skills work better.
99+- **Lead with the trigger.** First paragraph should make it
100+ obvious *when* to use the skill so the model self-selects correctly.
101+
102+## Examples
103+
104+See `examples/skills/` for two starter skills:
105+
106+- `code-review/` — self-review pass on a recent diff
107+- `test-fix/` — diagnose + minimally fix a failing test
108+
109+## Comparison to other discovery layouts
110+
111+| ecosystem | path | zot reads it? |
112+|---|---|---|
113+| (native) | `.zot/skills/<name>/SKILL.md` | yes |
114+| (claude-style) | `.claude/skills/<name>/SKILL.md` | yes |
115+| (agent-style) | `.agents/skills/<name>/SKILL.md` | yes |
116+
117+Cross-pollination is intentional: pick whichever convention you're
118+already using and zot tags along.
@@ -0,0 +1,118 @@
1+# zot skills
2+
3+A skill is a reusable instruction set written as a single
4+`SKILL.md` file with a YAML frontmatter header. zot discovers skills
5+at startup and surfaces them to the model in two ways:
6+
7+1. The system prompt gains a short manifest:
8+ `Available skills: ... - code-review — Run a self-review pass...`
9+2. A built-in `skill` tool lets the model load any one skill's full
10+ body on demand.
11+
12+The on-demand-load model keeps token usage cheap: only the manifest
13+goes into every request; the body is fetched as a tool result the
14+one or two turns the model actually needs it.
15+
16+## Anatomy
17+
18+```markdown
19+---
20+name: code-review
21+description: Run a thorough self-review pass on a recent change.
22+allowed-tools: [read, bash]
23+permissions:
24+ bash: ["git diff*", "git log*"]
25+---
26+
27+# Code review
28+
29+When asked to review code, ...
30+```
31+
32+### Frontmatter fields
33+
34+| field | required | purpose |
35+|---|---|---|
36+| `name` | optional | skill identifier; defaults to the directory name |
37+| `description` | required | one-line summary shown in the system prompt |
38+| `allowed-tools` | optional | list of tool names the skill is meant to use; informational |
39+| `permissions` | optional | per-tool patterns; informational |
40+
41+`allowed-tools` and `permissions` are **parsed but not enforced** in
42+this version. They appear in the rendered skill body so the model can
43+see them and self-regulate. Future versions may enforce.
44+
45+The body (everything after the second `---`) is plain markdown.
46+There's no template engine; the model sees what you write.
47+
48+## Discovery
49+
50+zot looks in these directories, in priority order, and registers the
51+first `SKILL.md` it finds for each unique name:
52+
53+| location | scope |
54+|---|---|
55+| `./.zot/skills/<name>/SKILL.md` | project (native) |
56+| `$ZOT_HOME/skills/<name>/SKILL.md` | global (native) |
57+| `./.claude/skills/<name>/SKILL.md` | project (claude-compat) |
58+| `~/.claude/skills/<name>/SKILL.md` | global (claude-compat) |
59+| `./.agents/skills/<name>/SKILL.md` | project (agent-compat) |
60+| `~/.agents/skills/<name>/SKILL.md` | global (agent-compat) |
61+
62+The compat paths are deliberate: a `SKILL.md` written for an existing
63+skill ecosystem works in zot unchanged. Drop your existing
64+`.claude/skills/` or `.agents/skills/` directories into a project and
65+zot will pick them up.
66+
67+`$ZOT_HOME` defaults to `~/Library/Application Support/zot/` on macOS,
68+`$XDG_STATE_HOME/zot` on Linux, `%LOCALAPPDATA%\zot` on Windows.
69+
70+## Inspecting installed skills
71+
72+In zot, run `/skills`. A picker lists every discovered skill with its
73+description and source path. Press enter on a row to view the full
74+body inline. Press esc to go back.
75+
76+## How the model uses a skill
77+
78+1. The system prompt tells the model that skills exist and what
79+ their names + descriptions are.
80+2. The model recognises a request that maps to a known skill and
81+ calls the `skill` tool with `name: "<skill-name>"`.
82+3. The `skill` tool returns the markdown body as the tool result.
83+4. The model follows the body's instructions.
84+
85+You can prompt the model directly to use a skill (e.g. "use the
86+code-review skill") but you don't have to — the descriptions in the
87+manifest are enough for it to choose on its own.
88+
89+## Writing good skills
90+
91+- **Be procedural.** Number steps. Tell the model what to do in what
92+ order. Skills are habits, not knowledge dumps.
93+- **Be precise about boundaries.** "Stop after step 4" is more
94+ effective than "don't go too far".
95+- **Trim aggressively.** A 200-line skill bloats every turn the
96+ model uses it. Aim for 20–80 lines.
97+- **One skill per behaviour.** Don't pack three workflows into one
98+ SKILL.md; the model picks one path. Two separate skills work better.
99+- **Lead with the trigger.** First paragraph should make it
100+ obvious *when* to use the skill so the model self-selects correctly.
101+
102+## Examples
103+
104+See `examples/skills/` for two starter skills:
105+
106+- `code-review/` — self-review pass on a recent diff
107+- `test-fix/` — diagnose + minimally fix a failing test
108+
109+## Comparison to other discovery layouts
110+
111+| ecosystem | path | zot reads it? |
112+|---|---|---|
113+| (native) | `.zot/skills/<name>/SKILL.md` | yes |
114+| (claude-style) | `.claude/skills/<name>/SKILL.md` | yes |
115+| (agent-style) | `.agents/skills/<name>/SKILL.md` | yes |
116+
117+Cross-pollination is intentional: pick whichever convention you're
118+already using and zot tags along.
A examples/skills/code-review/SKILL.md +52 -0
@@ -0,0 +1,52 @@
1+---
2+name: code-review
3+description: Run a thorough self-review pass on the most recent change.
4+allowed-tools: [read, bash]
5+permissions:
6+ bash: ["git diff*", "git log*", "git show*", "git status"]
7+---
8+
9+# Code review
10+
11+When the user asks for a code review (and you have not already done
12+one in this turn), follow this routine.
13+
14+## 1. Establish what changed
15+
16+Use `bash` to run `git status` and `git diff` (or `git diff --staged`
17+if there are no unstaged changes). Skim the patch end to end before
18+analysing any single hunk.
19+
20+## 2. For each modified file
21+
22+Read the file in full with the `read` tool — never review only the
23+hunk; you need surrounding context to evaluate the change properly.
24+Then look for:
25+
26+- **Correctness**: bugs, off-by-one errors, wrong sign, missing nil
27+ checks, swapped arguments, race conditions.
28+- **Error handling**: every external call (file IO, network, parsing,
29+ syscalls) — does it propagate or swallow? Are errors wrapped with
30+ enough context?
31+- **Tests**: do the new code paths have tests? Are existing tests
32+ still passing what they claim?
33+- **Surface area**: are exports necessary, or could the change stay
34+ internal? Public APIs deserve more scrutiny than internals.
35+- **Style consistency**: does the change match neighbouring code?
36+
37+## 3. Report
38+
39+Produce a concise written review with this shape:
40+
41+- **Verdict** (one line): ship-as-is / minor changes / needs work / blocked.
42+- **Required changes** (numbered, if any).
43+- **Suggestions** (bullets, optional).
44+- **Praise** (one or two lines if anything stood out — keeps the
45+ feedback humane).
46+
47+Don't restate every line of the diff. Don't speculate about future
48+features. Stay grounded in what the patch does.
49+
50+## 4. Stop
51+
52+Do not auto-apply fixes. The user will decide what to act on.
@@ -0,0 +1,52 @@
1+---
2+name: code-review
3+description: Run a thorough self-review pass on the most recent change.
4+allowed-tools: [read, bash]
5+permissions:
6+ bash: ["git diff*", "git log*", "git show*", "git status"]
7+---
8+
9+# Code review
10+
11+When the user asks for a code review (and you have not already done
12+one in this turn), follow this routine.
13+
14+## 1. Establish what changed
15+
16+Use `bash` to run `git status` and `git diff` (or `git diff --staged`
17+if there are no unstaged changes). Skim the patch end to end before
18+analysing any single hunk.
19+
20+## 2. For each modified file
21+
22+Read the file in full with the `read` tool — never review only the
23+hunk; you need surrounding context to evaluate the change properly.
24+Then look for:
25+
26+- **Correctness**: bugs, off-by-one errors, wrong sign, missing nil
27+ checks, swapped arguments, race conditions.
28+- **Error handling**: every external call (file IO, network, parsing,
29+ syscalls) — does it propagate or swallow? Are errors wrapped with
30+ enough context?
31+- **Tests**: do the new code paths have tests? Are existing tests
32+ still passing what they claim?
33+- **Surface area**: are exports necessary, or could the change stay
34+ internal? Public APIs deserve more scrutiny than internals.
35+- **Style consistency**: does the change match neighbouring code?
36+
37+## 3. Report
38+
39+Produce a concise written review with this shape:
40+
41+- **Verdict** (one line): ship-as-is / minor changes / needs work / blocked.
42+- **Required changes** (numbered, if any).
43+- **Suggestions** (bullets, optional).
44+- **Praise** (one or two lines if anything stood out — keeps the
45+ feedback humane).
46+
47+Don't restate every line of the diff. Don't speculate about future
48+features. Stay grounded in what the patch does.
49+
50+## 4. Stop
51+
52+Do not auto-apply fixes. The user will decide what to act on.
A examples/skills/test-fix/SKILL.md +43 -0
@@ -0,0 +1,43 @@
1+---
2+name: test-fix
3+description: Diagnose and minimally fix a failing test, then re-run.
4+allowed-tools: [read, edit, bash]
5+---
6+
7+# Test fix
8+
9+When the user reports a failing test (or a test suite failure), work
10+in this order. Resist the temptation to rewrite large chunks of code.
11+
12+## 1. Reproduce
13+
14+Run the failing test in isolation first. For Go: `go test -run
15+TestThing ./pkg/...`. For Node: `npm test -- --run TestThing`. The
16+goal is a fast feedback loop, not the whole suite.
17+
18+## 2. Read the failure carefully
19+
20+- What was expected vs got?
21+- Which line of test code triggered the failure?
22+- Is it a logic error in the implementation, an outdated test
23+ expectation, or environmental drift (e.g. timezone, locale, file
24+ system case-sensitivity)?
25+
26+## 3. Choose the smallest possible fix
27+
28+- If the test is wrong → update the assertion only. Don't restructure.
29+- If the implementation is wrong → patch the smallest function that
30+ fixes the failing case. Avoid drive-by refactors.
31+- If the test depends on behaviour you need to change deliberately →
32+ ask the user before changing the assertion.
33+
34+## 4. Re-run
35+
36+The same isolated test, then the immediate suite, then the full
37+test command if applicable. Stop the moment you see green; don't
38+keep poking.
39+
40+## 5. Report
41+
42+Write 2–3 sentences: what was wrong, what changed, and which command
43+verifies the fix. Include the diff inline.
@@ -0,0 +1,43 @@
1+---
2+name: test-fix
3+description: Diagnose and minimally fix a failing test, then re-run.
4+allowed-tools: [read, edit, bash]
5+---
6+
7+# Test fix
8+
9+When the user reports a failing test (or a test suite failure), work
10+in this order. Resist the temptation to rewrite large chunks of code.
11+
12+## 1. Reproduce
13+
14+Run the failing test in isolation first. For Go: `go test -run
15+TestThing ./pkg/...`. For Node: `npm test -- --run TestThing`. The
16+goal is a fast feedback loop, not the whole suite.
17+
18+## 2. Read the failure carefully
19+
20+- What was expected vs got?
21+- Which line of test code triggered the failure?
22+- Is it a logic error in the implementation, an outdated test
23+ expectation, or environmental drift (e.g. timezone, locale, file
24+ system case-sensitivity)?
25+
26+## 3. Choose the smallest possible fix
27+
28+- If the test is wrong → update the assertion only. Don't restructure.
29+- If the implementation is wrong → patch the smallest function that
30+ fixes the failing case. Avoid drive-by refactors.
31+- If the test depends on behaviour you need to change deliberately →
32+ ask the user before changing the assertion.
33+
34+## 4. Re-run
35+
36+The same isolated test, then the immediate suite, then the full
37+test command if applicable. Stop the moment you see green; don't
38+keep poking.
39+
40+## 5. Report
41+
42+Write 2–3 sentences: what was wrong, what changed, and which command
43+verifies the fix. Include the diff inline.
M internal/agent/build.go +30 -1
@@ -2,10 +2,12 @@ package agent
22
33 import (
44 "fmt"
5+ "os"
56
67 "github.com/patriceckhart/zot/internal/agent/tools"
78 "github.com/patriceckhart/zot/internal/core"
89 "github.com/patriceckhart/zot/internal/provider"
10+ "github.com/patriceckhart/zot/internal/skills"
911 )
1012
1113 // Resolved is the effective configuration after merging CLI, config, defaults.
@@ -24,6 +26,11 @@ type Resolved struct {
2426 SystemPrompt string
2527 MaxSteps int
2628 Sandbox *tools.Sandbox
29+
30+ // SkillTool is the on-demand skill loader registered with the
31+ // agent's tool registry, or nil if no SKILL.md files were
32+ // discovered. Exposed so the tui can list / preview skills.
33+ SkillTool *skills.Tool
2734 }
2835
2936 // HasCredential reports whether a credential was resolved.
@@ -91,13 +98,34 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
9198
9299 sandbox := tools.NewSandbox(args.CWD)
93100 reg := buildToolRegistry(args, args.CWD, sandbox)
101+
102+ // Skill discovery: scan project + global locations and, if any
103+ // SKILL.md files were found, register the on-demand `skill`
104+ // loader tool plus a system-prompt manifest so the model knows
105+ // what's available.
106+ homeDir, _ := os.UserHomeDir()
107+ discovered, _ := skills.Discover(ZotHome(), args.CWD, homeDir)
108+ var skillTool *skills.Tool
109+ var skillAddendum string
110+ if len(discovered) > 0 {
111+ skillTool = skills.NewTool(discovered)
112+ reg[skillTool.Name()] = skillTool
113+ skillAddendum = skills.SystemPromptAddendum(discovered)
114+ }
115+ _ = skillTool
116+
94117 summaries := toolSummaries(reg, args)
95118
119+ append_ := append([]string(nil), args.AppendSystemPrompt...)
120+ if skillAddendum != "" {
121+ append_ = append(append_, skillAddendum)
122+ }
123+
96124 sys := BuildSystemPrompt(SystemPromptOpts{
97125 CWD: args.CWD,
98126 Tools: summaries,
99127 Custom: args.SystemPrompt,
100- Append: args.AppendSystemPrompt,
128+ Append: append_,
101129 })
102130
103131 reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
@@ -121,6 +149,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
121149 SystemPrompt: sys,
122150 MaxSteps: max,
123151 Sandbox: sandbox,
152+ SkillTool: skillTool,
124153 }, nil
125154 }
126155
@@ -2,10 +2,12 @@ package agent
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "os"
5 6
6 "github.com/patriceckhart/zot/internal/agent/tools"7 "github.com/patriceckhart/zot/internal/agent/tools"
7 "github.com/patriceckhart/zot/internal/core"8 "github.com/patriceckhart/zot/internal/core"
8 "github.com/patriceckhart/zot/internal/provider"9 "github.com/patriceckhart/zot/internal/provider"
10+ "github.com/patriceckhart/zot/internal/skills"
9 )11 )
10 12
11 // Resolved is the effective configuration after merging CLI, config, defaults.13 // Resolved is the effective configuration after merging CLI, config, defaults.
@@ -24,6 +26,11 @@ type Resolved struct {
24 SystemPrompt string26 SystemPrompt string
25 MaxSteps int27 MaxSteps int
26 Sandbox *tools.Sandbox28 Sandbox *tools.Sandbox
29+
30+ // SkillTool is the on-demand skill loader registered with the
31+ // agent's tool registry, or nil if no SKILL.md files were
32+ // discovered. Exposed so the tui can list / preview skills.
33+ SkillTool *skills.Tool
27 }34 }
28 35
29 // HasCredential reports whether a credential was resolved.36 // HasCredential reports whether a credential was resolved.
@@ -91,13 +98,34 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
91 98
92 sandbox := tools.NewSandbox(args.CWD)99 sandbox := tools.NewSandbox(args.CWD)
93 reg := buildToolRegistry(args, args.CWD, sandbox)100 reg := buildToolRegistry(args, args.CWD, sandbox)
101+
102+ // Skill discovery: scan project + global locations and, if any
103+ // SKILL.md files were found, register the on-demand `skill`
104+ // loader tool plus a system-prompt manifest so the model knows
105+ // what's available.
106+ homeDir, _ := os.UserHomeDir()
107+ discovered, _ := skills.Discover(ZotHome(), args.CWD, homeDir)
108+ var skillTool *skills.Tool
109+ var skillAddendum string
110+ if len(discovered) > 0 {
111+ skillTool = skills.NewTool(discovered)
112+ reg[skillTool.Name()] = skillTool
113+ skillAddendum = skills.SystemPromptAddendum(discovered)
114+ }
115+ _ = skillTool
116+
94 summaries := toolSummaries(reg, args)117 summaries := toolSummaries(reg, args)
95 118
119+ append_ := append([]string(nil), args.AppendSystemPrompt...)
120+ if skillAddendum != "" {
121+ append_ = append(append_, skillAddendum)
122+ }
123+
96 sys := BuildSystemPrompt(SystemPromptOpts{124 sys := BuildSystemPrompt(SystemPromptOpts{
97 CWD: args.CWD,125 CWD: args.CWD,
98 Tools: summaries,126 Tools: summaries,
99 Custom: args.SystemPrompt,127 Custom: args.SystemPrompt,
100- Append: args.AppendSystemPrompt,128+ Append: append_,
101 })129 })
102 130
103 reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)131 reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
@@ -121,6 +149,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
121 SystemPrompt: sys,149 SystemPrompt: sys,
122 MaxSteps: max,150 MaxSteps: max,
123 Sandbox: sandbox,151 Sandbox: sandbox,
152+ SkillTool: skillTool,
124 }, nil153 }, nil
125 }154 }
126 155
M internal/agent/cli.go +8 -0
@@ -14,6 +14,7 @@ import (
1414 "github.com/patriceckhart/zot/internal/auth"
1515 "github.com/patriceckhart/zot/internal/core"
1616 "github.com/patriceckhart/zot/internal/provider"
17+ "github.com/patriceckhart/zot/internal/skills"
1718 "github.com/patriceckhart/zot/internal/tui"
1819 )
1920
@@ -300,6 +301,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
300301 BuildAgentFor: buildAgentFor,
301302 LoadSession: loadSession,
302303 Extensions: extMgr,
304+ SkillSnapshot: func() []*skills.Skill {
305+ // Re-discover so the picker reflects edits made during
306+ // the session. Cheap; SKILL.md files are small.
307+ userHome, _ := os.UserHomeDir()
308+ list, _ := skills.Discover(ZotHome(), r.CWD, userHome)
309+ return list
310+ },
303311 PersistModel: func(providerName, model string) {
304312 // Update config.json so next launch uses the same pick.
305313 cfg, _ := LoadConfig()
@@ -14,6 +14,7 @@ import (
14 "github.com/patriceckhart/zot/internal/auth"14 "github.com/patriceckhart/zot/internal/auth"
15 "github.com/patriceckhart/zot/internal/core"15 "github.com/patriceckhart/zot/internal/core"
16 "github.com/patriceckhart/zot/internal/provider"16 "github.com/patriceckhart/zot/internal/provider"
17+ "github.com/patriceckhart/zot/internal/skills"
17 "github.com/patriceckhart/zot/internal/tui"18 "github.com/patriceckhart/zot/internal/tui"
18 )19 )
19 20
@@ -300,6 +301,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
300 BuildAgentFor: buildAgentFor,301 BuildAgentFor: buildAgentFor,
301 LoadSession: loadSession,302 LoadSession: loadSession,
302 Extensions: extMgr,303 Extensions: extMgr,
304+ SkillSnapshot: func() []*skills.Skill {
305+ // Re-discover so the picker reflects edits made during
306+ // the session. Cheap; SKILL.md files are small.
307+ userHome, _ := os.UserHomeDir()
308+ list, _ := skills.Discover(ZotHome(), r.CWD, userHome)
309+ return list
310+ },
303 PersistModel: func(providerName, model string) {311 PersistModel: func(providerName, model string) {
304 // Update config.json so next launch uses the same pick.312 // Update config.json so next launch uses the same pick.
305 cfg, _ := LoadConfig()313 cfg, _ := LoadConfig()
M internal/agent/modes/interactive.go +35 -1
@@ -13,6 +13,7 @@ import (
1313 "github.com/patriceckhart/zot/internal/auth"
1414 "github.com/patriceckhart/zot/internal/core"
1515 "github.com/patriceckhart/zot/internal/provider"
16+ "github.com/patriceckhart/zot/internal/skills"
1617 "github.com/patriceckhart/zot/internal/tui"
1718 )
1819
@@ -84,6 +85,11 @@ type InteractiveConfig struct {
8485 // slash commands. Commands declared by extensions are looked up
8586 // AFTER the built-in catalog so a built-in name always wins.
8687 Extensions *extensions.Manager
88+
89+ // SkillSnapshot, if non-nil, returns the current list of
90+ // discovered SKILL.md files. Re-invoked each time /skills opens
91+ // so the picker reflects edits made during the session.
92+ SkillSnapshot func() []*skills.Skill
8793 }
8894
8995 // Interactive is the TUI chat loop.
@@ -133,6 +139,7 @@ type Interactive struct {
133139 sessionDialog *sessionDialog
134140 jumpDialog *jumpDialog
135141 btwDialog *btwDialog
142+ skillsDialog *skillsDialog
136143 suggest *slashSuggester
137144 spin *spinner
138145
@@ -181,6 +188,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
181188 sessionDialog: newSessionDialog(),
182189 jumpDialog: newJumpDialog(),
183190 btwDialog: newBtwDialog(),
191+ skillsDialog: newSkillsDialog(),
184192 suggest: newSlashSuggester(),
185193 spin: newSpinner(),
186194 }
@@ -363,7 +371,7 @@ func (i *Interactive) Run(ctx context.Context) error {
363371 // and the AfterFunc-driven invalidate got dropped on a
364372 // full channel.
365373 drainPending()
366- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() {
374+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() {
367375 requestRedraw() // keep the spinner / dialog animation moving
368376 }
369377 }
@@ -517,6 +525,8 @@ func (i *Interactive) redraw() {
517525 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
518526 case i.btwDialog.Active():
519527 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
528+ case i.skillsDialog.Active():
529+ dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
520530 }
521531
522532 // Slash-command autocomplete: popup above the status line, only
@@ -766,6 +776,16 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
766776 i.btwDialog.HandleKey(k, i.invalidate)
767777 return false
768778 }
779+ if i.skillsDialog.Active() {
780+ if k.Kind == tui.KeyCtrlC {
781+ i.skillsDialog.Close()
782+ i.invalidate()
783+ return false
784+ }
785+ i.skillsDialog.HandleKey(k)
786+ i.invalidate()
787+ return false
788+ }
769789
770790 // Global keys.
771791 switch k.Kind {
@@ -1108,6 +1128,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
11081128 i.openJumpDialog(parts[1:])
11091129 case "/btw":
11101130 i.openBtwDialog(parts[1:])
1131+ case "/skills":
1132+ i.openSkillsDialog()
11111133 case "/compact":
11121134 i.runCompact(ctx, false)
11131135 case "/lock":
@@ -1289,6 +1311,18 @@ func (i *Interactive) openBtwDialog(args []string) {
12891311 i.invalidate()
12901312 }
12911313
1314+// openSkillsDialog opens the skill inspector. The picker reflects
1315+// whatever SkillSnapshot returns at call time, so edits to a
1316+// SKILL.md made during a session show up on the next /skills.
1317+func (i *Interactive) openSkillsDialog() {
1318+ var list []*skills.Skill
1319+ if i.cfg.SkillSnapshot != nil {
1320+ list = i.cfg.SkillSnapshot()
1321+ }
1322+ i.skillsDialog.Open(list)
1323+ i.invalidate()
1324+}
1325+
12921326 // openJumpDialog builds a /jump picker from the current transcript.
12931327 // If the user typed "/jump foo" with a filter and it matches exactly
12941328 // one turn, jump there directly without showing the dialog.
@@ -13,6 +13,7 @@ import (
13 "github.com/patriceckhart/zot/internal/auth"13 "github.com/patriceckhart/zot/internal/auth"
14 "github.com/patriceckhart/zot/internal/core"14 "github.com/patriceckhart/zot/internal/core"
15 "github.com/patriceckhart/zot/internal/provider"15 "github.com/patriceckhart/zot/internal/provider"
16+ "github.com/patriceckhart/zot/internal/skills"
16 "github.com/patriceckhart/zot/internal/tui"17 "github.com/patriceckhart/zot/internal/tui"
17 )18 )
18 19
@@ -84,6 +85,11 @@ type InteractiveConfig struct {
84 // slash commands. Commands declared by extensions are looked up85 // slash commands. Commands declared by extensions are looked up
85 // AFTER the built-in catalog so a built-in name always wins.86 // AFTER the built-in catalog so a built-in name always wins.
86 Extensions *extensions.Manager87 Extensions *extensions.Manager
88+
89+ // SkillSnapshot, if non-nil, returns the current list of
90+ // discovered SKILL.md files. Re-invoked each time /skills opens
91+ // so the picker reflects edits made during the session.
92+ SkillSnapshot func() []*skills.Skill
87 }93 }
88 94
89 // Interactive is the TUI chat loop.95 // Interactive is the TUI chat loop.
@@ -133,6 +139,7 @@ type Interactive struct {
133 sessionDialog *sessionDialog139 sessionDialog *sessionDialog
134 jumpDialog *jumpDialog140 jumpDialog *jumpDialog
135 btwDialog *btwDialog141 btwDialog *btwDialog
142+ skillsDialog *skillsDialog
136 suggest *slashSuggester143 suggest *slashSuggester
137 spin *spinner144 spin *spinner
138 145
@@ -181,6 +188,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
181 sessionDialog: newSessionDialog(),188 sessionDialog: newSessionDialog(),
182 jumpDialog: newJumpDialog(),189 jumpDialog: newJumpDialog(),
183 btwDialog: newBtwDialog(),190 btwDialog: newBtwDialog(),
191+ skillsDialog: newSkillsDialog(),
184 suggest: newSlashSuggester(),192 suggest: newSlashSuggester(),
185 spin: newSpinner(),193 spin: newSpinner(),
186 }194 }
@@ -363,7 +371,7 @@ func (i *Interactive) Run(ctx context.Context) error {
363 // and the AfterFunc-driven invalidate got dropped on a371 // and the AfterFunc-driven invalidate got dropped on a
364 // full channel.372 // full channel.
365 drainPending()373 drainPending()
366- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() {374+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() {
367 requestRedraw() // keep the spinner / dialog animation moving375 requestRedraw() // keep the spinner / dialog animation moving
368 }376 }
369 }377 }
@@ -517,6 +525,8 @@ func (i *Interactive) redraw() {
517 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)525 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
518 case i.btwDialog.Active():526 case i.btwDialog.Active():
519 dialog = i.btwDialog.Render(i.cfg.Theme, cols)527 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
528+ case i.skillsDialog.Active():
529+ dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
520 }530 }
521 531
522 // Slash-command autocomplete: popup above the status line, only532 // Slash-command autocomplete: popup above the status line, only
@@ -766,6 +776,16 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
766 i.btwDialog.HandleKey(k, i.invalidate)776 i.btwDialog.HandleKey(k, i.invalidate)
767 return false777 return false
768 }778 }
779+ if i.skillsDialog.Active() {
780+ if k.Kind == tui.KeyCtrlC {
781+ i.skillsDialog.Close()
782+ i.invalidate()
783+ return false
784+ }
785+ i.skillsDialog.HandleKey(k)
786+ i.invalidate()
787+ return false
788+ }
769 789
770 // Global keys.790 // Global keys.
771 switch k.Kind {791 switch k.Kind {
@@ -1108,6 +1128,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1108 i.openJumpDialog(parts[1:])1128 i.openJumpDialog(parts[1:])
1109 case "/btw":1129 case "/btw":
1110 i.openBtwDialog(parts[1:])1130 i.openBtwDialog(parts[1:])
1131+ case "/skills":
1132+ i.openSkillsDialog()
1111 case "/compact":1133 case "/compact":
1112 i.runCompact(ctx, false)1134 i.runCompact(ctx, false)
1113 case "/lock":1135 case "/lock":
@@ -1289,6 +1311,18 @@ func (i *Interactive) openBtwDialog(args []string) {
1289 i.invalidate()1311 i.invalidate()
1290 }1312 }
1291 1313
1314+// openSkillsDialog opens the skill inspector. The picker reflects
1315+// whatever SkillSnapshot returns at call time, so edits to a
1316+// SKILL.md made during a session show up on the next /skills.
1317+func (i *Interactive) openSkillsDialog() {
1318+ var list []*skills.Skill
1319+ if i.cfg.SkillSnapshot != nil {
1320+ list = i.cfg.SkillSnapshot()
1321+ }
1322+ i.skillsDialog.Open(list)
1323+ i.invalidate()
1324+}
1325+
1292 // openJumpDialog builds a /jump picker from the current transcript.1326 // openJumpDialog builds a /jump picker from the current transcript.
1293 // If the user typed "/jump foo" with a filter and it matches exactly1327 // If the user typed "/jump foo" with a filter and it matches exactly
1294 // one turn, jump there directly without showing the dialog.1328 // one turn, jump there directly without showing the dialog.
A internal/agent/modes/skills_dialog.go +205 -0
@@ -0,0 +1,205 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/patriceckhart/zot/internal/skills"
8+ "github.com/patriceckhart/zot/internal/tui"
9+)
10+
11+// skillsDialog lists every discovered skill and lets the user view
12+// the body of one inline. View is read-only — the model loads skills
13+// itself via the `skill` tool. This dialog is for inspection.
14+type skillsDialog struct {
15+ active bool
16+ skills []*skills.Skill
17+ cursor int
18+ viewing *skills.Skill // when non-nil, render the body instead of the list
19+ scroll int // body view scroll offset (in lines)
20+}
21+
22+func newSkillsDialog() *skillsDialog { return &skillsDialog{} }
23+
24+// Open populates and shows the dialog with the given snapshot.
25+func (d *skillsDialog) Open(s []*skills.Skill) {
26+ d.active = true
27+ d.skills = s
28+ d.cursor = 0
29+ d.viewing = nil
30+ d.scroll = 0
31+}
32+
33+// Close hides the dialog.
34+func (d *skillsDialog) Close() { d.active = false }
35+
36+// Active reports whether the dialog is visible.
37+func (d *skillsDialog) Active() bool { return d != nil && d.active }
38+
39+// HandleKey advances the dialog state.
40+func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {
41+ if !d.Active() {
42+ return false
43+ }
44+
45+ if d.viewing != nil {
46+ // Body view keys.
47+ switch k.Kind {
48+ case tui.KeyEsc, tui.KeyEnter:
49+ d.viewing = nil
50+ d.scroll = 0
51+ case tui.KeyUp:
52+ if d.scroll > 0 {
53+ d.scroll--
54+ }
55+ case tui.KeyDown:
56+ d.scroll++
57+ case tui.KeyPageUp:
58+ d.scroll -= 8
59+ if d.scroll < 0 {
60+ d.scroll = 0
61+ }
62+ case tui.KeyPageDown:
63+ d.scroll += 8
64+ }
65+ return false
66+ }
67+
68+ // List view keys.
69+ switch k.Kind {
70+ case tui.KeyEsc:
71+ d.Close()
72+ return true
73+ case tui.KeyUp:
74+ if d.cursor > 0 {
75+ d.cursor--
76+ }
77+ case tui.KeyDown:
78+ if d.cursor < len(d.skills)-1 {
79+ d.cursor++
80+ }
81+ case tui.KeyEnter:
82+ if len(d.skills) > 0 {
83+ d.viewing = d.skills[d.cursor]
84+ d.scroll = 0
85+ }
86+ }
87+ return false
88+}
89+
90+// Render draws the picker or the body view.
91+func (d *skillsDialog) Render(th tui.Theme, width int) []string {
92+ if !d.Active() {
93+ return nil
94+ }
95+
96+ if d.viewing != nil {
97+ return d.renderBody(th, width)
98+ }
99+
100+ out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
101+ if len(d.skills) == 0 {
102+ out = append(out, " "+th.FG256(th.Muted, "no skills found"))
103+ out = append(out, " "+th.FG256(th.Muted, "see docs/skills.md to add some"))
104+ out = append(out, frameRule(th, width))
105+ return out
106+ }
107+
108+ const maxRows = 12
109+ start, end := visibleWindow(d.cursor, len(d.skills), maxRows)
110+ if start > 0 {
111+ out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2191 %d more above", start)))
112+ }
113+ for i := start; i < end; i++ {
114+ s := d.skills[i]
115+ row := formatSkillRow(s, width-2)
116+ if i == d.cursor {
117+ out = append(out, th.PadHighlight(" "+row, width))
118+ } else {
119+ out = append(out, " "+th.FG256(th.Muted, row))
120+ }
121+ }
122+ if end < len(d.skills) {
123+ out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))
124+ }
125+ out = append(out, frameRule(th, width))
126+ return out
127+}
128+
129+func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
130+ s := d.viewing
131+ out := []string{
132+ frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width),
133+ " " + th.FG256(th.Muted, s.Description),
134+ " " + th.FG256(th.Muted, "source: "+s.Source+" ("+s.Path+")"),
135+ "",
136+ }
137+
138+ rendered := tui.RenderMarkdown(s.Body, th, width-4)
139+ bodyLines := strings.Split(rendered, "\n")
140+
141+ const maxRows = 16
142+ if d.scroll > len(bodyLines)-1 {
143+ d.scroll = len(bodyLines) - 1
144+ }
145+ if d.scroll < 0 {
146+ d.scroll = 0
147+ }
148+ end := d.scroll + maxRows
149+ if end > len(bodyLines) {
150+ end = len(bodyLines)
151+ }
152+ for _, line := range bodyLines[d.scroll:end] {
153+ out = append(out, " "+line)
154+ }
155+ if end < len(bodyLines) {
156+ out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
157+ }
158+ out = append(out, frameRule(th, width))
159+ return out
160+}
161+
162+func formatSkillRow(s *skills.Skill, maxWidth int) string {
163+ left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))
164+ src := " " + truncateLineSafe(s.Source, 16)
165+ room := maxWidth - len(left) - len(src)
166+ if room < 10 {
167+ room = 10
168+ }
169+ desc := s.Description
170+ if len(desc) > room {
171+ desc = desc[:room-1] + "\u2026"
172+ }
173+ return left + desc + src
174+}
175+
176+// truncateLineSafe limits s to n runes (not bytes) so multibyte
177+// names + sources don't blow past the column.
178+func truncateLineSafe(s string, n int) string {
179+ r := []rune(s)
180+ if len(r) <= n {
181+ return s
182+ }
183+ if n <= 1 {
184+ return "\u2026"
185+ }
186+ return string(r[:n-1]) + "\u2026"
187+}
188+
189+// visibleWindow centers cursor in a window of size n within total
190+// items. Returns [start, end) bounds.
191+func visibleWindow(cursor, total, n int) (start, end int) {
192+ if total <= n {
193+ return 0, total
194+ }
195+ start = cursor - n/2
196+ if start < 0 {
197+ start = 0
198+ }
199+ end = start + n
200+ if end > total {
201+ end = total
202+ start = end - n
203+ }
204+ return
205+}
@@ -0,0 +1,205 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/patriceckhart/zot/internal/skills"
8+ "github.com/patriceckhart/zot/internal/tui"
9+)
10+
11+// skillsDialog lists every discovered skill and lets the user view
12+// the body of one inline. View is read-only — the model loads skills
13+// itself via the `skill` tool. This dialog is for inspection.
14+type skillsDialog struct {
15+ active bool
16+ skills []*skills.Skill
17+ cursor int
18+ viewing *skills.Skill // when non-nil, render the body instead of the list
19+ scroll int // body view scroll offset (in lines)
20+}
21+
22+func newSkillsDialog() *skillsDialog { return &skillsDialog{} }
23+
24+// Open populates and shows the dialog with the given snapshot.
25+func (d *skillsDialog) Open(s []*skills.Skill) {
26+ d.active = true
27+ d.skills = s
28+ d.cursor = 0
29+ d.viewing = nil
30+ d.scroll = 0
31+}
32+
33+// Close hides the dialog.
34+func (d *skillsDialog) Close() { d.active = false }
35+
36+// Active reports whether the dialog is visible.
37+func (d *skillsDialog) Active() bool { return d != nil && d.active }
38+
39+// HandleKey advances the dialog state.
40+func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {
41+ if !d.Active() {
42+ return false
43+ }
44+
45+ if d.viewing != nil {
46+ // Body view keys.
47+ switch k.Kind {
48+ case tui.KeyEsc, tui.KeyEnter:
49+ d.viewing = nil
50+ d.scroll = 0
51+ case tui.KeyUp:
52+ if d.scroll > 0 {
53+ d.scroll--
54+ }
55+ case tui.KeyDown:
56+ d.scroll++
57+ case tui.KeyPageUp:
58+ d.scroll -= 8
59+ if d.scroll < 0 {
60+ d.scroll = 0
61+ }
62+ case tui.KeyPageDown:
63+ d.scroll += 8
64+ }
65+ return false
66+ }
67+
68+ // List view keys.
69+ switch k.Kind {
70+ case tui.KeyEsc:
71+ d.Close()
72+ return true
73+ case tui.KeyUp:
74+ if d.cursor > 0 {
75+ d.cursor--
76+ }
77+ case tui.KeyDown:
78+ if d.cursor < len(d.skills)-1 {
79+ d.cursor++
80+ }
81+ case tui.KeyEnter:
82+ if len(d.skills) > 0 {
83+ d.viewing = d.skills[d.cursor]
84+ d.scroll = 0
85+ }
86+ }
87+ return false
88+}
89+
90+// Render draws the picker or the body view.
91+func (d *skillsDialog) Render(th tui.Theme, width int) []string {
92+ if !d.Active() {
93+ return nil
94+ }
95+
96+ if d.viewing != nil {
97+ return d.renderBody(th, width)
98+ }
99+
100+ out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
101+ if len(d.skills) == 0 {
102+ out = append(out, " "+th.FG256(th.Muted, "no skills found"))
103+ out = append(out, " "+th.FG256(th.Muted, "see docs/skills.md to add some"))
104+ out = append(out, frameRule(th, width))
105+ return out
106+ }
107+
108+ const maxRows = 12
109+ start, end := visibleWindow(d.cursor, len(d.skills), maxRows)
110+ if start > 0 {
111+ out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2191 %d more above", start)))
112+ }
113+ for i := start; i < end; i++ {
114+ s := d.skills[i]
115+ row := formatSkillRow(s, width-2)
116+ if i == d.cursor {
117+ out = append(out, th.PadHighlight(" "+row, width))
118+ } else {
119+ out = append(out, " "+th.FG256(th.Muted, row))
120+ }
121+ }
122+ if end < len(d.skills) {
123+ out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))
124+ }
125+ out = append(out, frameRule(th, width))
126+ return out
127+}
128+
129+func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
130+ s := d.viewing
131+ out := []string{
132+ frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width),
133+ " " + th.FG256(th.Muted, s.Description),
134+ " " + th.FG256(th.Muted, "source: "+s.Source+" ("+s.Path+")"),
135+ "",
136+ }
137+
138+ rendered := tui.RenderMarkdown(s.Body, th, width-4)
139+ bodyLines := strings.Split(rendered, "\n")
140+
141+ const maxRows = 16
142+ if d.scroll > len(bodyLines)-1 {
143+ d.scroll = len(bodyLines) - 1
144+ }
145+ if d.scroll < 0 {
146+ d.scroll = 0
147+ }
148+ end := d.scroll + maxRows
149+ if end > len(bodyLines) {
150+ end = len(bodyLines)
151+ }
152+ for _, line := range bodyLines[d.scroll:end] {
153+ out = append(out, " "+line)
154+ }
155+ if end < len(bodyLines) {
156+ out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
157+ }
158+ out = append(out, frameRule(th, width))
159+ return out
160+}
161+
162+func formatSkillRow(s *skills.Skill, maxWidth int) string {
163+ left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))
164+ src := " " + truncateLineSafe(s.Source, 16)
165+ room := maxWidth - len(left) - len(src)
166+ if room < 10 {
167+ room = 10
168+ }
169+ desc := s.Description
170+ if len(desc) > room {
171+ desc = desc[:room-1] + "\u2026"
172+ }
173+ return left + desc + src
174+}
175+
176+// truncateLineSafe limits s to n runes (not bytes) so multibyte
177+// names + sources don't blow past the column.
178+func truncateLineSafe(s string, n int) string {
179+ r := []rune(s)
180+ if len(r) <= n {
181+ return s
182+ }
183+ if n <= 1 {
184+ return "\u2026"
185+ }
186+ return string(r[:n-1]) + "\u2026"
187+}
188+
189+// visibleWindow centers cursor in a window of size n within total
190+// items. Returns [start, end) bounds.
191+func visibleWindow(cursor, total, n int) (start, end int) {
192+ if total <= n {
193+ return 0, total
194+ }
195+ start = cursor - n/2
196+ if start < 0 {
197+ start = 0
198+ }
199+ end = start + n
200+ if end > total {
201+ end = total
202+ start = end - n
203+ }
204+ return
205+}
M internal/agent/modes/slash_suggest.go +1 -0
@@ -35,6 +35,7 @@ var slashCatalog = []slashCommand{
3535 {"/sessions", "resume a previous session for this directory"},
3636 {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},
3737 {"/btw", "side-chat that doesn't add to the main thread (saves tokens)"},
38+ {"/skills", "list discovered skills (SKILL.md files)"},
3839 {"/compact", "summarize and replace the transcript to free up context"},
3940 {"/lock", "confine tools to the current directory"},
4041 {"/unlock", "allow tools to touch paths outside this directory"},
@@ -35,6 +35,7 @@ var slashCatalog = []slashCommand{
35 {"/sessions", "resume a previous session for this directory"},35 {"/sessions", "resume a previous session for this directory"},
36 {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},36 {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},
37 {"/btw", "side-chat that doesn't add to the main thread (saves tokens)"},37 {"/btw", "side-chat that doesn't add to the main thread (saves tokens)"},
38+ {"/skills", "list discovered skills (SKILL.md files)"},
38 {"/compact", "summarize and replace the transcript to free up context"},39 {"/compact", "summarize and replace the transcript to free up context"},
39 {"/lock", "confine tools to the current directory"},40 {"/lock", "confine tools to the current directory"},
40 {"/unlock", "allow tools to touch paths outside this directory"},41 {"/unlock", "allow tools to touch paths outside this directory"},
A internal/skills/skills.go +342 -0
@@ -0,0 +1,342 @@
1+// Package skills implements zot's reusable-instruction system.
2+//
3+// A skill is a per-folder SKILL.md file with a YAML frontmatter
4+// header. Skills live in well-known directories under the project or
5+// the user home; zot discovers them at startup, lists their names +
6+// one-line descriptions in the system prompt, and exposes a built-in
7+// "skill" tool the model uses to pull the full body on demand.
8+//
9+// The on-demand-load model keeps token usage cheap: only the
10+// short manifest goes into every request; the body is fetched as a
11+// tool result the one or two turns the model actually needs it.
12+//
13+// Discovery layout (priority order — first match wins per name):
14+//
15+// ./.zot/skills/<name>/SKILL.md — project (native)
16+// $ZOT_HOME/skills/<name>/SKILL.md — global (native)
17+// ./.claude/skills/<name>/SKILL.md — project (claude-compat)
18+// ~/.claude/skills/<name>/SKILL.md — global (claude-compat)
19+// ./.agents/skills/<name>/SKILL.md — project (agent-compat)
20+// ~/.agents/skills/<name>/SKILL.md — global (agent-compat)
21+//
22+// The compat paths are deliberate: a SKILL.md written for any of
23+// the related ecosystems works in zot unchanged.
24+package skills
25+
26+import (
27+ "fmt"
28+ "os"
29+ "path/filepath"
30+ "sort"
31+ "strings"
32+)
33+
34+// Skill is one discovered SKILL.md file.
35+type Skill struct {
36+ // Name is the skill identifier — what the model uses when it
37+ // invokes the `skill` tool. Taken from the frontmatter `name`
38+ // field; falls back to the directory basename.
39+ Name string
40+
41+ // Description is the one-line summary shown to the model in the
42+ // system-prompt manifest.
43+ Description string
44+
45+ // Body is the markdown after the frontmatter. Returned as the
46+ // tool result when the model loads this skill.
47+ Body string
48+
49+ // Path is the absolute path to the SKILL.md file.
50+ Path string
51+
52+ // Source is a human-friendly label describing where the skill
53+ // came from ("project", "global", "project (claude)", etc.).
54+ // Shown in the /skills picker.
55+ Source string
56+
57+ // AllowedTools and Permissions are parsed for forward-
58+ // compatibility but NOT enforced in this version. They appear
59+ // in the skill body so the model can self-regulate.
60+ AllowedTools []string
61+ Permissions map[string][]string
62+}
63+
64+// Discover walks every supported location, parses each SKILL.md, and
65+// returns the merged skill set. First-match-wins per name; the order
66+// matches the priority list in the package doc. Errors per skill are
67+// returned alongside the partial result so a single broken file
68+// doesn't suppress the rest.
69+func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
70+ var errs []error
71+ seen := map[string]*Skill{}
72+ for _, loc := range searchDirs(zotHome, cwd, userHome) {
73+ entries, err := os.ReadDir(loc.dir)
74+ if err != nil {
75+ continue // missing dir is fine
76+ }
77+ for _, e := range entries {
78+ if !e.IsDir() {
79+ continue
80+ }
81+ path := filepath.Join(loc.dir, e.Name(), "SKILL.md")
82+ s, err := load(path, loc.label)
83+ if err != nil {
84+ if !os.IsNotExist(err) {
85+ errs = append(errs, fmt.Errorf("%s: %w", path, err))
86+ }
87+ continue
88+ }
89+ if s.Name == "" {
90+ s.Name = e.Name()
91+ }
92+ if _, dup := seen[s.Name]; dup {
93+ continue // higher-priority location already won
94+ }
95+ seen[s.Name] = s
96+ }
97+ }
98+ out := make([]*Skill, 0, len(seen))
99+ for _, s := range seen {
100+ out = append(out, s)
101+ }
102+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
103+ return out, errs
104+}
105+
106+// SystemPromptAddendum returns the text to append to the system
107+// prompt when at least one skill is loaded. Empty string if none.
108+//
109+// Format kept short and explicit so the model reliably calls the
110+// `skill` tool with a name from the list rather than guessing.
111+func SystemPromptAddendum(skills []*Skill) string {
112+ if len(skills) == 0 {
113+ return ""
114+ }
115+ var sb strings.Builder
116+ sb.WriteString("Available skills (call the `skill` tool with one of these names to load full instructions):\n")
117+ for _, s := range skills {
118+ desc := strings.TrimSpace(s.Description)
119+ if desc == "" {
120+ desc = "(no description)"
121+ }
122+ fmt.Fprintf(&sb, "- %s — %s\n", s.Name, desc)
123+ }
124+ return sb.String()
125+}
126+
127+// FindByName returns the skill with the given name, or nil.
128+func FindByName(skills []*Skill, name string) *Skill {
129+ for _, s := range skills {
130+ if s.Name == name {
131+ return s
132+ }
133+ }
134+ return nil
135+}
136+
137+// ---- internals ----
138+
139+type location struct {
140+ dir string
141+ label string
142+}
143+
144+func searchDirs(zotHome, cwd, userHome string) []location {
145+ var out []location
146+ add := func(dir, label string) {
147+ if dir == "" {
148+ return
149+ }
150+ out = append(out, location{dir: dir, label: label})
151+ }
152+ if cwd != "" {
153+ add(filepath.Join(cwd, ".zot", "skills"), "project")
154+ }
155+ if zotHome != "" {
156+ add(filepath.Join(zotHome, "skills"), "global")
157+ }
158+ if cwd != "" {
159+ add(filepath.Join(cwd, ".claude", "skills"), "project (claude)")
160+ }
161+ if userHome != "" {
162+ add(filepath.Join(userHome, ".claude", "skills"), "global (claude)")
163+ }
164+ if cwd != "" {
165+ add(filepath.Join(cwd, ".agents", "skills"), "project (agents)")
166+ }
167+ if userHome != "" {
168+ add(filepath.Join(userHome, ".agents", "skills"), "global (agents)")
169+ }
170+ return out
171+}
172+
173+func load(path, source string) (*Skill, error) {
174+ raw, err := os.ReadFile(path)
175+ if err != nil {
176+ return nil, err
177+ }
178+ front, body := splitFrontmatter(string(raw))
179+ s := &Skill{
180+ Path: path,
181+ Source: source,
182+ Body: strings.TrimSpace(body),
183+ }
184+ parseFrontmatter(front, s)
185+ return s, nil
186+}
187+
188+// splitFrontmatter returns (yamlBlock, restOfDocument) for a string
189+// whose first non-empty line is "---". If no frontmatter is present,
190+// returns ("", entireString).
191+func splitFrontmatter(raw string) (string, string) {
192+ rest := strings.TrimLeft(raw, " \t\r\n")
193+ if !strings.HasPrefix(rest, "---") {
194+ return "", raw
195+ }
196+ rest = strings.TrimPrefix(rest, "---")
197+ // Drop the trailing newline after the opening ---.
198+ if i := strings.IndexByte(rest, '\n'); i >= 0 {
199+ rest = rest[i+1:]
200+ }
201+ end := strings.Index(rest, "\n---")
202+ if end < 0 {
203+ return "", raw // malformed; treat as no frontmatter
204+ }
205+ front := rest[:end]
206+ body := rest[end+len("\n---"):]
207+ body = strings.TrimLeft(body, " \t\r\n")
208+ return front, body
209+}
210+
211+// parseFrontmatter handles the small subset of YAML zot recognizes:
212+// - simple `key: value` lines
213+// - `key: [a, b, c]` flow-style lists
214+// - `key:` followed by indented `- item` block lists
215+// - nested `key:` followed by indented `subkey: [...]` for permissions
216+//
217+// Anything more elaborate is ignored. We deliberately avoid a yaml
218+// dependency to keep zot's binary lean.
219+func parseFrontmatter(front string, s *Skill) {
220+ lines := strings.Split(front, "\n")
221+ i := 0
222+ for i < len(lines) {
223+ line := lines[i]
224+ trimmed := strings.TrimSpace(line)
225+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
226+ i++
227+ continue
228+ }
229+ // Top-level key: value or key:
230+ if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
231+ colon := strings.IndexByte(trimmed, ':')
232+ if colon < 0 {
233+ i++
234+ continue
235+ }
236+ key := strings.TrimSpace(trimmed[:colon])
237+ value := strings.TrimSpace(trimmed[colon+1:])
238+ switch key {
239+ case "name":
240+ s.Name = unquote(value)
241+ case "description":
242+ s.Description = unquote(value)
243+ case "allowed-tools", "allowed_tools":
244+ if value != "" {
245+ s.AllowedTools = parseInlineList(value)
246+ } else {
247+ items, consumed := parseBlockList(lines[i+1:])
248+ s.AllowedTools = items
249+ i += consumed
250+ }
251+ case "permissions":
252+ if value != "" {
253+ // Unusual layout; ignore single-line for now.
254+ i++
255+ continue
256+ }
257+ perms, consumed := parsePermissionsBlock(lines[i+1:])
258+ s.Permissions = perms
259+ i += consumed
260+ }
261+ }
262+ i++
263+ }
264+}
265+
266+// unquote trims surrounding " or ' from a value.
267+func unquote(v string) string {
268+ if len(v) >= 2 {
269+ if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
270+ return v[1 : len(v)-1]
271+ }
272+ }
273+ return v
274+}
275+
276+// parseInlineList parses "[a, b, c]" or "[\"a\", \"b\"]".
277+func parseInlineList(v string) []string {
278+ v = strings.TrimSpace(v)
279+ v = strings.TrimPrefix(v, "[")
280+ v = strings.TrimSuffix(v, "]")
281+ if v == "" {
282+ return nil
283+ }
284+ parts := strings.Split(v, ",")
285+ out := make([]string, 0, len(parts))
286+ for _, p := range parts {
287+ out = append(out, unquote(strings.TrimSpace(p)))
288+ }
289+ return out
290+}
291+
292+// parseBlockList consumes indented "- item" lines until a less-
293+// indented line. Returns the items + how many lines to skip.
294+func parseBlockList(lines []string) ([]string, int) {
295+ var out []string
296+ consumed := 0
297+ for _, line := range lines {
298+ if strings.HasPrefix(line, " - ") || strings.HasPrefix(line, " - ") {
299+ out = append(out, unquote(strings.TrimSpace(strings.TrimPrefix(strings.TrimLeft(line, " "), "-"))))
300+ consumed++
301+ continue
302+ }
303+ if strings.TrimSpace(line) == "" {
304+ consumed++
305+ continue
306+ }
307+ break
308+ }
309+ return out, consumed
310+}
311+
312+// parsePermissionsBlock parses an indented map of tool->[patterns].
313+//
314+// permissions:
315+// bash: ["git diff*", "git log*"]
316+// read: ["./*.go"]
317+func parsePermissionsBlock(lines []string) (map[string][]string, int) {
318+ out := map[string][]string{}
319+ consumed := 0
320+ for _, line := range lines {
321+ if !strings.HasPrefix(line, " ") {
322+ break
323+ }
324+ trimmed := strings.TrimSpace(line)
325+ if trimmed == "" {
326+ consumed++
327+ continue
328+ }
329+ colon := strings.IndexByte(trimmed, ':')
330+ if colon < 0 {
331+ break
332+ }
333+ key := strings.TrimSpace(trimmed[:colon])
334+ val := strings.TrimSpace(trimmed[colon+1:])
335+ if val == "" {
336+ break // we don't support nested-block lists for permissions
337+ }
338+ out[key] = parseInlineList(val)
339+ consumed++
340+ }
341+ return out, consumed
342+}
@@ -0,0 +1,342 @@
1+// Package skills implements zot's reusable-instruction system.
2+//
3+// A skill is a per-folder SKILL.md file with a YAML frontmatter
4+// header. Skills live in well-known directories under the project or
5+// the user home; zot discovers them at startup, lists their names +
6+// one-line descriptions in the system prompt, and exposes a built-in
7+// "skill" tool the model uses to pull the full body on demand.
8+//
9+// The on-demand-load model keeps token usage cheap: only the
10+// short manifest goes into every request; the body is fetched as a
11+// tool result the one or two turns the model actually needs it.
12+//
13+// Discovery layout (priority order — first match wins per name):
14+//
15+// ./.zot/skills/<name>/SKILL.md — project (native)
16+// $ZOT_HOME/skills/<name>/SKILL.md — global (native)
17+// ./.claude/skills/<name>/SKILL.md — project (claude-compat)
18+// ~/.claude/skills/<name>/SKILL.md — global (claude-compat)
19+// ./.agents/skills/<name>/SKILL.md — project (agent-compat)
20+// ~/.agents/skills/<name>/SKILL.md — global (agent-compat)
21+//
22+// The compat paths are deliberate: a SKILL.md written for any of
23+// the related ecosystems works in zot unchanged.
24+package skills
25+
26+import (
27+ "fmt"
28+ "os"
29+ "path/filepath"
30+ "sort"
31+ "strings"
32+)
33+
34+// Skill is one discovered SKILL.md file.
35+type Skill struct {
36+ // Name is the skill identifier — what the model uses when it
37+ // invokes the `skill` tool. Taken from the frontmatter `name`
38+ // field; falls back to the directory basename.
39+ Name string
40+
41+ // Description is the one-line summary shown to the model in the
42+ // system-prompt manifest.
43+ Description string
44+
45+ // Body is the markdown after the frontmatter. Returned as the
46+ // tool result when the model loads this skill.
47+ Body string
48+
49+ // Path is the absolute path to the SKILL.md file.
50+ Path string
51+
52+ // Source is a human-friendly label describing where the skill
53+ // came from ("project", "global", "project (claude)", etc.).
54+ // Shown in the /skills picker.
55+ Source string
56+
57+ // AllowedTools and Permissions are parsed for forward-
58+ // compatibility but NOT enforced in this version. They appear
59+ // in the skill body so the model can self-regulate.
60+ AllowedTools []string
61+ Permissions map[string][]string
62+}
63+
64+// Discover walks every supported location, parses each SKILL.md, and
65+// returns the merged skill set. First-match-wins per name; the order
66+// matches the priority list in the package doc. Errors per skill are
67+// returned alongside the partial result so a single broken file
68+// doesn't suppress the rest.
69+func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
70+ var errs []error
71+ seen := map[string]*Skill{}
72+ for _, loc := range searchDirs(zotHome, cwd, userHome) {
73+ entries, err := os.ReadDir(loc.dir)
74+ if err != nil {
75+ continue // missing dir is fine
76+ }
77+ for _, e := range entries {
78+ if !e.IsDir() {
79+ continue
80+ }
81+ path := filepath.Join(loc.dir, e.Name(), "SKILL.md")
82+ s, err := load(path, loc.label)
83+ if err != nil {
84+ if !os.IsNotExist(err) {
85+ errs = append(errs, fmt.Errorf("%s: %w", path, err))
86+ }
87+ continue
88+ }
89+ if s.Name == "" {
90+ s.Name = e.Name()
91+ }
92+ if _, dup := seen[s.Name]; dup {
93+ continue // higher-priority location already won
94+ }
95+ seen[s.Name] = s
96+ }
97+ }
98+ out := make([]*Skill, 0, len(seen))
99+ for _, s := range seen {
100+ out = append(out, s)
101+ }
102+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
103+ return out, errs
104+}
105+
106+// SystemPromptAddendum returns the text to append to the system
107+// prompt when at least one skill is loaded. Empty string if none.
108+//
109+// Format kept short and explicit so the model reliably calls the
110+// `skill` tool with a name from the list rather than guessing.
111+func SystemPromptAddendum(skills []*Skill) string {
112+ if len(skills) == 0 {
113+ return ""
114+ }
115+ var sb strings.Builder
116+ sb.WriteString("Available skills (call the `skill` tool with one of these names to load full instructions):\n")
117+ for _, s := range skills {
118+ desc := strings.TrimSpace(s.Description)
119+ if desc == "" {
120+ desc = "(no description)"
121+ }
122+ fmt.Fprintf(&sb, "- %s — %s\n", s.Name, desc)
123+ }
124+ return sb.String()
125+}
126+
127+// FindByName returns the skill with the given name, or nil.
128+func FindByName(skills []*Skill, name string) *Skill {
129+ for _, s := range skills {
130+ if s.Name == name {
131+ return s
132+ }
133+ }
134+ return nil
135+}
136+
137+// ---- internals ----
138+
139+type location struct {
140+ dir string
141+ label string
142+}
143+
144+func searchDirs(zotHome, cwd, userHome string) []location {
145+ var out []location
146+ add := func(dir, label string) {
147+ if dir == "" {
148+ return
149+ }
150+ out = append(out, location{dir: dir, label: label})
151+ }
152+ if cwd != "" {
153+ add(filepath.Join(cwd, ".zot", "skills"), "project")
154+ }
155+ if zotHome != "" {
156+ add(filepath.Join(zotHome, "skills"), "global")
157+ }
158+ if cwd != "" {
159+ add(filepath.Join(cwd, ".claude", "skills"), "project (claude)")
160+ }
161+ if userHome != "" {
162+ add(filepath.Join(userHome, ".claude", "skills"), "global (claude)")
163+ }
164+ if cwd != "" {
165+ add(filepath.Join(cwd, ".agents", "skills"), "project (agents)")
166+ }
167+ if userHome != "" {
168+ add(filepath.Join(userHome, ".agents", "skills"), "global (agents)")
169+ }
170+ return out
171+}
172+
173+func load(path, source string) (*Skill, error) {
174+ raw, err := os.ReadFile(path)
175+ if err != nil {
176+ return nil, err
177+ }
178+ front, body := splitFrontmatter(string(raw))
179+ s := &Skill{
180+ Path: path,
181+ Source: source,
182+ Body: strings.TrimSpace(body),
183+ }
184+ parseFrontmatter(front, s)
185+ return s, nil
186+}
187+
188+// splitFrontmatter returns (yamlBlock, restOfDocument) for a string
189+// whose first non-empty line is "---". If no frontmatter is present,
190+// returns ("", entireString).
191+func splitFrontmatter(raw string) (string, string) {
192+ rest := strings.TrimLeft(raw, " \t\r\n")
193+ if !strings.HasPrefix(rest, "---") {
194+ return "", raw
195+ }
196+ rest = strings.TrimPrefix(rest, "---")
197+ // Drop the trailing newline after the opening ---.
198+ if i := strings.IndexByte(rest, '\n'); i >= 0 {
199+ rest = rest[i+1:]
200+ }
201+ end := strings.Index(rest, "\n---")
202+ if end < 0 {
203+ return "", raw // malformed; treat as no frontmatter
204+ }
205+ front := rest[:end]
206+ body := rest[end+len("\n---"):]
207+ body = strings.TrimLeft(body, " \t\r\n")
208+ return front, body
209+}
210+
211+// parseFrontmatter handles the small subset of YAML zot recognizes:
212+// - simple `key: value` lines
213+// - `key: [a, b, c]` flow-style lists
214+// - `key:` followed by indented `- item` block lists
215+// - nested `key:` followed by indented `subkey: [...]` for permissions
216+//
217+// Anything more elaborate is ignored. We deliberately avoid a yaml
218+// dependency to keep zot's binary lean.
219+func parseFrontmatter(front string, s *Skill) {
220+ lines := strings.Split(front, "\n")
221+ i := 0
222+ for i < len(lines) {
223+ line := lines[i]
224+ trimmed := strings.TrimSpace(line)
225+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
226+ i++
227+ continue
228+ }
229+ // Top-level key: value or key:
230+ if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
231+ colon := strings.IndexByte(trimmed, ':')
232+ if colon < 0 {
233+ i++
234+ continue
235+ }
236+ key := strings.TrimSpace(trimmed[:colon])
237+ value := strings.TrimSpace(trimmed[colon+1:])
238+ switch key {
239+ case "name":
240+ s.Name = unquote(value)
241+ case "description":
242+ s.Description = unquote(value)
243+ case "allowed-tools", "allowed_tools":
244+ if value != "" {
245+ s.AllowedTools = parseInlineList(value)
246+ } else {
247+ items, consumed := parseBlockList(lines[i+1:])
248+ s.AllowedTools = items
249+ i += consumed
250+ }
251+ case "permissions":
252+ if value != "" {
253+ // Unusual layout; ignore single-line for now.
254+ i++
255+ continue
256+ }
257+ perms, consumed := parsePermissionsBlock(lines[i+1:])
258+ s.Permissions = perms
259+ i += consumed
260+ }
261+ }
262+ i++
263+ }
264+}
265+
266+// unquote trims surrounding " or ' from a value.
267+func unquote(v string) string {
268+ if len(v) >= 2 {
269+ if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
270+ return v[1 : len(v)-1]
271+ }
272+ }
273+ return v
274+}
275+
276+// parseInlineList parses "[a, b, c]" or "[\"a\", \"b\"]".
277+func parseInlineList(v string) []string {
278+ v = strings.TrimSpace(v)
279+ v = strings.TrimPrefix(v, "[")
280+ v = strings.TrimSuffix(v, "]")
281+ if v == "" {
282+ return nil
283+ }
284+ parts := strings.Split(v, ",")
285+ out := make([]string, 0, len(parts))
286+ for _, p := range parts {
287+ out = append(out, unquote(strings.TrimSpace(p)))
288+ }
289+ return out
290+}
291+
292+// parseBlockList consumes indented "- item" lines until a less-
293+// indented line. Returns the items + how many lines to skip.
294+func parseBlockList(lines []string) ([]string, int) {
295+ var out []string
296+ consumed := 0
297+ for _, line := range lines {
298+ if strings.HasPrefix(line, " - ") || strings.HasPrefix(line, " - ") {
299+ out = append(out, unquote(strings.TrimSpace(strings.TrimPrefix(strings.TrimLeft(line, " "), "-"))))
300+ consumed++
301+ continue
302+ }
303+ if strings.TrimSpace(line) == "" {
304+ consumed++
305+ continue
306+ }
307+ break
308+ }
309+ return out, consumed
310+}
311+
312+// parsePermissionsBlock parses an indented map of tool->[patterns].
313+//
314+// permissions:
315+// bash: ["git diff*", "git log*"]
316+// read: ["./*.go"]
317+func parsePermissionsBlock(lines []string) (map[string][]string, int) {
318+ out := map[string][]string{}
319+ consumed := 0
320+ for _, line := range lines {
321+ if !strings.HasPrefix(line, " ") {
322+ break
323+ }
324+ trimmed := strings.TrimSpace(line)
325+ if trimmed == "" {
326+ consumed++
327+ continue
328+ }
329+ colon := strings.IndexByte(trimmed, ':')
330+ if colon < 0 {
331+ break
332+ }
333+ key := strings.TrimSpace(trimmed[:colon])
334+ val := strings.TrimSpace(trimmed[colon+1:])
335+ if val == "" {
336+ break // we don't support nested-block lists for permissions
337+ }
338+ out[key] = parseInlineList(val)
339+ consumed++
340+ }
341+ return out, consumed
342+}
A internal/skills/skills_test.go +104 -0
@@ -0,0 +1,104 @@
1+package skills
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestSplitFrontmatter(t *testing.T) {
10+ in := "---\nname: foo\ndescription: bar\n---\nbody text\n"
11+ front, body := splitFrontmatter(in)
12+ if front != "name: foo\ndescription: bar" {
13+ t.Errorf("front = %q", front)
14+ }
15+ if body != "body text\n" {
16+ t.Errorf("body = %q", body)
17+ }
18+
19+ front2, body2 := splitFrontmatter("no frontmatter here")
20+ if front2 != "" || body2 != "no frontmatter here" {
21+ t.Errorf("expected pass-through, got front=%q body=%q", front2, body2)
22+ }
23+}
24+
25+func TestParseFrontmatter(t *testing.T) {
26+ front := `name: code-review
27+description: "Review a recent change."
28+allowed-tools: [read, bash]
29+permissions:
30+ bash: ["git diff*", "git log*"]
31+`
32+ s := &Skill{}
33+ parseFrontmatter(front, s)
34+ if s.Name != "code-review" {
35+ t.Errorf("name = %q", s.Name)
36+ }
37+ if s.Description != "Review a recent change." {
38+ t.Errorf("description = %q", s.Description)
39+ }
40+ if got := s.AllowedTools; len(got) != 2 || got[0] != "read" || got[1] != "bash" {
41+ t.Errorf("allowed-tools = %v", got)
42+ }
43+ if got := s.Permissions["bash"]; len(got) != 2 || got[0] != "git diff*" || got[1] != "git log*" {
44+ t.Errorf("permissions[bash] = %v", got)
45+ }
46+}
47+
48+func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
49+ tmp := t.TempDir()
50+ zotHome := filepath.Join(tmp, "home")
51+ cwd := filepath.Join(tmp, "proj")
52+
53+ mk := func(dir, name, desc string) {
54+ full := filepath.Join(dir, name)
55+ os.MkdirAll(full, 0o755)
56+ body := "---\nname: " + name + "\ndescription: " + desc + "\n---\n# " + name + "\n"
57+ os.WriteFile(filepath.Join(full, "SKILL.md"), []byte(body), 0o644)
58+ }
59+
60+ // Same skill name in BOTH project and global; project should win.
61+ mk(filepath.Join(cwd, ".zot", "skills"), "shared", "project version")
62+ mk(filepath.Join(zotHome, "skills"), "shared", "global version")
63+ // Unique skill in global only.
64+ mk(filepath.Join(zotHome, "skills"), "global-only", "from global")
65+
66+ skills, errs := Discover(zotHome, cwd, "")
67+ if len(errs) > 0 {
68+ t.Fatalf("errs: %v", errs)
69+ }
70+ if len(skills) != 2 {
71+ t.Fatalf("expected 2 skills, got %d (%v)", len(skills), skills)
72+ }
73+ shared := FindByName(skills, "shared")
74+ if shared == nil || shared.Description != "project version" {
75+ t.Errorf("expected project to win for 'shared', got %v", shared)
76+ }
77+ if FindByName(skills, "global-only") == nil {
78+ t.Errorf("global-only skill missing")
79+ }
80+}
81+
82+func TestSystemPromptAddendum(t *testing.T) {
83+ skills := []*Skill{
84+ {Name: "a", Description: "Do A."},
85+ {Name: "b", Description: "Do B."},
86+ }
87+ out := SystemPromptAddendum(skills)
88+ if want := "- a — Do A.\n- b — Do B.\n"; !contains(out, want) {
89+ t.Errorf("addendum missing entries:\n%s", out)
90+ }
91+}
92+
93+func contains(s, sub string) bool {
94+ return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && stringIndex(s, sub) >= 0))
95+}
96+
97+func stringIndex(s, sub string) int {
98+ for i := 0; i+len(sub) <= len(s); i++ {
99+ if s[i:i+len(sub)] == sub {
100+ return i
101+ }
102+ }
103+ return -1
104+}
@@ -0,0 +1,104 @@
1+package skills
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestSplitFrontmatter(t *testing.T) {
10+ in := "---\nname: foo\ndescription: bar\n---\nbody text\n"
11+ front, body := splitFrontmatter(in)
12+ if front != "name: foo\ndescription: bar" {
13+ t.Errorf("front = %q", front)
14+ }
15+ if body != "body text\n" {
16+ t.Errorf("body = %q", body)
17+ }
18+
19+ front2, body2 := splitFrontmatter("no frontmatter here")
20+ if front2 != "" || body2 != "no frontmatter here" {
21+ t.Errorf("expected pass-through, got front=%q body=%q", front2, body2)
22+ }
23+}
24+
25+func TestParseFrontmatter(t *testing.T) {
26+ front := `name: code-review
27+description: "Review a recent change."
28+allowed-tools: [read, bash]
29+permissions:
30+ bash: ["git diff*", "git log*"]
31+`
32+ s := &Skill{}
33+ parseFrontmatter(front, s)
34+ if s.Name != "code-review" {
35+ t.Errorf("name = %q", s.Name)
36+ }
37+ if s.Description != "Review a recent change." {
38+ t.Errorf("description = %q", s.Description)
39+ }
40+ if got := s.AllowedTools; len(got) != 2 || got[0] != "read" || got[1] != "bash" {
41+ t.Errorf("allowed-tools = %v", got)
42+ }
43+ if got := s.Permissions["bash"]; len(got) != 2 || got[0] != "git diff*" || got[1] != "git log*" {
44+ t.Errorf("permissions[bash] = %v", got)
45+ }
46+}
47+
48+func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
49+ tmp := t.TempDir()
50+ zotHome := filepath.Join(tmp, "home")
51+ cwd := filepath.Join(tmp, "proj")
52+
53+ mk := func(dir, name, desc string) {
54+ full := filepath.Join(dir, name)
55+ os.MkdirAll(full, 0o755)
56+ body := "---\nname: " + name + "\ndescription: " + desc + "\n---\n# " + name + "\n"
57+ os.WriteFile(filepath.Join(full, "SKILL.md"), []byte(body), 0o644)
58+ }
59+
60+ // Same skill name in BOTH project and global; project should win.
61+ mk(filepath.Join(cwd, ".zot", "skills"), "shared", "project version")
62+ mk(filepath.Join(zotHome, "skills"), "shared", "global version")
63+ // Unique skill in global only.
64+ mk(filepath.Join(zotHome, "skills"), "global-only", "from global")
65+
66+ skills, errs := Discover(zotHome, cwd, "")
67+ if len(errs) > 0 {
68+ t.Fatalf("errs: %v", errs)
69+ }
70+ if len(skills) != 2 {
71+ t.Fatalf("expected 2 skills, got %d (%v)", len(skills), skills)
72+ }
73+ shared := FindByName(skills, "shared")
74+ if shared == nil || shared.Description != "project version" {
75+ t.Errorf("expected project to win for 'shared', got %v", shared)
76+ }
77+ if FindByName(skills, "global-only") == nil {
78+ t.Errorf("global-only skill missing")
79+ }
80+}
81+
82+func TestSystemPromptAddendum(t *testing.T) {
83+ skills := []*Skill{
84+ {Name: "a", Description: "Do A."},
85+ {Name: "b", Description: "Do B."},
86+ }
87+ out := SystemPromptAddendum(skills)
88+ if want := "- a — Do A.\n- b — Do B.\n"; !contains(out, want) {
89+ t.Errorf("addendum missing entries:\n%s", out)
90+ }
91+}
92+
93+func contains(s, sub string) bool {
94+ return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && stringIndex(s, sub) >= 0))
95+}
96+
97+func stringIndex(s, sub string) int {
98+ for i := 0; i+len(sub) <= len(s); i++ {
99+ if s[i:i+len(sub)] == sub {
100+ return i
101+ }
102+ }
103+ return -1
104+}
A internal/skills/tool.go +108 -0
@@ -0,0 +1,108 @@
1+package skills
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/patriceckhart/zot/internal/core"
10+ "github.com/patriceckhart/zot/internal/provider"
11+)
12+
13+// Tool implements core.Tool, exposing a `skill` tool the LLM can call
14+// to load the body of a discovered skill on demand. The system-prompt
15+// addendum lists the available names; this tool returns the full
16+// markdown for the requested one.
17+//
18+// The list of skills is held behind a mutex so tests / future
19+// /reload-skills wiring can swap in a fresh set without races.
20+type Tool struct {
21+ mu sync.RWMutex
22+ skills []*Skill
23+}
24+
25+// NewTool returns a skill loader tool seeded with the given skills.
26+// Pass the slice from Discover().
27+func NewTool(skills []*Skill) *Tool { return &Tool{skills: skills} }
28+
29+// SetSkills atomically replaces the underlying skill set. Used when
30+// the user re-runs discovery (e.g. after editing a SKILL.md).
31+func (t *Tool) SetSkills(s []*Skill) {
32+ t.mu.Lock()
33+ t.skills = s
34+ t.mu.Unlock()
35+}
36+
37+// Skills returns a snapshot for callers that need to render the
38+// current set (e.g. the /skills picker).
39+func (t *Tool) Skills() []*Skill {
40+ t.mu.RLock()
41+ defer t.mu.RUnlock()
42+ out := make([]*Skill, len(t.skills))
43+ copy(out, t.skills)
44+ return out
45+}
46+
47+// ---- core.Tool implementation ----
48+
49+// Name is the LLM-facing tool name.
50+func (*Tool) Name() string { return "skill" }
51+
52+// Description tells the model what this tool does. Kept blunt so the
53+// model reliably uses it instead of guessing what a "skill" is.
54+func (*Tool) Description() string {
55+ return "Load the full body of a named skill. Use this when the user's request matches one of the skills listed in the system prompt; the tool returns the skill's instructions, which you should then follow."
56+}
57+
58+// Schema is one required string parameter: the skill name.
59+func (*Tool) Schema() json.RawMessage {
60+ return json.RawMessage(`{
61+ "type": "object",
62+ "properties": {
63+ "name": {
64+ "type": "string",
65+ "description": "The skill name (must match one listed in the system prompt)."
66+ }
67+ },
68+ "required": ["name"]
69+ }`)
70+}
71+
72+// Execute returns the markdown body of the requested skill.
73+func (t *Tool) Execute(ctx context.Context, args json.RawMessage, progress func(string)) (core.ToolResult, error) {
74+ var in struct {
75+ Name string `json:"name"`
76+ }
77+ if err := json.Unmarshal(args, &in); err != nil {
78+ return core.ToolResult{
79+ IsError: true,
80+ Content: []provider.Content{provider.TextBlock{Text: "skill: invalid args: " + err.Error()}},
81+ }, nil
82+ }
83+ if in.Name == "" {
84+ return core.ToolResult{
85+ IsError: true,
86+ Content: []provider.Content{provider.TextBlock{Text: "skill: name is required"}},
87+ }, nil
88+ }
89+
90+ t.mu.RLock()
91+ s := FindByName(t.skills, in.Name)
92+ t.mu.RUnlock()
93+ if s == nil {
94+ return core.ToolResult{
95+ IsError: true,
96+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("skill: no skill named %q (run /skills in zot to see what's available)", in.Name)}},
97+ }, nil
98+ }
99+
100+ header := fmt.Sprintf("# Skill: %s\n\n%s\n\n---\n\n", s.Name, s.Description)
101+ return core.ToolResult{
102+ Content: []provider.Content{provider.TextBlock{Text: header + s.Body}},
103+ Details: map[string]any{
104+ "skill": s.Name,
105+ "path": s.Path,
106+ },
107+ }, nil
108+}
@@ -0,0 +1,108 @@
1+package skills
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/patriceckhart/zot/internal/core"
10+ "github.com/patriceckhart/zot/internal/provider"
11+)
12+
13+// Tool implements core.Tool, exposing a `skill` tool the LLM can call
14+// to load the body of a discovered skill on demand. The system-prompt
15+// addendum lists the available names; this tool returns the full
16+// markdown for the requested one.
17+//
18+// The list of skills is held behind a mutex so tests / future
19+// /reload-skills wiring can swap in a fresh set without races.
20+type Tool struct {
21+ mu sync.RWMutex
22+ skills []*Skill
23+}
24+
25+// NewTool returns a skill loader tool seeded with the given skills.
26+// Pass the slice from Discover().
27+func NewTool(skills []*Skill) *Tool { return &Tool{skills: skills} }
28+
29+// SetSkills atomically replaces the underlying skill set. Used when
30+// the user re-runs discovery (e.g. after editing a SKILL.md).
31+func (t *Tool) SetSkills(s []*Skill) {
32+ t.mu.Lock()
33+ t.skills = s
34+ t.mu.Unlock()
35+}
36+
37+// Skills returns a snapshot for callers that need to render the
38+// current set (e.g. the /skills picker).
39+func (t *Tool) Skills() []*Skill {
40+ t.mu.RLock()
41+ defer t.mu.RUnlock()
42+ out := make([]*Skill, len(t.skills))
43+ copy(out, t.skills)
44+ return out
45+}
46+
47+// ---- core.Tool implementation ----
48+
49+// Name is the LLM-facing tool name.
50+func (*Tool) Name() string { return "skill" }
51+
52+// Description tells the model what this tool does. Kept blunt so the
53+// model reliably uses it instead of guessing what a "skill" is.
54+func (*Tool) Description() string {
55+ return "Load the full body of a named skill. Use this when the user's request matches one of the skills listed in the system prompt; the tool returns the skill's instructions, which you should then follow."
56+}
57+
58+// Schema is one required string parameter: the skill name.
59+func (*Tool) Schema() json.RawMessage {
60+ return json.RawMessage(`{
61+ "type": "object",
62+ "properties": {
63+ "name": {
64+ "type": "string",
65+ "description": "The skill name (must match one listed in the system prompt)."
66+ }
67+ },
68+ "required": ["name"]
69+ }`)
70+}
71+
72+// Execute returns the markdown body of the requested skill.
73+func (t *Tool) Execute(ctx context.Context, args json.RawMessage, progress func(string)) (core.ToolResult, error) {
74+ var in struct {
75+ Name string `json:"name"`
76+ }
77+ if err := json.Unmarshal(args, &in); err != nil {
78+ return core.ToolResult{
79+ IsError: true,
80+ Content: []provider.Content{provider.TextBlock{Text: "skill: invalid args: " + err.Error()}},
81+ }, nil
82+ }
83+ if in.Name == "" {
84+ return core.ToolResult{
85+ IsError: true,
86+ Content: []provider.Content{provider.TextBlock{Text: "skill: name is required"}},
87+ }, nil
88+ }
89+
90+ t.mu.RLock()
91+ s := FindByName(t.skills, in.Name)
92+ t.mu.RUnlock()
93+ if s == nil {
94+ return core.ToolResult{
95+ IsError: true,
96+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("skill: no skill named %q (run /skills in zot to see what's available)", in.Name)}},
97+ }, nil
98+ }
99+
100+ header := fmt.Sprintf("# Skill: %s\n\n%s\n\n---\n\n", s.Name, s.Description)
101+ return core.ToolResult{
102+ Content: []provider.Content{provider.TextBlock{Text: header + s.Body}},
103+ Details: map[string]any{
104+ "skill": s.Name,
105+ "path": s.Path,
106+ },
107+ }, nil
108+}

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 477

↑↓ moveenter openesc close