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. | |||
| 8 | 8 | - three run modes (interactive tui, print, json). | |
| 9 | 9 | - built-in telegram bot. | |
| 10 | 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 | 12 | - no community atm. | |
| 12 | 13 | ||
| 13 | 14 | ## 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 | ## install | 14 | ## 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "os" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/patriceckhart/zot/internal/agent/tools" | |
| 7 | 8 | "github.com/patriceckhart/zot/internal/core" | |
| 8 | 9 | "github.com/patriceckhart/zot/internal/provider" | |
| 10 | + | "github.com/patriceckhart/zot/internal/skills" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | // Resolved is the effective configuration after merging CLI, config, defaults. | |
| @@ -24,6 +26,11 @@ type Resolved struct { | |||
| 24 | 26 | SystemPrompt string | |
| 25 | 27 | MaxSteps int | |
| 26 | 28 | 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 | 36 | // HasCredential reports whether a credential was resolved. | |
| @@ -91,13 +98,34 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 91 | 98 | ||
| 92 | 99 | sandbox := tools.NewSandbox(args.CWD) | |
| 93 | 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 | 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 | 124 | sys := BuildSystemPrompt(SystemPromptOpts{ | |
| 97 | 125 | CWD: args.CWD, | |
| 98 | 126 | Tools: summaries, | |
| 99 | 127 | Custom: args.SystemPrompt, | |
| 100 | - | Append: args.AppendSystemPrompt, | |
| 128 | + | Append: append_, | |
| 101 | 129 | }) | |
| 102 | 130 | ||
| 103 | 131 | reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning) | |
| @@ -121,6 +149,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 121 | 149 | SystemPrompt: sys, | |
| 122 | 150 | MaxSteps: max, | |
| 123 | 151 | Sandbox: sandbox, | |
| 152 | + | SkillTool: skillTool, | |
| 124 | 153 | }, nil | |
| 125 | 154 | } | |
| 126 | 155 | ||
| @@ -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 string | 26 | SystemPrompt string | ||
| 25 | MaxSteps int | 27 | MaxSteps int | ||
| 26 | Sandbox *tools.Sandbox | 28 | 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 | }, nil | 153 | }, nil | ||
| 125 | } | 154 | } | ||
| 126 | 155 | ||||
M internal/agent/cli.go +8 -0
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/patriceckhart/zot/internal/auth" | |
| 15 | 15 | "github.com/patriceckhart/zot/internal/core" | |
| 16 | 16 | "github.com/patriceckhart/zot/internal/provider" | |
| 17 | + | "github.com/patriceckhart/zot/internal/skills" | |
| 17 | 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 | 301 | BuildAgentFor: buildAgentFor, | |
| 301 | 302 | LoadSession: loadSession, | |
| 302 | 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 | 311 | PersistModel: func(providerName, model string) { | |
| 304 | 312 | // Update config.json so next launch uses the same pick. | |
| 305 | 313 | 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 ( | |||
| 13 | 13 | "github.com/patriceckhart/zot/internal/auth" | |
| 14 | 14 | "github.com/patriceckhart/zot/internal/core" | |
| 15 | 15 | "github.com/patriceckhart/zot/internal/provider" | |
| 16 | + | "github.com/patriceckhart/zot/internal/skills" | |
| 16 | 17 | "github.com/patriceckhart/zot/internal/tui" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -84,6 +85,11 @@ type InteractiveConfig struct { | |||
| 84 | 85 | // slash commands. Commands declared by extensions are looked up | |
| 85 | 86 | // AFTER the built-in catalog so a built-in name always wins. | |
| 86 | 87 | 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 | 95 | // Interactive is the TUI chat loop. | |
| @@ -133,6 +139,7 @@ type Interactive struct { | |||
| 133 | 139 | sessionDialog *sessionDialog | |
| 134 | 140 | jumpDialog *jumpDialog | |
| 135 | 141 | btwDialog *btwDialog | |
| 142 | + | skillsDialog *skillsDialog | |
| 136 | 143 | suggest *slashSuggester | |
| 137 | 144 | spin *spinner | |
| 138 | 145 | ||
| @@ -181,6 +188,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 181 | 188 | sessionDialog: newSessionDialog(), | |
| 182 | 189 | jumpDialog: newJumpDialog(), | |
| 183 | 190 | btwDialog: newBtwDialog(), | |
| 191 | + | skillsDialog: newSkillsDialog(), | |
| 184 | 192 | suggest: newSlashSuggester(), | |
| 185 | 193 | spin: newSpinner(), | |
| 186 | 194 | } | |
| @@ -363,7 +371,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 363 | 371 | // and the AfterFunc-driven invalidate got dropped on a | |
| 364 | 372 | // full channel. | |
| 365 | 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 | 375 | requestRedraw() // keep the spinner / dialog animation moving | |
| 368 | 376 | } | |
| 369 | 377 | } | |
| @@ -517,6 +525,8 @@ func (i *Interactive) redraw() { | |||
| 517 | 525 | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | |
| 518 | 526 | case i.btwDialog.Active(): | |
| 519 | 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 | 532 | // 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 | 776 | i.btwDialog.HandleKey(k, i.invalidate) | |
| 767 | 777 | 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 | 790 | // Global keys. | |
| 771 | 791 | switch k.Kind { | |
| @@ -1108,6 +1128,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 1108 | 1128 | i.openJumpDialog(parts[1:]) | |
| 1109 | 1129 | case "/btw": | |
| 1110 | 1130 | i.openBtwDialog(parts[1:]) | |
| 1131 | + | case "/skills": | |
| 1132 | + | i.openSkillsDialog() | |
| 1111 | 1133 | case "/compact": | |
| 1112 | 1134 | i.runCompact(ctx, false) | |
| 1113 | 1135 | case "/lock": | |
| @@ -1289,6 +1311,18 @@ func (i *Interactive) openBtwDialog(args []string) { | |||
| 1289 | 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 | 1326 | // openJumpDialog builds a /jump picker from the current transcript. | |
| 1293 | 1327 | // If the user typed "/jump foo" with a filter and it matches exactly | |
| 1294 | 1328 | // 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 up | 85 | // 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.Manager | 87 | 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 *sessionDialog | 139 | sessionDialog *sessionDialog | ||
| 134 | jumpDialog *jumpDialog | 140 | jumpDialog *jumpDialog | ||
| 135 | btwDialog *btwDialog | 141 | btwDialog *btwDialog | ||
| 142 | + | skillsDialog *skillsDialog | |||
| 136 | suggest *slashSuggester | 143 | suggest *slashSuggester | ||
| 137 | spin *spinner | 144 | 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 a | 371 | // 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 moving | 375 | 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, only | 532 | // 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 false | 777 | 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 exactly | 1327 | // 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{ | |||
| 35 | 35 | {"/sessions", "resume a previous session for this directory"}, | |
| 36 | 36 | {"/jump", "scroll the chat to a previous turn (or /jump <text>)"}, | |
| 37 | 37 | {"/btw", "side-chat that doesn't add to the main thread (saves tokens)"}, | |
| 38 | + | {"/skills", "list discovered skills (SKILL.md files)"}, | |
| 38 | 39 | {"/compact", "summarize and replace the transcript to free up context"}, | |
| 39 | 40 | {"/lock", "confine tools to the current directory"}, | |
| 40 | 41 | {"/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 | + | } | |||