feat(skills): built-in extension-author skill, hidden from /skills
The model now ships with a `write-zot-extension` skill compiled
into the binary. When the user asks for help authoring a zot
extension (slash command, LLM tool, audit hook, permission gate)
the model sees the skill in its system-prompt manifest, calls
the `skill` tool to load the body on demand, and walks the user
through the right answer with the wire format, manifest shape,
SDK examples (Go + TS + Python), and dev workflow already in
context. No need for the user to be in the zot repo or to ask
the model to read docs/extensions.md first.
Built-in skills:
- shipped via //go:embed at internal/skills/builtin/
- merged into Discover()'s output AFTER user skills, so a
user-installed skill with the same name shadows the built-in
(drop your own SKILL.md at $ZOT_HOME/skills/write-zot-extension/
to customise)
- marked Builtin: true on the Skill struct
- hidden from user-facing surfaces: VisibleSkills() filters them
so /skills only shows skills the user actually installed or
shipped in their project
The model side stays unchanged: system-prompt manifest still lists
built-ins (so the model knows they exist), the `skill` tool still
loads them on demand. Only the picker is filtered.
Verified live:
prompt: "List the names of the skills you have available"
-> code-review, test-fix, write-zot-extension
prompt: "I want to write a zot extension that adds a slash
command /pwd which inserts the current directory path
into the editor. What language should I use, and what
files do I need to create?"
-> [tool_call] skill({"name":"write-zot-extension"})
-> body returned
-> the model produces a complete extension with the right
manifest, the right hello/register/ready frames, action:
insert correctly chosen, and a remark about cwd capture.
The picker filter has its own unit test
(TestVisibleSkillsHidesBuiltins) and the existing Discover test
was updated to expect the built-in count without hardcoding it.- commit
- 2cffe048c9a007df2ed454973eb5d6b02b3f1bdc
- author
- patriceckhart · · 2026-04-19 13:55 UTC
- parent
- e425dbb
- signature
- unsigned
- refs
- tag: v0.0.30
5 files · +474 -4
M internal/agent/cli.go +6 -2
| @@ -409,10 +409,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 409 | 409 | Extensions: extMgr, | |
| 410 | 410 | SkillSnapshot: func() []*skills.Skill { | |
| 411 | 411 | // Re-discover so the picker reflects edits made during | |
| 412 | - | // the session. Cheap; SKILL.md files are small. | |
| 412 | + | // the session. Cheap; SKILL.md files are small. Filter | |
| 413 | + | // out built-in skills — they're hidden from user-facing | |
| 414 | + | // surfaces because they're implementation detail; the | |
| 415 | + | // model still sees them through the system-prompt | |
| 416 | + | // manifest and the skill tool. | |
| 413 | 417 | userHome, _ := os.UserHomeDir() | |
| 414 | 418 | list, _ := skills.Discover(ZotHome(), r.CWD, userHome) | |
| 415 | - | return list | |
| 419 | + | return skills.VisibleSkills(list) | |
| 416 | 420 | }, | |
| 417 | 421 | PersistModel: func(providerName, model string) { | |
| 418 | 422 | // Update config.json so next launch uses the same pick. | |
| @@ -409,10 +409,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 409 | Extensions: extMgr, | 409 | Extensions: extMgr, | ||
| 410 | SkillSnapshot: func() []*skills.Skill { | 410 | SkillSnapshot: func() []*skills.Skill { | ||
| 411 | // Re-discover so the picker reflects edits made during | 411 | // Re-discover so the picker reflects edits made during | ||
| 412 | - | // the session. Cheap; SKILL.md files are small. | 412 | + | // the session. Cheap; SKILL.md files are small. Filter |
| 413 | + | // out built-in skills — they're hidden from user-facing | |||
| 414 | + | // surfaces because they're implementation detail; the | |||
| 415 | + | // model still sees them through the system-prompt | |||
| 416 | + | // manifest and the skill tool. | |||
| 413 | userHome, _ := os.UserHomeDir() | 417 | userHome, _ := os.UserHomeDir() | ||
| 414 | list, _ := skills.Discover(ZotHome(), r.CWD, userHome) | 418 | list, _ := skills.Discover(ZotHome(), r.CWD, userHome) | ||
| 415 | - | return list | 419 | + | return skills.VisibleSkills(list) |
| 416 | }, | 420 | }, | ||
| 417 | PersistModel: func(providerName, model string) { | 421 | PersistModel: func(providerName, model string) { | ||
| 418 | // Update config.json so next launch uses the same pick. | 422 | // Update config.json so next launch uses the same pick. | ||
A internal/skills/builtin.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package skills | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "embed" | |
| 5 | + | "io/fs" | |
| 6 | + | "path" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // builtinFS holds the SKILL.md files zot ships with the binary. | |
| 11 | + | // They appear in the catalogue as ordinary skills — same on-demand | |
| 12 | + | // load via the `skill` tool, same /skills picker — but never need | |
| 13 | + | // to be installed by the user. A user-installed skill with the same | |
| 14 | + | // name shadows the built-in one (Discover's first-match-wins). | |
| 15 | + | // | |
| 16 | + | //go:embed all:builtin | |
| 17 | + | var builtinFS embed.FS | |
| 18 | + | ||
| 19 | + | // loadBuiltins returns every SKILL.md compiled into the binary. | |
| 20 | + | // Errors per file are silently dropped: built-ins are part of the | |
| 21 | + | // release; if one is malformed it's a release bug we want to surface | |
| 22 | + | // in tests, not panic in front of the user. | |
| 23 | + | func loadBuiltins() []*Skill { | |
| 24 | + | entries, err := fs.ReadDir(builtinFS, "builtin") | |
| 25 | + | if err != nil { | |
| 26 | + | return nil | |
| 27 | + | } | |
| 28 | + | var out []*Skill | |
| 29 | + | for _, e := range entries { | |
| 30 | + | if !e.IsDir() { | |
| 31 | + | continue | |
| 32 | + | } | |
| 33 | + | raw, err := fs.ReadFile(builtinFS, path.Join("builtin", e.Name(), "SKILL.md")) | |
| 34 | + | if err != nil { | |
| 35 | + | continue | |
| 36 | + | } | |
| 37 | + | front, body := splitFrontmatter(string(raw)) | |
| 38 | + | s := &Skill{ | |
| 39 | + | Path: "builtin:" + e.Name(), | |
| 40 | + | Source: "built-in", | |
| 41 | + | Body: strings.TrimSpace(body), | |
| 42 | + | Builtin: true, | |
| 43 | + | } | |
| 44 | + | parseFrontmatter(front, s) | |
| 45 | + | if s.Name == "" { | |
| 46 | + | s.Name = e.Name() | |
| 47 | + | } | |
| 48 | + | out = append(out, s) | |
| 49 | + | } | |
| 50 | + | return out | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package skills | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "embed" | |||
| 5 | + | "io/fs" | |||
| 6 | + | "path" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // builtinFS holds the SKILL.md files zot ships with the binary. | |||
| 11 | + | // They appear in the catalogue as ordinary skills — same on-demand | |||
| 12 | + | // load via the `skill` tool, same /skills picker — but never need | |||
| 13 | + | // to be installed by the user. A user-installed skill with the same | |||
| 14 | + | // name shadows the built-in one (Discover's first-match-wins). | |||
| 15 | + | // | |||
| 16 | + | //go:embed all:builtin | |||
| 17 | + | var builtinFS embed.FS | |||
| 18 | + | ||||
| 19 | + | // loadBuiltins returns every SKILL.md compiled into the binary. | |||
| 20 | + | // Errors per file are silently dropped: built-ins are part of the | |||
| 21 | + | // release; if one is malformed it's a release bug we want to surface | |||
| 22 | + | // in tests, not panic in front of the user. | |||
| 23 | + | func loadBuiltins() []*Skill { | |||
| 24 | + | entries, err := fs.ReadDir(builtinFS, "builtin") | |||
| 25 | + | if err != nil { | |||
| 26 | + | return nil | |||
| 27 | + | } | |||
| 28 | + | var out []*Skill | |||
| 29 | + | for _, e := range entries { | |||
| 30 | + | if !e.IsDir() { | |||
| 31 | + | continue | |||
| 32 | + | } | |||
| 33 | + | raw, err := fs.ReadFile(builtinFS, path.Join("builtin", e.Name(), "SKILL.md")) | |||
| 34 | + | if err != nil { | |||
| 35 | + | continue | |||
| 36 | + | } | |||
| 37 | + | front, body := splitFrontmatter(string(raw)) | |||
| 38 | + | s := &Skill{ | |||
| 39 | + | Path: "builtin:" + e.Name(), | |||
| 40 | + | Source: "built-in", | |||
| 41 | + | Body: strings.TrimSpace(body), | |||
| 42 | + | Builtin: true, | |||
| 43 | + | } | |||
| 44 | + | parseFrontmatter(front, s) | |||
| 45 | + | if s.Name == "" { | |||
| 46 | + | s.Name = e.Name() | |||
| 47 | + | } | |||
| 48 | + | out = append(out, s) | |||
| 49 | + | } | |||
| 50 | + | return out | |||
| 51 | + | } | |||
A internal/skills/builtin/write-zot-extension/SKILL.md +353 -0
| @@ -0,0 +1,353 @@ | |||
| 1 | + | --- | |
| 2 | + | name: write-zot-extension | |
| 3 | + | description: Help the user create a new zot extension (slash command, LLM tool, or guard) in any language. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | # Writing a zot extension | |
| 7 | + | ||
| 8 | + | Use this skill when the user asks for help building a zot extension — | |
| 9 | + | a new slash command, a new tool the LLM can call, an audit hook, or | |
| 10 | + | a permission gate. Skim this whole skill first, then collaborate | |
| 11 | + | with the user on the specific extension they want. | |
| 12 | + | ||
| 13 | + | ## What an extension is | |
| 14 | + | ||
| 15 | + | A zot extension is **an external executable** that zot launches as a | |
| 16 | + | subprocess and talks to over its stdin/stdout in newline-delimited | |
| 17 | + | JSON. It can be written in any language that can read/write JSON | |
| 18 | + | lines from stdio: Go, TypeScript (via tsx), Python, Rust, shell with | |
| 19 | + | jq, anything. Crash isolation is automatic; one bad extension never | |
| 20 | + | takes down zot. | |
| 21 | + | ||
| 22 | + | Three things an extension can do (any combination): | |
| 23 | + | ||
| 24 | + | 1. **Slash commands** — register `/foo` so the user can run it from | |
| 25 | + | the input. The handler returns a "prompt" (submitted to the | |
| 26 | + | agent), an "insert" (text dropped into the editor), a "display" | |
| 27 | + | (one-shot styled note in the chat), or a "noop". | |
| 28 | + | ||
| 29 | + | 2. **Tools** — register tools the LLM itself calls. Schema is | |
| 30 | + | JSON Schema; zot routes the model's `tool_call` to the | |
| 31 | + | extension's `tool_result`. Same lifecycle as built-in tools | |
| 32 | + | (read/write/edit/bash/skill). | |
| 33 | + | ||
| 34 | + | 3. **Lifecycle hooks** — subscribe to events | |
| 35 | + | (session_start, turn_start, tool_call, turn_end, | |
| 36 | + | assistant_message) for telemetry / audit / custom UI, or | |
| 37 | + | intercept tool calls before execution to refuse dangerous | |
| 38 | + | patterns. | |
| 39 | + | ||
| 40 | + | ## On-disk layout | |
| 41 | + | ||
| 42 | + | Each extension lives in its own directory: | |
| 43 | + | ||
| 44 | + | ``` | |
| 45 | + | ~/Library/Application Support/zot/extensions/<name>/ | |
| 46 | + | ├── extension.json # manifest (required) | |
| 47 | + | └── <executable> # whatever exec points at | |
| 48 | + | ``` | |
| 49 | + | ||
| 50 | + | Or project-local: `<project>/.zot/extensions/<name>/`. Project-local | |
| 51 | + | wins on name conflict. | |
| 52 | + | ||
| 53 | + | For ad-hoc use during development, skip the install step entirely | |
| 54 | + | and run `zot --ext PATH` (repeatable: `-e PATH -e PATH`). | |
| 55 | + | ||
| 56 | + | ### Manifest | |
| 57 | + | ||
| 58 | + | ```json | |
| 59 | + | { | |
| 60 | + | "name": "weather", | |
| 61 | + | "version": "1.0.0", | |
| 62 | + | "exec": "./weather", | |
| 63 | + | "args": [], | |
| 64 | + | "language": "go", | |
| 65 | + | "description": "current weather lookups for any city", | |
| 66 | + | "enabled": true | |
| 67 | + | } | |
| 68 | + | ``` | |
| 69 | + | ||
| 70 | + | Field rules: | |
| 71 | + | - `name` (required, unique) — id zot uses internally; matches the | |
| 72 | + | hello frame. Slash commands & tools live in the same name space | |
| 73 | + | as built-ins; conflicts are silently shadowed by built-ins. | |
| 74 | + | - `exec` (required) — the executable path. Resolution: | |
| 75 | + | - absolute: as-is | |
| 76 | + | - starts with `./` or `../`: relative to the manifest's directory | |
| 77 | + | - bare name (no separator): looked up via `$PATH` (e.g. `node`, | |
| 78 | + | `python3`, `npx`, `tsx`) | |
| 79 | + | - `args` — extra argv passed to `exec` (e.g. `["index.js"]`) | |
| 80 | + | - `language` — informational only (`"go"`, `"typescript"`, | |
| 81 | + | `"python"`, etc.) | |
| 82 | + | - `enabled` — defaults to true; set false to keep installed but skip | |
| 83 | + | ||
| 84 | + | ## Wire format | |
| 85 | + | ||
| 86 | + | Newline-delimited JSON in both directions. Top-level `type` is the | |
| 87 | + | discriminator. Optional `id` correlates command/tool requests with | |
| 88 | + | their responses. | |
| 89 | + | ||
| 90 | + | ### Required handshake | |
| 91 | + | ||
| 92 | + | The very first frame the extension sends is `hello`: | |
| 93 | + | ||
| 94 | + | ```json | |
| 95 | + | {"type":"hello","name":"weather","version":"1.0.0", | |
| 96 | + | "capabilities":["commands","tools"]} | |
| 97 | + | ``` | |
| 98 | + | ||
| 99 | + | Capabilities are advisory; current values are `commands`, `tools`, | |
| 100 | + | `events`. Send all that apply. | |
| 101 | + | ||
| 102 | + | zot replies with `hello_ack`: | |
| 103 | + | ||
| 104 | + | ```json | |
| 105 | + | {"type":"hello_ack","protocol_version":1,"zot_version":"0.0.x", | |
| 106 | + | "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"} | |
| 107 | + | ``` | |
| 108 | + | ||
| 109 | + | ### Registration (immediately after hello) | |
| 110 | + | ||
| 111 | + | Send registration frames in any order, then a single `ready` | |
| 112 | + | sentinel so zot can finalize the agent's tool registry: | |
| 113 | + | ||
| 114 | + | ```json | |
| 115 | + | {"type":"register_command","name":"weather","description":"current weather"} | |
| 116 | + | {"type":"register_tool","name":"weather","description":"Get current weather for a city.", | |
| 117 | + | "schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}} | |
| 118 | + | {"type":"subscribe","events":["tool_call"],"intercept":["tool_call"]} | |
| 119 | + | {"type":"ready"} | |
| 120 | + | ``` | |
| 121 | + | ||
| 122 | + | If you don't send `ready`, zot's idle watchdog auto-treats you as | |
| 123 | + | ready after 250ms of no frames, but always send it explicitly when | |
| 124 | + | you can — newer extensions on faster hosts shave that 250ms off. | |
| 125 | + | ||
| 126 | + | ### Runtime frames | |
| 127 | + | ||
| 128 | + | **zot → extension:** | |
| 129 | + | ||
| 130 | + | ```json | |
| 131 | + | {"type":"command_invoked","id":"abc","name":"weather","args":"berlin"} | |
| 132 | + | {"type":"tool_call","id":"def","name":"weather","args":{"city":"Berlin"}} | |
| 133 | + | {"type":"event","event":"turn_start","step":1} | |
| 134 | + | {"type":"event_intercept","id":"ghi","event":"tool_call", | |
| 135 | + | "tool_name":"bash","tool_args":{"command":"rm -rf /tmp/foo"}} | |
| 136 | + | {"type":"shutdown"} | |
| 137 | + | ``` | |
| 138 | + | ||
| 139 | + | **extension → zot (replies + spontaneous notifications):** | |
| 140 | + | ||
| 141 | + | ```json | |
| 142 | + | {"type":"command_response","id":"abc","action":"prompt", | |
| 143 | + | "prompt":"Show today's weather for Berlin in one line."} | |
| 144 | + | {"type":"tool_result","id":"def","content":[{"type":"text","text":"Berlin: 16°C, fog"}]} | |
| 145 | + | {"type":"event_intercept_response","id":"ghi","block":true, | |
| 146 | + | "reason":"refused: command matches the danger pattern \"rm -rf\""} | |
| 147 | + | {"type":"notify","level":"info","message":"refreshed cache"} | |
| 148 | + | {"type":"shutdown_ack"} | |
| 149 | + | ``` | |
| 150 | + | ||
| 151 | + | `command_response.action` values: | |
| 152 | + | - `"prompt"` — submit `prompt` as a fresh user message | |
| 153 | + | - `"insert"` — drop `insert` into the editor at the cursor | |
| 154 | + | - `"display"` — append `display` to chat as a one-shot note (no | |
| 155 | + | model call, not in transcript) | |
| 156 | + | - `"noop"` — handled internally; zot doesn't change the UI | |
| 157 | + | ||
| 158 | + | `tool_result.content[]` blocks: `{"type":"text","text":"..."}` or | |
| 159 | + | `{"type":"image","mime_type":"image/png","data":"<base64>"}`. | |
| 160 | + | ||
| 161 | + | Per-tool timeout: 60s. Per-intercept timeout: 5s. Missing the | |
| 162 | + | intercept timeout is treated as "allow" so an unresponsive guard | |
| 163 | + | never stalls the agent. | |
| 164 | + | ||
| 165 | + | ## Important rules | |
| 166 | + | ||
| 167 | + | - **stdout is reserved for the protocol.** Anything you print to | |
| 168 | + | stdout that isn't a JSON frame breaks the wire. Use stderr for | |
| 169 | + | logs / debug output (zot captures stderr to | |
| 170 | + | `$ZOT_HOME/logs/ext-<name>.log`). | |
| 171 | + | - **One JSON object per line.** No multi-line JSON. Always end | |
| 172 | + | every frame with `\n`. | |
| 173 | + | - **Flush after writing.** Most stdout writes are line-buffered when | |
| 174 | + | piped, which is fine, but explicitly flushing avoids surprise | |
| 175 | + | buffering on slow handlers. | |
| 176 | + | - Extension processes inherit the user's permissions. A bad | |
| 177 | + | extension can do anything the user can. | |
| 178 | + | ||
| 179 | + | ## Recommended layout per language | |
| 180 | + | ||
| 181 | + | ### Go (use the built-in SDK at pkg/zotext) | |
| 182 | + | ||
| 183 | + | ```go | |
| 184 | + | package main | |
| 185 | + | ||
| 186 | + | import ( | |
| 187 | + | "encoding/json" | |
| 188 | + | "github.com/patriceckhart/zot/pkg/zotext" | |
| 189 | + | ) | |
| 190 | + | ||
| 191 | + | func main() { | |
| 192 | + | ext := zotext.New("weather", "1.0.0") | |
| 193 | + | ||
| 194 | + | ext.Command("weather", "current weather for a city", | |
| 195 | + | func(args string) zotext.Response { | |
| 196 | + | return zotext.Prompt("Tell me the weather for " + args) | |
| 197 | + | }) | |
| 198 | + | ||
| 199 | + | ext.Tool("weather", "Get current weather for a city.", | |
| 200 | + | json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`), | |
| 201 | + | func(args json.RawMessage) zotext.ToolResult { | |
| 202 | + | var in struct{ City string `json:"city"` } | |
| 203 | + | if err := json.Unmarshal(args, &in); err != nil { | |
| 204 | + | return zotext.TextErrorResult("invalid args") | |
| 205 | + | } | |
| 206 | + | return zotext.TextResult(in.City + ": sunny, 21°C (fake)") | |
| 207 | + | }) | |
| 208 | + | ||
| 209 | + | if err := ext.Run(); err != nil { | |
| 210 | + | ext.Logf("fatal: %v", err) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ``` | |
| 214 | + | ||
| 215 | + | Build: `go build -o weather .` | |
| 216 | + | ||
| 217 | + | `extension.json`: | |
| 218 | + | ```json | |
| 219 | + | {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true} | |
| 220 | + | ``` | |
| 221 | + | ||
| 222 | + | ### TypeScript (no SDK; handles the protocol directly) | |
| 223 | + | ||
| 224 | + | Run via `tsx`, which executes `.ts` files without a build step. | |
| 225 | + | ||
| 226 | + | ```json | |
| 227 | + | {"name":"scratchpad","version":"1.0.0","exec":"tsx","args":["index.ts"],"language":"typescript","enabled":true} | |
| 228 | + | ``` | |
| 229 | + | ||
| 230 | + | ```typescript | |
| 231 | + | // index.ts (excerpt; see examples/extensions/scratchpad/index.ts for the full version) | |
| 232 | + | import { createInterface } from "node:readline"; | |
| 233 | + | import { stderr, stdin, stdout } from "node:process"; | |
| 234 | + | ||
| 235 | + | function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); } | |
| 236 | + | function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); } | |
| 237 | + | ||
| 238 | + | send({ type: "hello", name: "scratchpad", version: "1.0.0", | |
| 239 | + | capabilities: ["commands", "tools"] }); | |
| 240 | + | send({ type: "register_command", name: "note", description: "append a note" }); | |
| 241 | + | send({ type: "register_tool", name: "read_notes", | |
| 242 | + | description: "Read the user's scratchpad notes.", | |
| 243 | + | schema: { type: "object", properties: {} } }); | |
| 244 | + | send({ type: "ready" }); | |
| 245 | + | ||
| 246 | + | const rl = createInterface({ input: stdin, crlfDelay: Infinity }); | |
| 247 | + | rl.on("line", (line) => { | |
| 248 | + | const f = JSON.parse(line); | |
| 249 | + | if (f.type === "command_invoked" && f.name === "note") { | |
| 250 | + | send({ type: "command_response", id: f.id, action: "display", | |
| 251 | + | display: `noted: ${f.args}` }); | |
| 252 | + | } else if (f.type === "tool_call" && f.name === "read_notes") { | |
| 253 | + | send({ type: "tool_result", id: f.id, | |
| 254 | + | content: [{ type: "text", text: "(notes go here)" }] }); | |
| 255 | + | } else if (f.type === "shutdown") { | |
| 256 | + | send({ type: "shutdown_ack" }); | |
| 257 | + | rl.close(); | |
| 258 | + | } | |
| 259 | + | }); | |
| 260 | + | ``` | |
| 261 | + | ||
| 262 | + | `tsx` install: `npm install -g tsx`. Without global tsx, fall back | |
| 263 | + | to `"exec":"npx","args":["--yes","tsx","index.ts"]` (slower | |
| 264 | + | startup; npx checks the registry every launch). | |
| 265 | + | ||
| 266 | + | ### Python | |
| 267 | + | ||
| 268 | + | ```json | |
| 269 | + | {"name":"hello-py","version":"1.0.0","exec":"./hello.py","language":"python","enabled":true} | |
| 270 | + | ``` | |
| 271 | + | ||
| 272 | + | ```python | |
| 273 | + | #!/usr/bin/env python3 | |
| 274 | + | import json, sys | |
| 275 | + | ||
| 276 | + | def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush() | |
| 277 | + | ||
| 278 | + | emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]}) | |
| 279 | + | emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"}) | |
| 280 | + | emit({"type": "ready"}) | |
| 281 | + | ||
| 282 | + | for line in sys.stdin: | |
| 283 | + | msg = json.loads(line) | |
| 284 | + | if msg["type"] == "command_invoked": | |
| 285 | + | emit({"type": "command_response", "id": msg["id"], | |
| 286 | + | "action": "prompt", "prompt": "Say hi briefly."}) | |
| 287 | + | elif msg["type"] == "shutdown": | |
| 288 | + | emit({"type": "shutdown_ack"}) | |
| 289 | + | break | |
| 290 | + | ``` | |
| 291 | + | ||
| 292 | + | `chmod +x hello.py`. | |
| 293 | + | ||
| 294 | + | ## Install / dev workflow | |
| 295 | + | ||
| 296 | + | ```bash | |
| 297 | + | zot ext install ./weather # copy into $ZOT_HOME/extensions/ | |
| 298 | + | zot --ext ./weather # run from disk for one zot session (no install) | |
| 299 | + | zot --ext . # cwd is the extension dir | |
| 300 | + | zot ext list # show installed extensions | |
| 301 | + | zot ext logs weather # cat the extension's stderr | |
| 302 | + | zot ext logs weather -f # tail it | |
| 303 | + | zot ext disable weather # keep installed but skip on launch | |
| 304 | + | zot ext enable weather | |
| 305 | + | zot ext remove weather | |
| 306 | + | ``` | |
| 307 | + | ||
| 308 | + | For TS / Python extensions, no build step is needed — edit the source | |
| 309 | + | in place and relaunch zot. | |
| 310 | + | ||
| 311 | + | For Go, run `go build -o <name> .` in the extension directory after | |
| 312 | + | edits, then `zot ext install` (which copies the manifest + binary) | |
| 313 | + | or `zot --ext .` to test from the working tree. | |
| 314 | + | ||
| 315 | + | ## Manual debug | |
| 316 | + | ||
| 317 | + | The extension is just a process. Drive it directly with shell pipes | |
| 318 | + | to see exactly what's happening on the wire: | |
| 319 | + | ||
| 320 | + | ```bash | |
| 321 | + | { | |
| 322 | + | printf '%s\n' '{"type":"hello_ack","protocol_version":1,"zot_version":"x","provider":"a","model":"o","cwd":"/tmp"}' | |
| 323 | + | sleep 0.2 | |
| 324 | + | printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}' | |
| 325 | + | sleep 0.5 | |
| 326 | + | printf '%s\n' '{"type":"shutdown"}' | |
| 327 | + | } | ./weather | |
| 328 | + | ``` | |
| 329 | + | ||
| 330 | + | Compare what comes out of stdout to the expected wire format. If a | |
| 331 | + | frame doesn't match what zot expects, it's discarded silently and | |
| 332 | + | logged to `ext-<name>.log`. | |
| 333 | + | ||
| 334 | + | ## Process to follow with the user | |
| 335 | + | ||
| 336 | + | 1. Ask what the extension should DO. One sentence. | |
| 337 | + | 2. Pick the right capability: | |
| 338 | + | - "I want a slash command that triggers a prompt" → `command` only | |
| 339 | + | - "I want the model to be able to do X" → `tool` | |
| 340 | + | - "I want to gate / log every bash command" → `event` + `intercept` | |
| 341 | + | 3. Pick a language. Default to **Go via pkg/zotext** for new | |
| 342 | + | extensions if the user has Go installed; **TypeScript via tsx** | |
| 343 | + | if they prefer JS-flavored ergonomics; **Python** for one-off | |
| 344 | + | scripts. | |
| 345 | + | 4. Write the extension dir (manifest + source). | |
| 346 | + | 5. For Go, build it. For TS / Python, mark the script executable. | |
| 347 | + | 6. Suggest `zot --ext <path>` for testing without committing to an | |
| 348 | + | install. | |
| 349 | + | 7. When happy, `zot ext install <path>`. | |
| 350 | + | ||
| 351 | + | Don't try to write a full SDK or framework on top of the protocol | |
| 352 | + | unless the user asked for one — the wire format is small enough | |
| 353 | + | that a 30-line raw script is the right answer for most extensions. | |
| @@ -0,0 +1,353 @@ | |||||
| 1 | + | --- | |||
| 2 | + | name: write-zot-extension | |||
| 3 | + | description: Help the user create a new zot extension (slash command, LLM tool, or guard) in any language. | |||
| 4 | + | --- | |||
| 5 | + | ||||
| 6 | + | # Writing a zot extension | |||
| 7 | + | ||||
| 8 | + | Use this skill when the user asks for help building a zot extension — | |||
| 9 | + | a new slash command, a new tool the LLM can call, an audit hook, or | |||
| 10 | + | a permission gate. Skim this whole skill first, then collaborate | |||
| 11 | + | with the user on the specific extension they want. | |||
| 12 | + | ||||
| 13 | + | ## What an extension is | |||
| 14 | + | ||||
| 15 | + | A zot extension is **an external executable** that zot launches as a | |||
| 16 | + | subprocess and talks to over its stdin/stdout in newline-delimited | |||
| 17 | + | JSON. It can be written in any language that can read/write JSON | |||
| 18 | + | lines from stdio: Go, TypeScript (via tsx), Python, Rust, shell with | |||
| 19 | + | jq, anything. Crash isolation is automatic; one bad extension never | |||
| 20 | + | takes down zot. | |||
| 21 | + | ||||
| 22 | + | Three things an extension can do (any combination): | |||
| 23 | + | ||||
| 24 | + | 1. **Slash commands** — register `/foo` so the user can run it from | |||
| 25 | + | the input. The handler returns a "prompt" (submitted to the | |||
| 26 | + | agent), an "insert" (text dropped into the editor), a "display" | |||
| 27 | + | (one-shot styled note in the chat), or a "noop". | |||
| 28 | + | ||||
| 29 | + | 2. **Tools** — register tools the LLM itself calls. Schema is | |||
| 30 | + | JSON Schema; zot routes the model's `tool_call` to the | |||
| 31 | + | extension's `tool_result`. Same lifecycle as built-in tools | |||
| 32 | + | (read/write/edit/bash/skill). | |||
| 33 | + | ||||
| 34 | + | 3. **Lifecycle hooks** — subscribe to events | |||
| 35 | + | (session_start, turn_start, tool_call, turn_end, | |||
| 36 | + | assistant_message) for telemetry / audit / custom UI, or | |||
| 37 | + | intercept tool calls before execution to refuse dangerous | |||
| 38 | + | patterns. | |||
| 39 | + | ||||
| 40 | + | ## On-disk layout | |||
| 41 | + | ||||
| 42 | + | Each extension lives in its own directory: | |||
| 43 | + | ||||
| 44 | + | ``` | |||
| 45 | + | ~/Library/Application Support/zot/extensions/<name>/ | |||
| 46 | + | ├── extension.json # manifest (required) | |||
| 47 | + | └── <executable> # whatever exec points at | |||
| 48 | + | ``` | |||
| 49 | + | ||||
| 50 | + | Or project-local: `<project>/.zot/extensions/<name>/`. Project-local | |||
| 51 | + | wins on name conflict. | |||
| 52 | + | ||||
| 53 | + | For ad-hoc use during development, skip the install step entirely | |||
| 54 | + | and run `zot --ext PATH` (repeatable: `-e PATH -e PATH`). | |||
| 55 | + | ||||
| 56 | + | ### Manifest | |||
| 57 | + | ||||
| 58 | + | ```json | |||
| 59 | + | { | |||
| 60 | + | "name": "weather", | |||
| 61 | + | "version": "1.0.0", | |||
| 62 | + | "exec": "./weather", | |||
| 63 | + | "args": [], | |||
| 64 | + | "language": "go", | |||
| 65 | + | "description": "current weather lookups for any city", | |||
| 66 | + | "enabled": true | |||
| 67 | + | } | |||
| 68 | + | ``` | |||
| 69 | + | ||||
| 70 | + | Field rules: | |||
| 71 | + | - `name` (required, unique) — id zot uses internally; matches the | |||
| 72 | + | hello frame. Slash commands & tools live in the same name space | |||
| 73 | + | as built-ins; conflicts are silently shadowed by built-ins. | |||
| 74 | + | - `exec` (required) — the executable path. Resolution: | |||
| 75 | + | - absolute: as-is | |||
| 76 | + | - starts with `./` or `../`: relative to the manifest's directory | |||
| 77 | + | - bare name (no separator): looked up via `$PATH` (e.g. `node`, | |||
| 78 | + | `python3`, `npx`, `tsx`) | |||
| 79 | + | - `args` — extra argv passed to `exec` (e.g. `["index.js"]`) | |||
| 80 | + | - `language` — informational only (`"go"`, `"typescript"`, | |||
| 81 | + | `"python"`, etc.) | |||
| 82 | + | - `enabled` — defaults to true; set false to keep installed but skip | |||
| 83 | + | ||||
| 84 | + | ## Wire format | |||
| 85 | + | ||||
| 86 | + | Newline-delimited JSON in both directions. Top-level `type` is the | |||
| 87 | + | discriminator. Optional `id` correlates command/tool requests with | |||
| 88 | + | their responses. | |||
| 89 | + | ||||
| 90 | + | ### Required handshake | |||
| 91 | + | ||||
| 92 | + | The very first frame the extension sends is `hello`: | |||
| 93 | + | ||||
| 94 | + | ```json | |||
| 95 | + | {"type":"hello","name":"weather","version":"1.0.0", | |||
| 96 | + | "capabilities":["commands","tools"]} | |||
| 97 | + | ``` | |||
| 98 | + | ||||
| 99 | + | Capabilities are advisory; current values are `commands`, `tools`, | |||
| 100 | + | `events`. Send all that apply. | |||
| 101 | + | ||||
| 102 | + | zot replies with `hello_ack`: | |||
| 103 | + | ||||
| 104 | + | ```json | |||
| 105 | + | {"type":"hello_ack","protocol_version":1,"zot_version":"0.0.x", | |||
| 106 | + | "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"} | |||
| 107 | + | ``` | |||
| 108 | + | ||||
| 109 | + | ### Registration (immediately after hello) | |||
| 110 | + | ||||
| 111 | + | Send registration frames in any order, then a single `ready` | |||
| 112 | + | sentinel so zot can finalize the agent's tool registry: | |||
| 113 | + | ||||
| 114 | + | ```json | |||
| 115 | + | {"type":"register_command","name":"weather","description":"current weather"} | |||
| 116 | + | {"type":"register_tool","name":"weather","description":"Get current weather for a city.", | |||
| 117 | + | "schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}} | |||
| 118 | + | {"type":"subscribe","events":["tool_call"],"intercept":["tool_call"]} | |||
| 119 | + | {"type":"ready"} | |||
| 120 | + | ``` | |||
| 121 | + | ||||
| 122 | + | If you don't send `ready`, zot's idle watchdog auto-treats you as | |||
| 123 | + | ready after 250ms of no frames, but always send it explicitly when | |||
| 124 | + | you can — newer extensions on faster hosts shave that 250ms off. | |||
| 125 | + | ||||
| 126 | + | ### Runtime frames | |||
| 127 | + | ||||
| 128 | + | **zot → extension:** | |||
| 129 | + | ||||
| 130 | + | ```json | |||
| 131 | + | {"type":"command_invoked","id":"abc","name":"weather","args":"berlin"} | |||
| 132 | + | {"type":"tool_call","id":"def","name":"weather","args":{"city":"Berlin"}} | |||
| 133 | + | {"type":"event","event":"turn_start","step":1} | |||
| 134 | + | {"type":"event_intercept","id":"ghi","event":"tool_call", | |||
| 135 | + | "tool_name":"bash","tool_args":{"command":"rm -rf /tmp/foo"}} | |||
| 136 | + | {"type":"shutdown"} | |||
| 137 | + | ``` | |||
| 138 | + | ||||
| 139 | + | **extension → zot (replies + spontaneous notifications):** | |||
| 140 | + | ||||
| 141 | + | ```json | |||
| 142 | + | {"type":"command_response","id":"abc","action":"prompt", | |||
| 143 | + | "prompt":"Show today's weather for Berlin in one line."} | |||
| 144 | + | {"type":"tool_result","id":"def","content":[{"type":"text","text":"Berlin: 16°C, fog"}]} | |||
| 145 | + | {"type":"event_intercept_response","id":"ghi","block":true, | |||
| 146 | + | "reason":"refused: command matches the danger pattern \"rm -rf\""} | |||
| 147 | + | {"type":"notify","level":"info","message":"refreshed cache"} | |||
| 148 | + | {"type":"shutdown_ack"} | |||
| 149 | + | ``` | |||
| 150 | + | ||||
| 151 | + | `command_response.action` values: | |||
| 152 | + | - `"prompt"` — submit `prompt` as a fresh user message | |||
| 153 | + | - `"insert"` — drop `insert` into the editor at the cursor | |||
| 154 | + | - `"display"` — append `display` to chat as a one-shot note (no | |||
| 155 | + | model call, not in transcript) | |||
| 156 | + | - `"noop"` — handled internally; zot doesn't change the UI | |||
| 157 | + | ||||
| 158 | + | `tool_result.content[]` blocks: `{"type":"text","text":"..."}` or | |||
| 159 | + | `{"type":"image","mime_type":"image/png","data":"<base64>"}`. | |||
| 160 | + | ||||
| 161 | + | Per-tool timeout: 60s. Per-intercept timeout: 5s. Missing the | |||
| 162 | + | intercept timeout is treated as "allow" so an unresponsive guard | |||
| 163 | + | never stalls the agent. | |||
| 164 | + | ||||
| 165 | + | ## Important rules | |||
| 166 | + | ||||
| 167 | + | - **stdout is reserved for the protocol.** Anything you print to | |||
| 168 | + | stdout that isn't a JSON frame breaks the wire. Use stderr for | |||
| 169 | + | logs / debug output (zot captures stderr to | |||
| 170 | + | `$ZOT_HOME/logs/ext-<name>.log`). | |||
| 171 | + | - **One JSON object per line.** No multi-line JSON. Always end | |||
| 172 | + | every frame with `\n`. | |||
| 173 | + | - **Flush after writing.** Most stdout writes are line-buffered when | |||
| 174 | + | piped, which is fine, but explicitly flushing avoids surprise | |||
| 175 | + | buffering on slow handlers. | |||
| 176 | + | - Extension processes inherit the user's permissions. A bad | |||
| 177 | + | extension can do anything the user can. | |||
| 178 | + | ||||
| 179 | + | ## Recommended layout per language | |||
| 180 | + | ||||
| 181 | + | ### Go (use the built-in SDK at pkg/zotext) | |||
| 182 | + | ||||
| 183 | + | ```go | |||
| 184 | + | package main | |||
| 185 | + | ||||
| 186 | + | import ( | |||
| 187 | + | "encoding/json" | |||
| 188 | + | "github.com/patriceckhart/zot/pkg/zotext" | |||
| 189 | + | ) | |||
| 190 | + | ||||
| 191 | + | func main() { | |||
| 192 | + | ext := zotext.New("weather", "1.0.0") | |||
| 193 | + | ||||
| 194 | + | ext.Command("weather", "current weather for a city", | |||
| 195 | + | func(args string) zotext.Response { | |||
| 196 | + | return zotext.Prompt("Tell me the weather for " + args) | |||
| 197 | + | }) | |||
| 198 | + | ||||
| 199 | + | ext.Tool("weather", "Get current weather for a city.", | |||
| 200 | + | json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`), | |||
| 201 | + | func(args json.RawMessage) zotext.ToolResult { | |||
| 202 | + | var in struct{ City string `json:"city"` } | |||
| 203 | + | if err := json.Unmarshal(args, &in); err != nil { | |||
| 204 | + | return zotext.TextErrorResult("invalid args") | |||
| 205 | + | } | |||
| 206 | + | return zotext.TextResult(in.City + ": sunny, 21°C (fake)") | |||
| 207 | + | }) | |||
| 208 | + | ||||
| 209 | + | if err := ext.Run(); err != nil { | |||
| 210 | + | ext.Logf("fatal: %v", err) | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ``` | |||
| 214 | + | ||||
| 215 | + | Build: `go build -o weather .` | |||
| 216 | + | ||||
| 217 | + | `extension.json`: | |||
| 218 | + | ```json | |||
| 219 | + | {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true} | |||
| 220 | + | ``` | |||
| 221 | + | ||||
| 222 | + | ### TypeScript (no SDK; handles the protocol directly) | |||
| 223 | + | ||||
| 224 | + | Run via `tsx`, which executes `.ts` files without a build step. | |||
| 225 | + | ||||
| 226 | + | ```json | |||
| 227 | + | {"name":"scratchpad","version":"1.0.0","exec":"tsx","args":["index.ts"],"language":"typescript","enabled":true} | |||
| 228 | + | ``` | |||
| 229 | + | ||||
| 230 | + | ```typescript | |||
| 231 | + | // index.ts (excerpt; see examples/extensions/scratchpad/index.ts for the full version) | |||
| 232 | + | import { createInterface } from "node:readline"; | |||
| 233 | + | import { stderr, stdin, stdout } from "node:process"; | |||
| 234 | + | ||||
| 235 | + | function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); } | |||
| 236 | + | function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); } | |||
| 237 | + | ||||
| 238 | + | send({ type: "hello", name: "scratchpad", version: "1.0.0", | |||
| 239 | + | capabilities: ["commands", "tools"] }); | |||
| 240 | + | send({ type: "register_command", name: "note", description: "append a note" }); | |||
| 241 | + | send({ type: "register_tool", name: "read_notes", | |||
| 242 | + | description: "Read the user's scratchpad notes.", | |||
| 243 | + | schema: { type: "object", properties: {} } }); | |||
| 244 | + | send({ type: "ready" }); | |||
| 245 | + | ||||
| 246 | + | const rl = createInterface({ input: stdin, crlfDelay: Infinity }); | |||
| 247 | + | rl.on("line", (line) => { | |||
| 248 | + | const f = JSON.parse(line); | |||
| 249 | + | if (f.type === "command_invoked" && f.name === "note") { | |||
| 250 | + | send({ type: "command_response", id: f.id, action: "display", | |||
| 251 | + | display: `noted: ${f.args}` }); | |||
| 252 | + | } else if (f.type === "tool_call" && f.name === "read_notes") { | |||
| 253 | + | send({ type: "tool_result", id: f.id, | |||
| 254 | + | content: [{ type: "text", text: "(notes go here)" }] }); | |||
| 255 | + | } else if (f.type === "shutdown") { | |||
| 256 | + | send({ type: "shutdown_ack" }); | |||
| 257 | + | rl.close(); | |||
| 258 | + | } | |||
| 259 | + | }); | |||
| 260 | + | ``` | |||
| 261 | + | ||||
| 262 | + | `tsx` install: `npm install -g tsx`. Without global tsx, fall back | |||
| 263 | + | to `"exec":"npx","args":["--yes","tsx","index.ts"]` (slower | |||
| 264 | + | startup; npx checks the registry every launch). | |||
| 265 | + | ||||
| 266 | + | ### Python | |||
| 267 | + | ||||
| 268 | + | ```json | |||
| 269 | + | {"name":"hello-py","version":"1.0.0","exec":"./hello.py","language":"python","enabled":true} | |||
| 270 | + | ``` | |||
| 271 | + | ||||
| 272 | + | ```python | |||
| 273 | + | #!/usr/bin/env python3 | |||
| 274 | + | import json, sys | |||
| 275 | + | ||||
| 276 | + | def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush() | |||
| 277 | + | ||||
| 278 | + | emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]}) | |||
| 279 | + | emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"}) | |||
| 280 | + | emit({"type": "ready"}) | |||
| 281 | + | ||||
| 282 | + | for line in sys.stdin: | |||
| 283 | + | msg = json.loads(line) | |||
| 284 | + | if msg["type"] == "command_invoked": | |||
| 285 | + | emit({"type": "command_response", "id": msg["id"], | |||
| 286 | + | "action": "prompt", "prompt": "Say hi briefly."}) | |||
| 287 | + | elif msg["type"] == "shutdown": | |||
| 288 | + | emit({"type": "shutdown_ack"}) | |||
| 289 | + | break | |||
| 290 | + | ``` | |||
| 291 | + | ||||
| 292 | + | `chmod +x hello.py`. | |||
| 293 | + | ||||
| 294 | + | ## Install / dev workflow | |||
| 295 | + | ||||
| 296 | + | ```bash | |||
| 297 | + | zot ext install ./weather # copy into $ZOT_HOME/extensions/ | |||
| 298 | + | zot --ext ./weather # run from disk for one zot session (no install) | |||
| 299 | + | zot --ext . # cwd is the extension dir | |||
| 300 | + | zot ext list # show installed extensions | |||
| 301 | + | zot ext logs weather # cat the extension's stderr | |||
| 302 | + | zot ext logs weather -f # tail it | |||
| 303 | + | zot ext disable weather # keep installed but skip on launch | |||
| 304 | + | zot ext enable weather | |||
| 305 | + | zot ext remove weather | |||
| 306 | + | ``` | |||
| 307 | + | ||||
| 308 | + | For TS / Python extensions, no build step is needed — edit the source | |||
| 309 | + | in place and relaunch zot. | |||
| 310 | + | ||||
| 311 | + | For Go, run `go build -o <name> .` in the extension directory after | |||
| 312 | + | edits, then `zot ext install` (which copies the manifest + binary) | |||
| 313 | + | or `zot --ext .` to test from the working tree. | |||
| 314 | + | ||||
| 315 | + | ## Manual debug | |||
| 316 | + | ||||
| 317 | + | The extension is just a process. Drive it directly with shell pipes | |||
| 318 | + | to see exactly what's happening on the wire: | |||
| 319 | + | ||||
| 320 | + | ```bash | |||
| 321 | + | { | |||
| 322 | + | printf '%s\n' '{"type":"hello_ack","protocol_version":1,"zot_version":"x","provider":"a","model":"o","cwd":"/tmp"}' | |||
| 323 | + | sleep 0.2 | |||
| 324 | + | printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}' | |||
| 325 | + | sleep 0.5 | |||
| 326 | + | printf '%s\n' '{"type":"shutdown"}' | |||
| 327 | + | } | ./weather | |||
| 328 | + | ``` | |||
| 329 | + | ||||
| 330 | + | Compare what comes out of stdout to the expected wire format. If a | |||
| 331 | + | frame doesn't match what zot expects, it's discarded silently and | |||
| 332 | + | logged to `ext-<name>.log`. | |||
| 333 | + | ||||
| 334 | + | ## Process to follow with the user | |||
| 335 | + | ||||
| 336 | + | 1. Ask what the extension should DO. One sentence. | |||
| 337 | + | 2. Pick the right capability: | |||
| 338 | + | - "I want a slash command that triggers a prompt" → `command` only | |||
| 339 | + | - "I want the model to be able to do X" → `tool` | |||
| 340 | + | - "I want to gate / log every bash command" → `event` + `intercept` | |||
| 341 | + | 3. Pick a language. Default to **Go via pkg/zotext** for new | |||
| 342 | + | extensions if the user has Go installed; **TypeScript via tsx** | |||
| 343 | + | if they prefer JS-flavored ergonomics; **Python** for one-off | |||
| 344 | + | scripts. | |||
| 345 | + | 4. Write the extension dir (manifest + source). | |||
| 346 | + | 5. For Go, build it. For TS / Python, mark the script executable. | |||
| 347 | + | 6. Suggest `zot --ext <path>` for testing without committing to an | |||
| 348 | + | install. | |||
| 349 | + | 7. When happy, `zot ext install <path>`. | |||
| 350 | + | ||||
| 351 | + | Don't try to write a full SDK or framework on top of the protocol | |||
| 352 | + | unless the user asked for one — the wire format is small enough | |||
| 353 | + | that a 30-line raw script is the right answer for most extensions. | |||
M internal/skills/skills.go +35 -0
| @@ -54,6 +54,13 @@ type Skill struct { | |||
| 54 | 54 | // Shown in the /skills picker. | |
| 55 | 55 | Source string | |
| 56 | 56 | ||
| 57 | + | // Builtin marks skills that ship inside the zot binary. They are | |
| 58 | + | // fully active for the model (system-prompt manifest + skill | |
| 59 | + | // tool) but hidden from user-facing surfaces like the /skills | |
| 60 | + | // picker so users only see skills they actually installed or | |
| 61 | + | // shipped in their project. | |
| 62 | + | Builtin bool | |
| 63 | + | ||
| 57 | 64 | // AllowedTools and Permissions are parsed for forward- | |
| 58 | 65 | // compatibility but NOT enforced in this version. They appear | |
| 59 | 66 | // in the skill body so the model can self-regulate. | |
| @@ -61,11 +68,31 @@ type Skill struct { | |||
| 61 | 68 | Permissions map[string][]string | |
| 62 | 69 | } | |
| 63 | 70 | ||
| 71 | + | // VisibleSkills returns the subset of skills users should see in | |
| 72 | + | // pickers, /skills, and other interactive surfaces. Built-ins are | |
| 73 | + | // hidden because they're implementation detail; the model still | |
| 74 | + | // loads them through the system-prompt manifest + the skill tool. | |
| 75 | + | func VisibleSkills(in []*Skill) []*Skill { | |
| 76 | + | out := make([]*Skill, 0, len(in)) | |
| 77 | + | for _, s := range in { | |
| 78 | + | if s == nil || s.Builtin { | |
| 79 | + | continue | |
| 80 | + | } | |
| 81 | + | out = append(out, s) | |
| 82 | + | } | |
| 83 | + | return out | |
| 84 | + | } | |
| 85 | + | ||
| 64 | 86 | // Discover walks every supported location, parses each SKILL.md, and | |
| 65 | 87 | // returns the merged skill set. First-match-wins per name; the order | |
| 66 | 88 | // matches the priority list in the package doc. Errors per skill are | |
| 67 | 89 | // returned alongside the partial result so a single broken file | |
| 68 | 90 | // doesn't suppress the rest. | |
| 91 | + | // | |
| 92 | + | // Built-in skills (compiled into the zot binary) are added LAST so | |
| 93 | + | // any user-installed skill with the same name shadows the built-in. | |
| 94 | + | // That lets users customise the help text by dropping their own | |
| 95 | + | // SKILL.md with the same name into $ZOT_HOME/skills/<name>/. | |
| 69 | 96 | func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) { | |
| 70 | 97 | var errs []error | |
| 71 | 98 | seen := map[string]*Skill{} | |
| @@ -95,6 +122,14 @@ func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) { | |||
| 95 | 122 | seen[s.Name] = s | |
| 96 | 123 | } | |
| 97 | 124 | } | |
| 125 | + | // Built-ins fill in any name the user didn't already provide. | |
| 126 | + | for _, s := range loadBuiltins() { | |
| 127 | + | if _, dup := seen[s.Name]; dup { | |
| 128 | + | continue | |
| 129 | + | } | |
| 130 | + | seen[s.Name] = s | |
| 131 | + | } | |
| 132 | + | ||
| 98 | 133 | out := make([]*Skill, 0, len(seen)) | |
| 99 | 134 | for _, s := range seen { | |
| 100 | 135 | out = append(out, s) | |
| @@ -54,6 +54,13 @@ type Skill struct { | |||||
| 54 | // Shown in the /skills picker. | 54 | // Shown in the /skills picker. | ||
| 55 | Source string | 55 | Source string | ||
| 56 | 56 | ||||
| 57 | + | // Builtin marks skills that ship inside the zot binary. They are | |||
| 58 | + | // fully active for the model (system-prompt manifest + skill | |||
| 59 | + | // tool) but hidden from user-facing surfaces like the /skills | |||
| 60 | + | // picker so users only see skills they actually installed or | |||
| 61 | + | // shipped in their project. | |||
| 62 | + | Builtin bool | |||
| 63 | + | ||||
| 57 | // AllowedTools and Permissions are parsed for forward- | 64 | // AllowedTools and Permissions are parsed for forward- | ||
| 58 | // compatibility but NOT enforced in this version. They appear | 65 | // compatibility but NOT enforced in this version. They appear | ||
| 59 | // in the skill body so the model can self-regulate. | 66 | // in the skill body so the model can self-regulate. | ||
| @@ -61,11 +68,31 @@ type Skill struct { | |||||
| 61 | Permissions map[string][]string | 68 | Permissions map[string][]string | ||
| 62 | } | 69 | } | ||
| 63 | 70 | ||||
| 71 | + | // VisibleSkills returns the subset of skills users should see in | |||
| 72 | + | // pickers, /skills, and other interactive surfaces. Built-ins are | |||
| 73 | + | // hidden because they're implementation detail; the model still | |||
| 74 | + | // loads them through the system-prompt manifest + the skill tool. | |||
| 75 | + | func VisibleSkills(in []*Skill) []*Skill { | |||
| 76 | + | out := make([]*Skill, 0, len(in)) | |||
| 77 | + | for _, s := range in { | |||
| 78 | + | if s == nil || s.Builtin { | |||
| 79 | + | continue | |||
| 80 | + | } | |||
| 81 | + | out = append(out, s) | |||
| 82 | + | } | |||
| 83 | + | return out | |||
| 84 | + | } | |||
| 85 | + | ||||
| 64 | // Discover walks every supported location, parses each SKILL.md, and | 86 | // Discover walks every supported location, parses each SKILL.md, and | ||
| 65 | // returns the merged skill set. First-match-wins per name; the order | 87 | // 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 | 88 | // matches the priority list in the package doc. Errors per skill are | ||
| 67 | // returned alongside the partial result so a single broken file | 89 | // returned alongside the partial result so a single broken file | ||
| 68 | // doesn't suppress the rest. | 90 | // doesn't suppress the rest. | ||
| 91 | + | // | |||
| 92 | + | // Built-in skills (compiled into the zot binary) are added LAST so | |||
| 93 | + | // any user-installed skill with the same name shadows the built-in. | |||
| 94 | + | // That lets users customise the help text by dropping their own | |||
| 95 | + | // SKILL.md with the same name into $ZOT_HOME/skills/<name>/. | |||
| 69 | func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) { | 96 | func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) { | ||
| 70 | var errs []error | 97 | var errs []error | ||
| 71 | seen := map[string]*Skill{} | 98 | seen := map[string]*Skill{} | ||
| @@ -95,6 +122,14 @@ func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) { | |||||
| 95 | seen[s.Name] = s | 122 | seen[s.Name] = s | ||
| 96 | } | 123 | } | ||
| 97 | } | 124 | } | ||
| 125 | + | // Built-ins fill in any name the user didn't already provide. | |||
| 126 | + | for _, s := range loadBuiltins() { | |||
| 127 | + | if _, dup := seen[s.Name]; dup { | |||
| 128 | + | continue | |||
| 129 | + | } | |||
| 130 | + | seen[s.Name] = s | |||
| 131 | + | } | |||
| 132 | + | ||||
| 98 | out := make([]*Skill, 0, len(seen)) | 133 | out := make([]*Skill, 0, len(seen)) | ||
| 99 | for _, s := range seen { | 134 | for _, s := range seen { | ||
| 100 | out = append(out, s) | 135 | out = append(out, s) | ||
M internal/skills/skills_test.go +29 -2
| @@ -67,8 +67,12 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) { | |||
| 67 | 67 | if len(errs) > 0 { | |
| 68 | 68 | t.Fatalf("errs: %v", errs) | |
| 69 | 69 | } | |
| 70 | - | if len(skills) != 2 { | |
| 71 | - | t.Fatalf("expected 2 skills, got %d (%v)", len(skills), skills) | |
| 70 | + | // Expect the two user skills + every built-in shipped with the | |
| 71 | + | // binary (currently the write-zot-extension authoring guide). | |
| 72 | + | builtins := loadBuiltins() | |
| 73 | + | want := 2 + len(builtins) | |
| 74 | + | if len(skills) != want { | |
| 75 | + | t.Fatalf("expected %d skills (2 user + %d built-in), got %d (%v)", want, len(builtins), len(skills), skills) | |
| 72 | 76 | } | |
| 73 | 77 | shared := FindByName(skills, "shared") | |
| 74 | 78 | if shared == nil || shared.Description != "project version" { | |
| @@ -77,6 +81,29 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) { | |||
| 77 | 81 | if FindByName(skills, "global-only") == nil { | |
| 78 | 82 | t.Errorf("global-only skill missing") | |
| 79 | 83 | } | |
| 84 | + | // At least one built-in should have made it through. | |
| 85 | + | for _, b := range builtins { | |
| 86 | + | if FindByName(skills, b.Name) == nil { | |
| 87 | + | t.Errorf("built-in skill %q missing from Discover output", b.Name) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestVisibleSkillsHidesBuiltins(t *testing.T) { | |
| 93 | + | in := []*Skill{ | |
| 94 | + | {Name: "user-one"}, | |
| 95 | + | {Name: "built-one", Builtin: true}, | |
| 96 | + | {Name: "user-two"}, | |
| 97 | + | } | |
| 98 | + | out := VisibleSkills(in) | |
| 99 | + | if len(out) != 2 { | |
| 100 | + | t.Fatalf("expected 2 visible skills, got %d (%v)", len(out), out) | |
| 101 | + | } | |
| 102 | + | for _, s := range out { | |
| 103 | + | if s.Builtin { | |
| 104 | + | t.Errorf("built-in %q leaked into visible set", s.Name) | |
| 105 | + | } | |
| 106 | + | } | |
| 80 | 107 | } | |
| 81 | 108 | ||
| 82 | 109 | func TestSystemPromptAddendum(t *testing.T) { | |
| @@ -67,8 +67,12 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) { | |||||
| 67 | if len(errs) > 0 { | 67 | if len(errs) > 0 { | ||
| 68 | t.Fatalf("errs: %v", errs) | 68 | t.Fatalf("errs: %v", errs) | ||
| 69 | } | 69 | } | ||
| 70 | - | if len(skills) != 2 { | 70 | + | // Expect the two user skills + every built-in shipped with the |
| 71 | - | t.Fatalf("expected 2 skills, got %d (%v)", len(skills), skills) | 71 | + | // binary (currently the write-zot-extension authoring guide). |
| 72 | + | builtins := loadBuiltins() | |||
| 73 | + | want := 2 + len(builtins) | |||
| 74 | + | if len(skills) != want { | |||
| 75 | + | t.Fatalf("expected %d skills (2 user + %d built-in), got %d (%v)", want, len(builtins), len(skills), skills) | |||
| 72 | } | 76 | } | ||
| 73 | shared := FindByName(skills, "shared") | 77 | shared := FindByName(skills, "shared") | ||
| 74 | if shared == nil || shared.Description != "project version" { | 78 | if shared == nil || shared.Description != "project version" { | ||
| @@ -77,6 +81,29 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) { | |||||
| 77 | if FindByName(skills, "global-only") == nil { | 81 | if FindByName(skills, "global-only") == nil { | ||
| 78 | t.Errorf("global-only skill missing") | 82 | t.Errorf("global-only skill missing") | ||
| 79 | } | 83 | } | ||
| 84 | + | // At least one built-in should have made it through. | |||
| 85 | + | for _, b := range builtins { | |||
| 86 | + | if FindByName(skills, b.Name) == nil { | |||
| 87 | + | t.Errorf("built-in skill %q missing from Discover output", b.Name) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestVisibleSkillsHidesBuiltins(t *testing.T) { | |||
| 93 | + | in := []*Skill{ | |||
| 94 | + | {Name: "user-one"}, | |||
| 95 | + | {Name: "built-one", Builtin: true}, | |||
| 96 | + | {Name: "user-two"}, | |||
| 97 | + | } | |||
| 98 | + | out := VisibleSkills(in) | |||
| 99 | + | if len(out) != 2 { | |||
| 100 | + | t.Fatalf("expected 2 visible skills, got %d (%v)", len(out), out) | |||
| 101 | + | } | |||
| 102 | + | for _, s := range out { | |||
| 103 | + | if s.Builtin { | |||
| 104 | + | t.Errorf("built-in %q leaked into visible set", s.Name) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 80 | } | 107 | } | ||
| 81 | 108 | ||||
| 82 | func TestSystemPromptAddendum(t *testing.T) { | 109 | func TestSystemPromptAddendum(t *testing.T) { | ||