feat: extension system (subprocess + json-rpc, any language)
Phase 1: extensions can register slash commands and push chat
notifications. Tools and event subscriptions land in later phases.
Architecture: each extension is its own subprocess. Zot launches
it on startup, completes a hello/hello_ack handshake over its
stdin/stdout, then routes slash commands the extension registered.
Crash isolation, language agnostic, works with any executable
that can read/write json lines.
What lands here:
- internal/extproto: shared wire-format types (Frame, HelloFromExt,
RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt,
HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...).
Both the host and the SDK marshal/unmarshal the same types.
- internal/agent/extensions: discovery + lifecycle manager.
- Discover() walks $ZOT_HOME/extensions and ./.zot/extensions
(project-local first, global second; first wins for duplicates)
- Spawns each enabled extension, captures stderr to
$ZOT_HOME/logs/ext-<name>.log
- Reads frames in a goroutine, dispatches register_command and
notify, correlates command_response by id
- Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL
- HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display)
- Interactive bridge: extensions slot into the slash dispatcher
*after* the built-in catalog, so built-ins always win on conflict.
Extension-registered commands also flow into the autocomplete
popup and /help via slashSuggester.SetExtra. NotifyFromExt frames
render as muted [ext-name] notes above the editor.
- internal/agent/extcmd: `zot ext` CLI.
list / install <path|git-url> / remove / enable / disable / logs
- pkg/zotext: public Go SDK. Construct an Extension, register
Command(name, desc, fn), call Run(). Fn returns a Response built
with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr
via Logf() so stdout stays clean for the protocol.
- examples/extensions/hello: working Go example registering /hello
and /summon, plus README + extension.json.
- docs/extensions.md: full protocol reference, including a
~30-line raw-Python example for users who don't want the SDK.
Tests: internal/agent/extensions/manager_test.go spawns a mock
extension via /bin/sh and exercises the full handshake -> register
-> invoke -> response cycle. Verifies the hello frame ordering,
correlation-by-id, and graceful shutdown.
Verified manually: built and installed the example, drove it via
stdin pipes, confirmed clean handshake + correct frame ordering
and shutdown_ack. Builds vet-clean on darwin / linux / windows.
Editor.Insert exported (was Editor.insert) so the extension hooks
can drop text into the input.- commit
- 0c92d6e9149217b67d04b68357f4cb4909ea55b2
- author
- patriceckhart · · 2026-04-19 12:09 UTC
- parent
- e7dbf3d
- signature
- unsigned
15 files · +1977 -11
M README.md +1 -1
| @@ -7,7 +7,7 @@ yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||
| 7 | 7 | - four tools (read, write, edit, bash). | |
| 8 | 8 | - three run modes (interactive tui, print, json). | |
| 9 | 9 | - built-in telegram bot. | |
| 10 | - | - no extensions atm. | |
| 10 | + | - extensions in any language via subprocess + json-rpc; see [docs/extensions.md](docs/extensions.md). | |
| 11 | 11 | - no community atm. | |
| 12 | 12 | ||
| 13 | 13 | ## install | |
| @@ -7,7 +7,7 @@ yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||||
| 7 | - four tools (read, write, edit, bash). | 7 | - four tools (read, write, edit, bash). | ||
| 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 | - | - no extensions atm. | 10 | + | - extensions in any language via subprocess + json-rpc; see [docs/extensions.md](docs/extensions.md). |
| 11 | - no community atm. | 11 | - no community atm. | ||
| 12 | 12 | ||||
| 13 | ## install | 13 | ## install | ||
A docs/extensions.md +284 -0
| @@ -0,0 +1,284 @@ | |||
| 1 | + | # zot extensions | |
| 2 | + | ||
| 3 | + | zot can be extended with custom slash commands by running an external | |
| 4 | + | program as a subprocess and exchanging newline-delimited JSON over | |
| 5 | + | its stdin/stdout. Extensions can be written in **any language** that | |
| 6 | + | can read and write JSON lines from stdio — Go, TypeScript, Python, | |
| 7 | + | Rust, shell with `jq`, anything. | |
| 8 | + | ||
| 9 | + | This is **phase 1**: slash commands and chat notifications. Future | |
| 10 | + | phases will add tools the model can call, lifecycle event subscriptions, | |
| 11 | + | and tool-call interception. | |
| 12 | + | ||
| 13 | + | ## Quick start | |
| 14 | + | ||
| 15 | + | The simplest extension is a script that prints a hello frame, reads | |
| 16 | + | commands, and prints responses. Here's the whole thing in **Python**, | |
| 17 | + | no SDK required: | |
| 18 | + | ||
| 19 | + | ```python | |
| 20 | + | #!/usr/bin/env python3 | |
| 21 | + | # $ZOT_HOME/extensions/hello-py/hello.py | |
| 22 | + | import json, sys, threading | |
| 23 | + | ||
| 24 | + | def emit(obj): | |
| 25 | + | sys.stdout.write(json.dumps(obj) + "\n") | |
| 26 | + | sys.stdout.flush() | |
| 27 | + | ||
| 28 | + | emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]}) | |
| 29 | + | emit({"type":"register_command","name":"hellopy","description":"say hi (python)"}) | |
| 30 | + | ||
| 31 | + | for line in sys.stdin: | |
| 32 | + | msg = json.loads(line) | |
| 33 | + | if msg["type"] == "command_invoked": | |
| 34 | + | emit({"type":"command_response","id":msg["id"],"action":"prompt", | |
| 35 | + | "prompt": "Greet me very briefly. Add one emoji."}) | |
| 36 | + | elif msg["type"] == "shutdown": | |
| 37 | + | emit({"type":"shutdown_ack"}) | |
| 38 | + | break | |
| 39 | + | ``` | |
| 40 | + | ||
| 41 | + | Drop it in a directory with this `extension.json`: | |
| 42 | + | ||
| 43 | + | ```json | |
| 44 | + | { | |
| 45 | + | "name": "hello-py", | |
| 46 | + | "version": "1.0.0", | |
| 47 | + | "exec": "./hello.py", | |
| 48 | + | "language": "python", | |
| 49 | + | "enabled": true | |
| 50 | + | } | |
| 51 | + | ``` | |
| 52 | + | ||
| 53 | + | `chmod +x hello.py`, install: | |
| 54 | + | ||
| 55 | + | ```bash | |
| 56 | + | zot ext install ./hello-py | |
| 57 | + | ``` | |
| 58 | + | ||
| 59 | + | Restart `zot`, type `/hellopy`, the agent greets you. Done. | |
| 60 | + | ||
| 61 | + | ## Layout & discovery | |
| 62 | + | ||
| 63 | + | zot scans two directories on startup, in this order: | |
| 64 | + | ||
| 65 | + | 1. **Project-local**: `./.zot/extensions/<name>/extension.json` | |
| 66 | + | 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json` | |
| 67 | + | ||
| 68 | + | A project-local extension with the same name wins over a global one. | |
| 69 | + | On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`; | |
| 70 | + | on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`. | |
| 71 | + | ||
| 72 | + | Each extension owns its own subdirectory. The `extension.json` | |
| 73 | + | manifest tells zot how to launch it: | |
| 74 | + | ||
| 75 | + | ```json | |
| 76 | + | { | |
| 77 | + | "name": "weather", | |
| 78 | + | "version": "1.0.0", | |
| 79 | + | "exec": "./weather", | |
| 80 | + | "args": ["--mode", "daemon"], | |
| 81 | + | "language": "go", | |
| 82 | + | "description": "current weather for any city", | |
| 83 | + | "enabled": true | |
| 84 | + | } | |
| 85 | + | ``` | |
| 86 | + | ||
| 87 | + | | field | meaning | | |
| 88 | + | |---|---| | |
| 89 | + | | `name` | required. how zot identifies the extension; must match what's sent in the `hello` frame. | | |
| 90 | + | | `version` | optional. shown in `zot ext list`. | | |
| 91 | + | | `exec` | required. path to the executable (relative to the manifest). | | |
| 92 | + | | `args` | optional. extra argv passed to `exec`. | | |
| 93 | + | | `language` | optional. informational only (`go`, `python`, `typescript`, ...). | | |
| 94 | + | | `description` | optional. shown in `zot ext list`. | | |
| 95 | + | | `enabled` | optional, defaults to `true`. set to `false` to disable without removing. | | |
| 96 | + | ||
| 97 | + | ## Lifecycle | |
| 98 | + | ||
| 99 | + | 1. **Discovery**: zot reads every `extension.json` in the search dirs. | |
| 100 | + | 2. **Spawn**: enabled extensions are launched as subprocesses. stderr | |
| 101 | + | redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per | |
| 102 | + | extension, append-mode). | |
| 103 | + | 3. **Hello handshake**: the extension sends a `hello` frame; zot | |
| 104 | + | replies with `hello_ack` containing the protocol version and the | |
| 105 | + | active provider/model/cwd. | |
| 106 | + | 4. **Registration**: the extension sends `register_command` frames. | |
| 107 | + | First-come-first-served: a name already taken by a built-in or by | |
| 108 | + | a previously-loaded extension is silently shadowed (logged in the | |
| 109 | + | extension's own log file). | |
| 110 | + | 5. **Runtime**: zot dispatches `command_invoked` frames when the | |
| 111 | + | user runs a registered command; the extension responds with | |
| 112 | + | `command_response`. Extensions can also push `notify` frames at | |
| 113 | + | any time. | |
| 114 | + | 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to | |
| 115 | + | 2s for the extension to send `shutdown_ack`. Holdouts are | |
| 116 | + | SIGTERM'd, then SIGKILL'd. | |
| 117 | + | ||
| 118 | + | A crashing extension does not bring down zot. The slash command it | |
| 119 | + | owned simply stops working until the extension is fixed and zot is | |
| 120 | + | restarted. | |
| 121 | + | ||
| 122 | + | ## Wire format | |
| 123 | + | ||
| 124 | + | All frames are one JSON object per line. Top-level `type` is the | |
| 125 | + | discriminator. Optional `id` correlates command invocations with | |
| 126 | + | their responses. | |
| 127 | + | ||
| 128 | + | ### Extension → host | |
| 129 | + | ||
| 130 | + | #### `hello` (required, first frame) | |
| 131 | + | ||
| 132 | + | ```json | |
| 133 | + | {"type":"hello","name":"weather","version":"1.0.0", | |
| 134 | + | "capabilities":["commands"]} | |
| 135 | + | ``` | |
| 136 | + | ||
| 137 | + | #### `register_command` | |
| 138 | + | ||
| 139 | + | ```json | |
| 140 | + | {"type":"register_command","name":"weather", | |
| 141 | + | "description":"current weather for a city"} | |
| 142 | + | ``` | |
| 143 | + | ||
| 144 | + | #### `command_response` (reply to `command_invoked`) | |
| 145 | + | ||
| 146 | + | ```json | |
| 147 | + | {"type":"command_response","id":"...","action":"prompt", | |
| 148 | + | "prompt":"Show today's weather for Berlin in one line."} | |
| 149 | + | ``` | |
| 150 | + | ||
| 151 | + | `action` is one of: | |
| 152 | + | ||
| 153 | + | - `"prompt"` — submits `prompt` as a fresh user message; the agent | |
| 154 | + | runs a turn against it. | |
| 155 | + | - `"insert"` — inserts `insert` into the editor at the cursor without | |
| 156 | + | submitting. | |
| 157 | + | - `"display"` — appends `display` to the chat as a one-shot styled | |
| 158 | + | note. No model call, nothing written to the transcript. | |
| 159 | + | - `"noop"` — the extension handled it itself (e.g. it pushed | |
| 160 | + | `notify` frames or kicked off background work). zot doesn't change | |
| 161 | + | the UI in response. | |
| 162 | + | ||
| 163 | + | If `error` is non-empty, zot renders it as a red status line | |
| 164 | + | regardless of `action`. | |
| 165 | + | ||
| 166 | + | #### `notify` (one-way, any time) | |
| 167 | + | ||
| 168 | + | ```json | |
| 169 | + | {"type":"notify","level":"info", | |
| 170 | + | "message":"refreshed cache (12 entries)"} | |
| 171 | + | ``` | |
| 172 | + | ||
| 173 | + | `level` is one of `info`, `success`, `warn`, `error`. The note shows | |
| 174 | + | up below the transcript with the extension's name in brackets. | |
| 175 | + | ||
| 176 | + | #### `shutdown_ack` | |
| 177 | + | ||
| 178 | + | Sent in response to `shutdown`. Extension should exit promptly after. | |
| 179 | + | ||
| 180 | + | ### Host → extension | |
| 181 | + | ||
| 182 | + | #### `hello_ack` | |
| 183 | + | ||
| 184 | + | ```json | |
| 185 | + | {"type":"hello_ack","protocol_version":1, | |
| 186 | + | "zot_version":"0.0.7","provider":"anthropic", | |
| 187 | + | "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot"} | |
| 188 | + | ``` | |
| 189 | + | ||
| 190 | + | Sent immediately after `hello`. The extension can use these fields to | |
| 191 | + | decide which commands to register (e.g. only register a Python tool | |
| 192 | + | on macOS, only register a model-specific shortcut for opus, etc.). | |
| 193 | + | ||
| 194 | + | #### `command_invoked` | |
| 195 | + | ||
| 196 | + | ```json | |
| 197 | + | {"type":"command_invoked","id":"...", | |
| 198 | + | "name":"weather","args":"berlin"} | |
| 199 | + | ``` | |
| 200 | + | ||
| 201 | + | `args` is everything the user typed after the command name, trimmed. | |
| 202 | + | ||
| 203 | + | #### `shutdown` | |
| 204 | + | ||
| 205 | + | Sent during graceful zot exit (or `/reload-ext` once that lands). | |
| 206 | + | Reply with `shutdown_ack` and then exit. | |
| 207 | + | ||
| 208 | + | ## Managing extensions from the CLI | |
| 209 | + | ||
| 210 | + | ``` | |
| 211 | + | zot ext list list installed extensions and their state | |
| 212 | + | zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/ | |
| 213 | + | zot ext remove <name> delete an extension directory | |
| 214 | + | zot ext enable <name> re-enable a disabled extension | |
| 215 | + | zot ext disable <name> disable without removing | |
| 216 | + | zot ext logs <name> [-f] cat / tail the extension's stderr | |
| 217 | + | ``` | |
| 218 | + | ||
| 219 | + | `zot ext install <path>` does a recursive copy; `<git-url>` does a | |
| 220 | + | shallow clone. Both validate that the destination contains an | |
| 221 | + | `extension.json` and roll back if not. | |
| 222 | + | ||
| 223 | + | ## SDKs | |
| 224 | + | ||
| 225 | + | Writing the wire protocol by hand is fine for one-off scripts, but | |
| 226 | + | for anything bigger the SDKs handle the boilerplate. | |
| 227 | + | ||
| 228 | + | ### Go — `pkg/zotext` | |
| 229 | + | ||
| 230 | + | ```go | |
| 231 | + | package main | |
| 232 | + | ||
| 233 | + | import "github.com/patriceckhart/zot/pkg/zotext" | |
| 234 | + | ||
| 235 | + | func main() { | |
| 236 | + | ext := zotext.New("hello", "1.0.0") | |
| 237 | + | ext.Command("hello", "say hi", func(args string) zotext.Response { | |
| 238 | + | return zotext.Prompt("Greet me in one short sentence.") | |
| 239 | + | }) | |
| 240 | + | ext.Run() | |
| 241 | + | } | |
| 242 | + | ``` | |
| 243 | + | ||
| 244 | + | Build with `go build -o hello .`, drop the binary + an `extension.json` | |
| 245 | + | into `$ZOT_HOME/extensions/hello/`. | |
| 246 | + | ||
| 247 | + | See `examples/extensions/hello/` for the full working example. | |
| 248 | + | ||
| 249 | + | ### TypeScript / Python | |
| 250 | + | ||
| 251 | + | These SDKs aren't in the main repo yet; the wire format is small | |
| 252 | + | enough that a `~30 line` raw script gets you started in either | |
| 253 | + | language. See the [Quick start](#quick-start) Python example for the | |
| 254 | + | shape. SDK packages will land in follow-up commits. | |
| 255 | + | ||
| 256 | + | ## Security | |
| 257 | + | ||
| 258 | + | Extensions run with **the user's full filesystem and network | |
| 259 | + | permissions**. Treat installing an extension the same as installing | |
| 260 | + | any other binary on your machine. | |
| 261 | + | ||
| 262 | + | `zot ext install <git-url>` clones from any URL you give it. There's | |
| 263 | + | no sandbox in v1; if you need isolation, install only extensions you | |
| 264 | + | trust or run zot under your platform's sandboxing tool (`bwrap` / | |
| 265 | + | `sandbox-exec` / AppContainer). | |
| 266 | + | ||
| 267 | + | ## Roadmap | |
| 268 | + | ||
| 269 | + | Phase 1 (this document): | |
| 270 | + | - [x] subprocess lifecycle + hello handshake | |
| 271 | + | - [x] `register_command` + `command_invoked` | |
| 272 | + | - [x] `notify` | |
| 273 | + | - [x] `zot ext` CLI | |
| 274 | + | ||
| 275 | + | Phase 2: | |
| 276 | + | - [ ] `register_tool` + `tool_call` + `tool_result` (extension-defined | |
| 277 | + | tools that the LLM can call) | |
| 278 | + | - [ ] tool result rendering with extension attribution | |
| 279 | + | ||
| 280 | + | Phase 3: | |
| 281 | + | - [ ] event subscriptions (`turn_start`, `turn_end`, `tool_call_*`, | |
| 282 | + | `session_start`, etc.) | |
| 283 | + | - [ ] tool-call interception (block / modify before execution) | |
| 284 | + | - [ ] `/reload-ext` slash command (hot-reload without restarting zot) | |
| @@ -0,0 +1,284 @@ | |||||
| 1 | + | # zot extensions | |||
| 2 | + | ||||
| 3 | + | zot can be extended with custom slash commands by running an external | |||
| 4 | + | program as a subprocess and exchanging newline-delimited JSON over | |||
| 5 | + | its stdin/stdout. Extensions can be written in **any language** that | |||
| 6 | + | can read and write JSON lines from stdio — Go, TypeScript, Python, | |||
| 7 | + | Rust, shell with `jq`, anything. | |||
| 8 | + | ||||
| 9 | + | This is **phase 1**: slash commands and chat notifications. Future | |||
| 10 | + | phases will add tools the model can call, lifecycle event subscriptions, | |||
| 11 | + | and tool-call interception. | |||
| 12 | + | ||||
| 13 | + | ## Quick start | |||
| 14 | + | ||||
| 15 | + | The simplest extension is a script that prints a hello frame, reads | |||
| 16 | + | commands, and prints responses. Here's the whole thing in **Python**, | |||
| 17 | + | no SDK required: | |||
| 18 | + | ||||
| 19 | + | ```python | |||
| 20 | + | #!/usr/bin/env python3 | |||
| 21 | + | # $ZOT_HOME/extensions/hello-py/hello.py | |||
| 22 | + | import json, sys, threading | |||
| 23 | + | ||||
| 24 | + | def emit(obj): | |||
| 25 | + | sys.stdout.write(json.dumps(obj) + "\n") | |||
| 26 | + | sys.stdout.flush() | |||
| 27 | + | ||||
| 28 | + | emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]}) | |||
| 29 | + | emit({"type":"register_command","name":"hellopy","description":"say hi (python)"}) | |||
| 30 | + | ||||
| 31 | + | for line in sys.stdin: | |||
| 32 | + | msg = json.loads(line) | |||
| 33 | + | if msg["type"] == "command_invoked": | |||
| 34 | + | emit({"type":"command_response","id":msg["id"],"action":"prompt", | |||
| 35 | + | "prompt": "Greet me very briefly. Add one emoji."}) | |||
| 36 | + | elif msg["type"] == "shutdown": | |||
| 37 | + | emit({"type":"shutdown_ack"}) | |||
| 38 | + | break | |||
| 39 | + | ``` | |||
| 40 | + | ||||
| 41 | + | Drop it in a directory with this `extension.json`: | |||
| 42 | + | ||||
| 43 | + | ```json | |||
| 44 | + | { | |||
| 45 | + | "name": "hello-py", | |||
| 46 | + | "version": "1.0.0", | |||
| 47 | + | "exec": "./hello.py", | |||
| 48 | + | "language": "python", | |||
| 49 | + | "enabled": true | |||
| 50 | + | } | |||
| 51 | + | ``` | |||
| 52 | + | ||||
| 53 | + | `chmod +x hello.py`, install: | |||
| 54 | + | ||||
| 55 | + | ```bash | |||
| 56 | + | zot ext install ./hello-py | |||
| 57 | + | ``` | |||
| 58 | + | ||||
| 59 | + | Restart `zot`, type `/hellopy`, the agent greets you. Done. | |||
| 60 | + | ||||
| 61 | + | ## Layout & discovery | |||
| 62 | + | ||||
| 63 | + | zot scans two directories on startup, in this order: | |||
| 64 | + | ||||
| 65 | + | 1. **Project-local**: `./.zot/extensions/<name>/extension.json` | |||
| 66 | + | 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json` | |||
| 67 | + | ||||
| 68 | + | A project-local extension with the same name wins over a global one. | |||
| 69 | + | On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`; | |||
| 70 | + | on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`. | |||
| 71 | + | ||||
| 72 | + | Each extension owns its own subdirectory. The `extension.json` | |||
| 73 | + | manifest tells zot how to launch it: | |||
| 74 | + | ||||
| 75 | + | ```json | |||
| 76 | + | { | |||
| 77 | + | "name": "weather", | |||
| 78 | + | "version": "1.0.0", | |||
| 79 | + | "exec": "./weather", | |||
| 80 | + | "args": ["--mode", "daemon"], | |||
| 81 | + | "language": "go", | |||
| 82 | + | "description": "current weather for any city", | |||
| 83 | + | "enabled": true | |||
| 84 | + | } | |||
| 85 | + | ``` | |||
| 86 | + | ||||
| 87 | + | | field | meaning | | |||
| 88 | + | |---|---| | |||
| 89 | + | | `name` | required. how zot identifies the extension; must match what's sent in the `hello` frame. | | |||
| 90 | + | | `version` | optional. shown in `zot ext list`. | | |||
| 91 | + | | `exec` | required. path to the executable (relative to the manifest). | | |||
| 92 | + | | `args` | optional. extra argv passed to `exec`. | | |||
| 93 | + | | `language` | optional. informational only (`go`, `python`, `typescript`, ...). | | |||
| 94 | + | | `description` | optional. shown in `zot ext list`. | | |||
| 95 | + | | `enabled` | optional, defaults to `true`. set to `false` to disable without removing. | | |||
| 96 | + | ||||
| 97 | + | ## Lifecycle | |||
| 98 | + | ||||
| 99 | + | 1. **Discovery**: zot reads every `extension.json` in the search dirs. | |||
| 100 | + | 2. **Spawn**: enabled extensions are launched as subprocesses. stderr | |||
| 101 | + | redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per | |||
| 102 | + | extension, append-mode). | |||
| 103 | + | 3. **Hello handshake**: the extension sends a `hello` frame; zot | |||
| 104 | + | replies with `hello_ack` containing the protocol version and the | |||
| 105 | + | active provider/model/cwd. | |||
| 106 | + | 4. **Registration**: the extension sends `register_command` frames. | |||
| 107 | + | First-come-first-served: a name already taken by a built-in or by | |||
| 108 | + | a previously-loaded extension is silently shadowed (logged in the | |||
| 109 | + | extension's own log file). | |||
| 110 | + | 5. **Runtime**: zot dispatches `command_invoked` frames when the | |||
| 111 | + | user runs a registered command; the extension responds with | |||
| 112 | + | `command_response`. Extensions can also push `notify` frames at | |||
| 113 | + | any time. | |||
| 114 | + | 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to | |||
| 115 | + | 2s for the extension to send `shutdown_ack`. Holdouts are | |||
| 116 | + | SIGTERM'd, then SIGKILL'd. | |||
| 117 | + | ||||
| 118 | + | A crashing extension does not bring down zot. The slash command it | |||
| 119 | + | owned simply stops working until the extension is fixed and zot is | |||
| 120 | + | restarted. | |||
| 121 | + | ||||
| 122 | + | ## Wire format | |||
| 123 | + | ||||
| 124 | + | All frames are one JSON object per line. Top-level `type` is the | |||
| 125 | + | discriminator. Optional `id` correlates command invocations with | |||
| 126 | + | their responses. | |||
| 127 | + | ||||
| 128 | + | ### Extension → host | |||
| 129 | + | ||||
| 130 | + | #### `hello` (required, first frame) | |||
| 131 | + | ||||
| 132 | + | ```json | |||
| 133 | + | {"type":"hello","name":"weather","version":"1.0.0", | |||
| 134 | + | "capabilities":["commands"]} | |||
| 135 | + | ``` | |||
| 136 | + | ||||
| 137 | + | #### `register_command` | |||
| 138 | + | ||||
| 139 | + | ```json | |||
| 140 | + | {"type":"register_command","name":"weather", | |||
| 141 | + | "description":"current weather for a city"} | |||
| 142 | + | ``` | |||
| 143 | + | ||||
| 144 | + | #### `command_response` (reply to `command_invoked`) | |||
| 145 | + | ||||
| 146 | + | ```json | |||
| 147 | + | {"type":"command_response","id":"...","action":"prompt", | |||
| 148 | + | "prompt":"Show today's weather for Berlin in one line."} | |||
| 149 | + | ``` | |||
| 150 | + | ||||
| 151 | + | `action` is one of: | |||
| 152 | + | ||||
| 153 | + | - `"prompt"` — submits `prompt` as a fresh user message; the agent | |||
| 154 | + | runs a turn against it. | |||
| 155 | + | - `"insert"` — inserts `insert` into the editor at the cursor without | |||
| 156 | + | submitting. | |||
| 157 | + | - `"display"` — appends `display` to the chat as a one-shot styled | |||
| 158 | + | note. No model call, nothing written to the transcript. | |||
| 159 | + | - `"noop"` — the extension handled it itself (e.g. it pushed | |||
| 160 | + | `notify` frames or kicked off background work). zot doesn't change | |||
| 161 | + | the UI in response. | |||
| 162 | + | ||||
| 163 | + | If `error` is non-empty, zot renders it as a red status line | |||
| 164 | + | regardless of `action`. | |||
| 165 | + | ||||
| 166 | + | #### `notify` (one-way, any time) | |||
| 167 | + | ||||
| 168 | + | ```json | |||
| 169 | + | {"type":"notify","level":"info", | |||
| 170 | + | "message":"refreshed cache (12 entries)"} | |||
| 171 | + | ``` | |||
| 172 | + | ||||
| 173 | + | `level` is one of `info`, `success`, `warn`, `error`. The note shows | |||
| 174 | + | up below the transcript with the extension's name in brackets. | |||
| 175 | + | ||||
| 176 | + | #### `shutdown_ack` | |||
| 177 | + | ||||
| 178 | + | Sent in response to `shutdown`. Extension should exit promptly after. | |||
| 179 | + | ||||
| 180 | + | ### Host → extension | |||
| 181 | + | ||||
| 182 | + | #### `hello_ack` | |||
| 183 | + | ||||
| 184 | + | ```json | |||
| 185 | + | {"type":"hello_ack","protocol_version":1, | |||
| 186 | + | "zot_version":"0.0.7","provider":"anthropic", | |||
| 187 | + | "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot"} | |||
| 188 | + | ``` | |||
| 189 | + | ||||
| 190 | + | Sent immediately after `hello`. The extension can use these fields to | |||
| 191 | + | decide which commands to register (e.g. only register a Python tool | |||
| 192 | + | on macOS, only register a model-specific shortcut for opus, etc.). | |||
| 193 | + | ||||
| 194 | + | #### `command_invoked` | |||
| 195 | + | ||||
| 196 | + | ```json | |||
| 197 | + | {"type":"command_invoked","id":"...", | |||
| 198 | + | "name":"weather","args":"berlin"} | |||
| 199 | + | ``` | |||
| 200 | + | ||||
| 201 | + | `args` is everything the user typed after the command name, trimmed. | |||
| 202 | + | ||||
| 203 | + | #### `shutdown` | |||
| 204 | + | ||||
| 205 | + | Sent during graceful zot exit (or `/reload-ext` once that lands). | |||
| 206 | + | Reply with `shutdown_ack` and then exit. | |||
| 207 | + | ||||
| 208 | + | ## Managing extensions from the CLI | |||
| 209 | + | ||||
| 210 | + | ``` | |||
| 211 | + | zot ext list list installed extensions and their state | |||
| 212 | + | zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/ | |||
| 213 | + | zot ext remove <name> delete an extension directory | |||
| 214 | + | zot ext enable <name> re-enable a disabled extension | |||
| 215 | + | zot ext disable <name> disable without removing | |||
| 216 | + | zot ext logs <name> [-f] cat / tail the extension's stderr | |||
| 217 | + | ``` | |||
| 218 | + | ||||
| 219 | + | `zot ext install <path>` does a recursive copy; `<git-url>` does a | |||
| 220 | + | shallow clone. Both validate that the destination contains an | |||
| 221 | + | `extension.json` and roll back if not. | |||
| 222 | + | ||||
| 223 | + | ## SDKs | |||
| 224 | + | ||||
| 225 | + | Writing the wire protocol by hand is fine for one-off scripts, but | |||
| 226 | + | for anything bigger the SDKs handle the boilerplate. | |||
| 227 | + | ||||
| 228 | + | ### Go — `pkg/zotext` | |||
| 229 | + | ||||
| 230 | + | ```go | |||
| 231 | + | package main | |||
| 232 | + | ||||
| 233 | + | import "github.com/patriceckhart/zot/pkg/zotext" | |||
| 234 | + | ||||
| 235 | + | func main() { | |||
| 236 | + | ext := zotext.New("hello", "1.0.0") | |||
| 237 | + | ext.Command("hello", "say hi", func(args string) zotext.Response { | |||
| 238 | + | return zotext.Prompt("Greet me in one short sentence.") | |||
| 239 | + | }) | |||
| 240 | + | ext.Run() | |||
| 241 | + | } | |||
| 242 | + | ``` | |||
| 243 | + | ||||
| 244 | + | Build with `go build -o hello .`, drop the binary + an `extension.json` | |||
| 245 | + | into `$ZOT_HOME/extensions/hello/`. | |||
| 246 | + | ||||
| 247 | + | See `examples/extensions/hello/` for the full working example. | |||
| 248 | + | ||||
| 249 | + | ### TypeScript / Python | |||
| 250 | + | ||||
| 251 | + | These SDKs aren't in the main repo yet; the wire format is small | |||
| 252 | + | enough that a `~30 line` raw script gets you started in either | |||
| 253 | + | language. See the [Quick start](#quick-start) Python example for the | |||
| 254 | + | shape. SDK packages will land in follow-up commits. | |||
| 255 | + | ||||
| 256 | + | ## Security | |||
| 257 | + | ||||
| 258 | + | Extensions run with **the user's full filesystem and network | |||
| 259 | + | permissions**. Treat installing an extension the same as installing | |||
| 260 | + | any other binary on your machine. | |||
| 261 | + | ||||
| 262 | + | `zot ext install <git-url>` clones from any URL you give it. There's | |||
| 263 | + | no sandbox in v1; if you need isolation, install only extensions you | |||
| 264 | + | trust or run zot under your platform's sandboxing tool (`bwrap` / | |||
| 265 | + | `sandbox-exec` / AppContainer). | |||
| 266 | + | ||||
| 267 | + | ## Roadmap | |||
| 268 | + | ||||
| 269 | + | Phase 1 (this document): | |||
| 270 | + | - [x] subprocess lifecycle + hello handshake | |||
| 271 | + | - [x] `register_command` + `command_invoked` | |||
| 272 | + | - [x] `notify` | |||
| 273 | + | - [x] `zot ext` CLI | |||
| 274 | + | ||||
| 275 | + | Phase 2: | |||
| 276 | + | - [ ] `register_tool` + `tool_call` + `tool_result` (extension-defined | |||
| 277 | + | tools that the LLM can call) | |||
| 278 | + | - [ ] tool result rendering with extension attribution | |||
| 279 | + | ||||
| 280 | + | Phase 3: | |||
| 281 | + | - [ ] event subscriptions (`turn_start`, `turn_end`, `tool_call_*`, | |||
| 282 | + | `session_start`, etc.) | |||
| 283 | + | - [ ] tool-call interception (block / modify before execution) | |||
| 284 | + | - [ ] `/reload-ext` slash command (hot-reload without restarting zot) | |||
A examples/extensions/hello/README.md +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | # hello — example zot extension (Go) | |
| 2 | + | ||
| 3 | + | A minimal example showing how to register slash commands with the | |
| 4 | + | `zotext` SDK. | |
| 5 | + | ||
| 6 | + | ## Build | |
| 7 | + | ||
| 8 | + | ```bash | |
| 9 | + | cd examples/extensions/hello | |
| 10 | + | go build -o hello . | |
| 11 | + | ``` | |
| 12 | + | ||
| 13 | + | ## Install | |
| 14 | + | ||
| 15 | + | ```bash | |
| 16 | + | # from this directory | |
| 17 | + | zot ext install . | |
| 18 | + | ``` | |
| 19 | + | ||
| 20 | + | This copies the directory (manifest + binary) into | |
| 21 | + | `$ZOT_HOME/extensions/hello/`. zot picks it up the next time you | |
| 22 | + | launch the TUI. | |
| 23 | + | ||
| 24 | + | ## Use | |
| 25 | + | ||
| 26 | + | In zot, type: | |
| 27 | + | ||
| 28 | + | - `/hello` — agent greets you | |
| 29 | + | - `/hello <name>` — agent greets `<name>` | |
| 30 | + | - `/summon` — extension pushes a one-shot notice to the chat | |
| 31 | + | ||
| 32 | + | ## See also | |
| 33 | + | ||
| 34 | + | - `pkg/zotext` — Go SDK | |
| 35 | + | - `docs/extensions.md` — protocol reference for any-language extensions | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | # hello — example zot extension (Go) | |||
| 2 | + | ||||
| 3 | + | A minimal example showing how to register slash commands with the | |||
| 4 | + | `zotext` SDK. | |||
| 5 | + | ||||
| 6 | + | ## Build | |||
| 7 | + | ||||
| 8 | + | ```bash | |||
| 9 | + | cd examples/extensions/hello | |||
| 10 | + | go build -o hello . | |||
| 11 | + | ``` | |||
| 12 | + | ||||
| 13 | + | ## Install | |||
| 14 | + | ||||
| 15 | + | ```bash | |||
| 16 | + | # from this directory | |||
| 17 | + | zot ext install . | |||
| 18 | + | ``` | |||
| 19 | + | ||||
| 20 | + | This copies the directory (manifest + binary) into | |||
| 21 | + | `$ZOT_HOME/extensions/hello/`. zot picks it up the next time you | |||
| 22 | + | launch the TUI. | |||
| 23 | + | ||||
| 24 | + | ## Use | |||
| 25 | + | ||||
| 26 | + | In zot, type: | |||
| 27 | + | ||||
| 28 | + | - `/hello` — agent greets you | |||
| 29 | + | - `/hello <name>` — agent greets `<name>` | |||
| 30 | + | - `/summon` — extension pushes a one-shot notice to the chat | |||
| 31 | + | ||||
| 32 | + | ## See also | |||
| 33 | + | ||||
| 34 | + | - `pkg/zotext` — Go SDK | |||
| 35 | + | - `docs/extensions.md` — protocol reference for any-language extensions | |||
A examples/extensions/hello/extension.json +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "hello", | |
| 3 | + | "version": "1.0.0", | |
| 4 | + | "exec": "./hello", | |
| 5 | + | "language": "go", | |
| 6 | + | "description": "registers /hello and /summon as a starter example", | |
| 7 | + | "enabled": true | |
| 8 | + | } | |
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "hello", | |||
| 3 | + | "version": "1.0.0", | |||
| 4 | + | "exec": "./hello", | |||
| 5 | + | "language": "go", | |||
| 6 | + | "description": "registers /hello and /summon as a starter example", | |||
| 7 | + | "enabled": true | |||
| 8 | + | } | |||
A examples/extensions/hello/hello +0 -0 binary file: no text diff
binary file: no text diff
A examples/extensions/hello/main.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | // hello — a tiny zot extension that registers /hello and /summon. | |
| 2 | + | // | |
| 3 | + | // Build it: | |
| 4 | + | // | |
| 5 | + | // cd examples/extensions/hello | |
| 6 | + | // go build -o hello . | |
| 7 | + | // | |
| 8 | + | // Then drop it next to its extension.json under | |
| 9 | + | // $ZOT_HOME/extensions/hello/, or run `zot ext install ./hello` | |
| 10 | + | // from this directory. | |
| 11 | + | package main | |
| 12 | + | ||
| 13 | + | import ( | |
| 14 | + | "strings" | |
| 15 | + | ||
| 16 | + | "github.com/patriceckhart/zot/pkg/zotext" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func main() { | |
| 20 | + | ext := zotext.New("hello", "1.0.0") | |
| 21 | + | ||
| 22 | + | // /hello [name] — submits a friendly prompt to the agent. | |
| 23 | + | ext.Command("hello", "say hello (optional name)", func(args string) zotext.Response { | |
| 24 | + | who := strings.TrimSpace(args) | |
| 25 | + | if who == "" { | |
| 26 | + | return zotext.Prompt("Greet me with a short, slightly absurd compliment.") | |
| 27 | + | } | |
| 28 | + | return zotext.Prompt("Greet " + who + " with a short, slightly absurd compliment.") | |
| 29 | + | }) | |
| 30 | + | ||
| 31 | + | // /summon — pushes a notice into the chat without involving the | |
| 32 | + | // model. Useful for pretending we did something important. | |
| 33 | + | ext.Command("summon", "show a tongue-in-cheek summon notice", func(args string) zotext.Response { | |
| 34 | + | ext.Notify("info", "the daemon stirs in its cage.") | |
| 35 | + | return zotext.Display("a wisp of incense curls past your terminal.") | |
| 36 | + | }) | |
| 37 | + | ||
| 38 | + | if err := ext.Run(); err != nil { | |
| 39 | + | ext.Logf("fatal: %v", err) | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | // hello — a tiny zot extension that registers /hello and /summon. | |||
| 2 | + | // | |||
| 3 | + | // Build it: | |||
| 4 | + | // | |||
| 5 | + | // cd examples/extensions/hello | |||
| 6 | + | // go build -o hello . | |||
| 7 | + | // | |||
| 8 | + | // Then drop it next to its extension.json under | |||
| 9 | + | // $ZOT_HOME/extensions/hello/, or run `zot ext install ./hello` | |||
| 10 | + | // from this directory. | |||
| 11 | + | package main | |||
| 12 | + | ||||
| 13 | + | import ( | |||
| 14 | + | "strings" | |||
| 15 | + | ||||
| 16 | + | "github.com/patriceckhart/zot/pkg/zotext" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func main() { | |||
| 20 | + | ext := zotext.New("hello", "1.0.0") | |||
| 21 | + | ||||
| 22 | + | // /hello [name] — submits a friendly prompt to the agent. | |||
| 23 | + | ext.Command("hello", "say hello (optional name)", func(args string) zotext.Response { | |||
| 24 | + | who := strings.TrimSpace(args) | |||
| 25 | + | if who == "" { | |||
| 26 | + | return zotext.Prompt("Greet me with a short, slightly absurd compliment.") | |||
| 27 | + | } | |||
| 28 | + | return zotext.Prompt("Greet " + who + " with a short, slightly absurd compliment.") | |||
| 29 | + | }) | |||
| 30 | + | ||||
| 31 | + | // /summon — pushes a notice into the chat without involving the | |||
| 32 | + | // model. Useful for pretending we did something important. | |||
| 33 | + | ext.Command("summon", "show a tongue-in-cheek summon notice", func(args string) zotext.Response { | |||
| 34 | + | ext.Notify("info", "the daemon stirs in its cage.") | |||
| 35 | + | return zotext.Display("a wisp of incense curls past your terminal.") | |||
| 36 | + | }) | |||
| 37 | + | ||||
| 38 | + | if err := ext.Run(); err != nil { | |||
| 39 | + | ext.Logf("fatal: %v", err) | |||
| 40 | + | } | |||
| 41 | + | } | |||
M internal/agent/cli.go +56 -1
| @@ -7,7 +7,9 @@ import ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "os" | |
| 9 | 9 | "strings" | |
| 10 | + | "time" | |
| 10 | 11 | ||
| 12 | + | "github.com/patriceckhart/zot/internal/agent/extensions" | |
| 11 | 13 | "github.com/patriceckhart/zot/internal/agent/modes" | |
| 12 | 14 | "github.com/patriceckhart/zot/internal/auth" | |
| 13 | 15 | "github.com/patriceckhart/zot/internal/core" | |
| @@ -15,6 +17,42 @@ import ( | |||
| 15 | 17 | "github.com/patriceckhart/zot/internal/tui" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 20 | + | // interactiveExtHooks is a tiny adapter that lets the extension | |
| 21 | + | // manager call back into the Interactive instance built later in | |
| 22 | + | // runInteractive. The forward-declared *modes.Interactive is filled | |
| 23 | + | // in immediately after manager construction. | |
| 24 | + | type interactiveExtHooks struct { | |
| 25 | + | ivPtr **modes.Interactive | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (h *interactiveExtHooks) iv() *modes.Interactive { | |
| 29 | + | if h == nil || h.ivPtr == nil { | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | return *h.ivPtr | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (h *interactiveExtHooks) Notify(extName, level, message string) { | |
| 36 | + | if iv := h.iv(); iv != nil { | |
| 37 | + | iv.Notify(extName, level, message) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | func (h *interactiveExtHooks) Submit(text string) { | |
| 41 | + | if iv := h.iv(); iv != nil { | |
| 42 | + | iv.Submit(text) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | func (h *interactiveExtHooks) Insert(text string) { | |
| 46 | + | if iv := h.iv(); iv != nil { | |
| 47 | + | iv.Insert(text) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | func (h *interactiveExtHooks) Display(extName, text string) { | |
| 51 | + | if iv := h.iv(); iv != nil { | |
| 52 | + | iv.Display(extName, text) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 18 | 56 | // Run is the top-level entrypoint for the zot binary. | |
| 19 | 57 | func Run(rawArgs []string, version string) error { | |
| 20 | 58 | // Subcommand router: `zot bot ...` is handled separately so the | |
| @@ -22,6 +60,9 @@ func Run(rawArgs []string, version string) error { | |||
| 22 | 60 | if handled, err := runBotCommand(rawArgs, version); handled { | |
| 23 | 61 | return err | |
| 24 | 62 | } | |
| 63 | + | if handled, err := runExtCommand(rawArgs); handled { | |
| 64 | + | return err | |
| 65 | + | } | |
| 25 | 66 | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | |
| 26 | 67 | // spawn the binary with a clean argv. Strip the leading 'rpc' | |
| 27 | 68 | // token and let the rest flow through the normal arg parser. | |
| @@ -223,7 +264,20 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 223 | 264 | } | |
| 224 | 265 | }() | |
| 225 | 266 | ||
| 226 | - | iv := modes.NewInteractive(modes.InteractiveConfig{ | |
| 267 | + | // Forward-declare iv so the extension manager hook adapter can | |
| 268 | + | // dereference it after construction. The manager is started after | |
| 269 | + | // iv is built so iv (which implements extensions.HostHooks) is | |
| 270 | + | // available as the hook target. | |
| 271 | + | var iv *modes.Interactive | |
| 272 | + | extHooks := &interactiveExtHooks{ivPtr: &iv} | |
| 273 | + | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | |
| 274 | + | discoveryErrs := extMgr.Discover(ctx) | |
| 275 | + | for _, e := range discoveryErrs { | |
| 276 | + | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| 277 | + | } | |
| 278 | + | defer extMgr.Stop(2 * time.Second) | |
| 279 | + | ||
| 280 | + | iv = modes.NewInteractive(modes.InteractiveConfig{ | |
| 227 | 281 | Terminal: term, | |
| 228 | 282 | Theme: tui.Dark, | |
| 229 | 283 | Model: r.Model, | |
| @@ -245,6 +299,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 245 | 299 | BuildAgent: buildAgent, | |
| 246 | 300 | BuildAgentFor: buildAgentFor, | |
| 247 | 301 | LoadSession: loadSession, | |
| 302 | + | Extensions: extMgr, | |
| 248 | 303 | PersistModel: func(providerName, model string) { | |
| 249 | 304 | // Update config.json so next launch uses the same pick. | |
| 250 | 305 | cfg, _ := LoadConfig() | |
| @@ -7,7 +7,9 @@ import ( | |||||
| 7 | "io" | 7 | "io" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | + | "time" | |||
| 10 | 11 | ||||
| 12 | + | "github.com/patriceckhart/zot/internal/agent/extensions" | |||
| 11 | "github.com/patriceckhart/zot/internal/agent/modes" | 13 | "github.com/patriceckhart/zot/internal/agent/modes" | ||
| 12 | "github.com/patriceckhart/zot/internal/auth" | 14 | "github.com/patriceckhart/zot/internal/auth" | ||
| 13 | "github.com/patriceckhart/zot/internal/core" | 15 | "github.com/patriceckhart/zot/internal/core" | ||
| @@ -15,6 +17,42 @@ import ( | |||||
| 15 | "github.com/patriceckhart/zot/internal/tui" | 17 | "github.com/patriceckhart/zot/internal/tui" | ||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| 20 | + | // interactiveExtHooks is a tiny adapter that lets the extension | |||
| 21 | + | // manager call back into the Interactive instance built later in | |||
| 22 | + | // runInteractive. The forward-declared *modes.Interactive is filled | |||
| 23 | + | // in immediately after manager construction. | |||
| 24 | + | type interactiveExtHooks struct { | |||
| 25 | + | ivPtr **modes.Interactive | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (h *interactiveExtHooks) iv() *modes.Interactive { | |||
| 29 | + | if h == nil || h.ivPtr == nil { | |||
| 30 | + | return nil | |||
| 31 | + | } | |||
| 32 | + | return *h.ivPtr | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (h *interactiveExtHooks) Notify(extName, level, message string) { | |||
| 36 | + | if iv := h.iv(); iv != nil { | |||
| 37 | + | iv.Notify(extName, level, message) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | func (h *interactiveExtHooks) Submit(text string) { | |||
| 41 | + | if iv := h.iv(); iv != nil { | |||
| 42 | + | iv.Submit(text) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | func (h *interactiveExtHooks) Insert(text string) { | |||
| 46 | + | if iv := h.iv(); iv != nil { | |||
| 47 | + | iv.Insert(text) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | func (h *interactiveExtHooks) Display(extName, text string) { | |||
| 51 | + | if iv := h.iv(); iv != nil { | |||
| 52 | + | iv.Display(extName, text) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 18 | // Run is the top-level entrypoint for the zot binary. | 56 | // Run is the top-level entrypoint for the zot binary. | ||
| 19 | func Run(rawArgs []string, version string) error { | 57 | func Run(rawArgs []string, version string) error { | ||
| 20 | // Subcommand router: `zot bot ...` is handled separately so the | 58 | // Subcommand router: `zot bot ...` is handled separately so the | ||
| @@ -22,6 +60,9 @@ func Run(rawArgs []string, version string) error { | |||||
| 22 | if handled, err := runBotCommand(rawArgs, version); handled { | 60 | if handled, err := runBotCommand(rawArgs, version); handled { | ||
| 23 | return err | 61 | return err | ||
| 24 | } | 62 | } | ||
| 63 | + | if handled, err := runExtCommand(rawArgs); handled { | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 25 | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | 66 | // `zot rpc` is shorthand for `zot --rpc` so third-party apps can | ||
| 26 | // spawn the binary with a clean argv. Strip the leading 'rpc' | 67 | // spawn the binary with a clean argv. Strip the leading 'rpc' | ||
| 27 | // token and let the rest flow through the normal arg parser. | 68 | // token and let the rest flow through the normal arg parser. | ||
| @@ -223,7 +264,20 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 223 | } | 264 | } | ||
| 224 | }() | 265 | }() | ||
| 225 | 266 | ||||
| 226 | - | iv := modes.NewInteractive(modes.InteractiveConfig{ | 267 | + | // Forward-declare iv so the extension manager hook adapter can |
| 268 | + | // dereference it after construction. The manager is started after | |||
| 269 | + | // iv is built so iv (which implements extensions.HostHooks) is | |||
| 270 | + | // available as the hook target. | |||
| 271 | + | var iv *modes.Interactive | |||
| 272 | + | extHooks := &interactiveExtHooks{ivPtr: &iv} | |||
| 273 | + | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | |||
| 274 | + | discoveryErrs := extMgr.Discover(ctx) | |||
| 275 | + | for _, e := range discoveryErrs { | |||
| 276 | + | fmt.Fprintln(os.Stderr, "extension load:", e) | |||
| 277 | + | } | |||
| 278 | + | defer extMgr.Stop(2 * time.Second) | |||
| 279 | + | ||||
| 280 | + | iv = modes.NewInteractive(modes.InteractiveConfig{ | |||
| 227 | Terminal: term, | 281 | Terminal: term, | ||
| 228 | Theme: tui.Dark, | 282 | Theme: tui.Dark, | ||
| 229 | Model: r.Model, | 283 | Model: r.Model, | ||
| @@ -245,6 +299,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 245 | BuildAgent: buildAgent, | 299 | BuildAgent: buildAgent, | ||
| 246 | BuildAgentFor: buildAgentFor, | 300 | BuildAgentFor: buildAgentFor, | ||
| 247 | LoadSession: loadSession, | 301 | LoadSession: loadSession, | ||
| 302 | + | Extensions: extMgr, | |||
| 248 | PersistModel: func(providerName, model string) { | 303 | PersistModel: func(providerName, model string) { | ||
| 249 | // Update config.json so next launch uses the same pick. | 304 | // Update config.json so next launch uses the same pick. | ||
| 250 | cfg, _ := LoadConfig() | 305 | cfg, _ := LoadConfig() | ||
A internal/agent/extcmd.go +340 -0
| @@ -0,0 +1,340 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // runExtCommand dispatches `zot ext ...` subcommands. Returns | |
| 14 | + | // (handled=true, err) if rawArgs starts with "ext"; otherwise | |
| 15 | + | // (handled=false, nil) so the main router falls through to the | |
| 16 | + | // regular flag parser. | |
| 17 | + | func runExtCommand(rawArgs []string) (handled bool, err error) { | |
| 18 | + | if len(rawArgs) == 0 || rawArgs[0] != "ext" { | |
| 19 | + | return false, nil | |
| 20 | + | } | |
| 21 | + | if len(rawArgs) == 1 { | |
| 22 | + | printExtHelp() | |
| 23 | + | return true, nil | |
| 24 | + | } | |
| 25 | + | switch rawArgs[1] { | |
| 26 | + | case "list": | |
| 27 | + | return true, extList() | |
| 28 | + | case "logs": | |
| 29 | + | return true, extLogs(rawArgs[2:]) | |
| 30 | + | case "enable": | |
| 31 | + | return true, extToggle(rawArgs[2:], true) | |
| 32 | + | case "disable": | |
| 33 | + | return true, extToggle(rawArgs[2:], false) | |
| 34 | + | case "remove", "rm": | |
| 35 | + | return true, extRemove(rawArgs[2:]) | |
| 36 | + | case "install": | |
| 37 | + | return true, extInstall(rawArgs[2:]) | |
| 38 | + | case "help", "-h", "--help": | |
| 39 | + | printExtHelp() | |
| 40 | + | return true, nil | |
| 41 | + | default: | |
| 42 | + | printExtHelp() | |
| 43 | + | return true, fmt.Errorf("unknown ext subcommand: %s", rawArgs[1]) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func printExtHelp() { | |
| 48 | + | fmt.Fprintln(os.Stderr, `zot ext — manage extensions | |
| 49 | + | ||
| 50 | + | usage: | |
| 51 | + | zot ext list list installed extensions and their state | |
| 52 | + | zot ext logs <name> [-f] cat / tail an extension's stderr log | |
| 53 | + | zot ext enable <name> re-enable a disabled extension | |
| 54 | + | zot ext disable <name> disable without removing | |
| 55 | + | zot ext remove <name> delete an extension directory | |
| 56 | + | zot ext install <path|git-url> copy / clone an extension into $ZOT_HOME/extensions/ | |
| 57 | + | ||
| 58 | + | extensions live under: | |
| 59 | + | $ZOT_HOME/extensions/<name>/extension.json (global) | |
| 60 | + | ./.zot/extensions/<name>/extension.json (project-local)`) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // extList walks both the global and project-local extension dirs and | |
| 64 | + | // prints a one-row-per-extension table. | |
| 65 | + | func extList() error { | |
| 66 | + | type row struct { | |
| 67 | + | Scope string | |
| 68 | + | Name string | |
| 69 | + | Version string | |
| 70 | + | Enabled string | |
| 71 | + | Language string | |
| 72 | + | Dir string | |
| 73 | + | } | |
| 74 | + | var rows []row | |
| 75 | + | for scope, dir := range extensionDirs() { | |
| 76 | + | entries, err := os.ReadDir(dir) | |
| 77 | + | if err != nil { | |
| 78 | + | continue | |
| 79 | + | } | |
| 80 | + | for _, e := range entries { | |
| 81 | + | if !e.IsDir() { | |
| 82 | + | continue | |
| 83 | + | } | |
| 84 | + | extDir := filepath.Join(dir, e.Name()) | |
| 85 | + | mfPath := filepath.Join(extDir, "extension.json") | |
| 86 | + | raw, err := os.ReadFile(mfPath) | |
| 87 | + | if err != nil { | |
| 88 | + | continue | |
| 89 | + | } | |
| 90 | + | var m struct { | |
| 91 | + | Name string `json:"name"` | |
| 92 | + | Version string `json:"version"` | |
| 93 | + | Language string `json:"language"` | |
| 94 | + | Enabled *bool `json:"enabled"` | |
| 95 | + | } | |
| 96 | + | if err := json.Unmarshal(raw, &m); err != nil { | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | enabled := "yes" | |
| 100 | + | if m.Enabled != nil && !*m.Enabled { | |
| 101 | + | enabled = "no" | |
| 102 | + | } | |
| 103 | + | rows = append(rows, row{ | |
| 104 | + | Scope: scope, Name: m.Name, Version: m.Version, | |
| 105 | + | Enabled: enabled, Language: m.Language, Dir: extDir, | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | if len(rows) == 0 { | |
| 110 | + | fmt.Fprintln(os.Stderr, "no extensions installed") | |
| 111 | + | fmt.Fprintln(os.Stderr, "see docs/extensions.md to write your own, or `zot ext install <path|url>`") | |
| 112 | + | return nil | |
| 113 | + | } | |
| 114 | + | fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n", "scope", "name", "version", "enabled", "language", "dir") | |
| 115 | + | for _, r := range rows { | |
| 116 | + | fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n", | |
| 117 | + | r.Scope, r.Name, dashIfEmpty(r.Version), | |
| 118 | + | r.Enabled, dashIfEmpty(r.Language), r.Dir) | |
| 119 | + | } | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // extLogs locates the named extension's log file and either cats or | |
| 124 | + | // tails it (-f). | |
| 125 | + | func extLogs(args []string) error { | |
| 126 | + | if len(args) == 0 { | |
| 127 | + | return fmt.Errorf("usage: zot ext logs <name> [-f]") | |
| 128 | + | } | |
| 129 | + | name := args[0] | |
| 130 | + | follow := false | |
| 131 | + | for _, a := range args[1:] { | |
| 132 | + | if a == "-f" || a == "--follow" { | |
| 133 | + | follow = true | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | logPath := filepath.Join(ZotHome(), "logs", "ext-"+name+".log") | |
| 137 | + | if _, err := os.Stat(logPath); err != nil { | |
| 138 | + | return fmt.Errorf("no log for %q at %s", name, logPath) | |
| 139 | + | } | |
| 140 | + | if !follow { | |
| 141 | + | f, err := os.Open(logPath) | |
| 142 | + | if err != nil { | |
| 143 | + | return err | |
| 144 | + | } | |
| 145 | + | defer f.Close() | |
| 146 | + | _, err = io.Copy(os.Stdout, f) | |
| 147 | + | return err | |
| 148 | + | } | |
| 149 | + | cmd := exec.Command("tail", "-F", logPath) | |
| 150 | + | cmd.Stdout = os.Stdout | |
| 151 | + | cmd.Stderr = os.Stderr | |
| 152 | + | return cmd.Run() | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // extToggle flips the enabled flag in an extension's manifest. | |
| 156 | + | func extToggle(args []string, enabled bool) error { | |
| 157 | + | if len(args) == 0 { | |
| 158 | + | verb := "enable" | |
| 159 | + | if !enabled { | |
| 160 | + | verb = "disable" | |
| 161 | + | } | |
| 162 | + | return fmt.Errorf("usage: zot ext %s <name>", verb) | |
| 163 | + | } | |
| 164 | + | name := args[0] | |
| 165 | + | dir, err := findExtensionDir(name) | |
| 166 | + | if err != nil { | |
| 167 | + | return err | |
| 168 | + | } | |
| 169 | + | mfPath := filepath.Join(dir, "extension.json") | |
| 170 | + | raw, err := os.ReadFile(mfPath) | |
| 171 | + | if err != nil { | |
| 172 | + | return err | |
| 173 | + | } | |
| 174 | + | var generic map[string]any | |
| 175 | + | if err := json.Unmarshal(raw, &generic); err != nil { | |
| 176 | + | return fmt.Errorf("parse manifest: %w", err) | |
| 177 | + | } | |
| 178 | + | generic["enabled"] = enabled | |
| 179 | + | out, err := json.MarshalIndent(generic, "", " ") | |
| 180 | + | if err != nil { | |
| 181 | + | return err | |
| 182 | + | } | |
| 183 | + | if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil { | |
| 184 | + | return err | |
| 185 | + | } | |
| 186 | + | state := "enabled" | |
| 187 | + | if !enabled { | |
| 188 | + | state = "disabled" | |
| 189 | + | } | |
| 190 | + | fmt.Fprintf(os.Stderr, "%s %s\n", state, name) | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // extRemove deletes an extension's directory after a confirmation | |
| 195 | + | // prompt (skip with --yes). | |
| 196 | + | func extRemove(args []string) error { | |
| 197 | + | if len(args) == 0 { | |
| 198 | + | return fmt.Errorf("usage: zot ext remove <name> [--yes]") | |
| 199 | + | } | |
| 200 | + | name := args[0] | |
| 201 | + | yes := false | |
| 202 | + | for _, a := range args[1:] { | |
| 203 | + | if a == "--yes" || a == "-y" { | |
| 204 | + | yes = true | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | dir, err := findExtensionDir(name) | |
| 208 | + | if err != nil { | |
| 209 | + | return err | |
| 210 | + | } | |
| 211 | + | if !yes { | |
| 212 | + | fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir) | |
| 213 | + | var resp string | |
| 214 | + | _, _ = fmt.Scanln(&resp) | |
| 215 | + | if !strings.EqualFold(strings.TrimSpace(resp), "y") { | |
| 216 | + | fmt.Fprintln(os.Stderr, "aborted") | |
| 217 | + | return nil | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | if err := os.RemoveAll(dir); err != nil { | |
| 221 | + | return err | |
| 222 | + | } | |
| 223 | + | fmt.Fprintf(os.Stderr, "removed %s\n", dir) | |
| 224 | + | return nil | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // extInstall copies a local directory or shallow-clones a git URL | |
| 228 | + | // into $ZOT_HOME/extensions/. Validates the destination contains an | |
| 229 | + | // extension.json before reporting success. | |
| 230 | + | func extInstall(args []string) error { | |
| 231 | + | if len(args) == 0 { | |
| 232 | + | return fmt.Errorf("usage: zot ext install <path|git-url>") | |
| 233 | + | } | |
| 234 | + | src := args[0] | |
| 235 | + | dest := filepath.Join(ZotHome(), "extensions") | |
| 236 | + | if err := os.MkdirAll(dest, 0o755); err != nil { | |
| 237 | + | return err | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") { | |
| 241 | + | // Git clone path. Pick the destination name from the repo basename. | |
| 242 | + | name := strings.TrimSuffix(filepath.Base(src), ".git") | |
| 243 | + | out := filepath.Join(dest, name) | |
| 244 | + | if _, err := os.Stat(out); err == nil { | |
| 245 | + | return fmt.Errorf("destination %s already exists; remove it first", out) | |
| 246 | + | } | |
| 247 | + | cmd := exec.Command("git", "clone", "--depth", "1", src, out) | |
| 248 | + | cmd.Stdout = os.Stderr | |
| 249 | + | cmd.Stderr = os.Stderr | |
| 250 | + | if err := cmd.Run(); err != nil { | |
| 251 | + | return fmt.Errorf("git clone: %w", err) | |
| 252 | + | } | |
| 253 | + | if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil { | |
| 254 | + | _ = os.RemoveAll(out) | |
| 255 | + | return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back") | |
| 256 | + | } | |
| 257 | + | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |
| 258 | + | return nil | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // Local path: must be a directory containing extension.json. | |
| 262 | + | info, err := os.Stat(src) | |
| 263 | + | if err != nil { | |
| 264 | + | return err | |
| 265 | + | } | |
| 266 | + | if !info.IsDir() { | |
| 267 | + | return fmt.Errorf("not a directory: %s", src) | |
| 268 | + | } | |
| 269 | + | if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil { | |
| 270 | + | return fmt.Errorf("source lacks extension.json") | |
| 271 | + | } | |
| 272 | + | name := filepath.Base(src) | |
| 273 | + | out := filepath.Join(dest, name) | |
| 274 | + | if _, err := os.Stat(out); err == nil { | |
| 275 | + | return fmt.Errorf("destination %s already exists; remove it first", out) | |
| 276 | + | } | |
| 277 | + | if err := copyDir(src, out); err != nil { | |
| 278 | + | return err | |
| 279 | + | } | |
| 280 | + | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |
| 281 | + | return nil | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func extensionDirs() map[string]string { | |
| 285 | + | out := map[string]string{} | |
| 286 | + | if h := ZotHome(); h != "" { | |
| 287 | + | out["global"] = filepath.Join(h, "extensions") | |
| 288 | + | } | |
| 289 | + | if cwd, err := os.Getwd(); err == nil { | |
| 290 | + | out["project"] = filepath.Join(cwd, ".zot", "extensions") | |
| 291 | + | } | |
| 292 | + | return out | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func findExtensionDir(name string) (string, error) { | |
| 296 | + | for _, dir := range extensionDirs() { | |
| 297 | + | candidate := filepath.Join(dir, name) | |
| 298 | + | if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil { | |
| 299 | + | return candidate, nil | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | return "", fmt.Errorf("extension %q not found", name) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func dashIfEmpty(s string) string { | |
| 306 | + | if s == "" { | |
| 307 | + | return "-" | |
| 308 | + | } | |
| 309 | + | return s | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // copyDir does a recursive copy of src to dst preserving file mode | |
| 313 | + | // bits. Used by `zot ext install <local-path>`. | |
| 314 | + | func copyDir(src, dst string) error { | |
| 315 | + | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | |
| 316 | + | if err != nil { | |
| 317 | + | return err | |
| 318 | + | } | |
| 319 | + | rel, err := filepath.Rel(src, path) | |
| 320 | + | if err != nil { | |
| 321 | + | return err | |
| 322 | + | } | |
| 323 | + | target := filepath.Join(dst, rel) | |
| 324 | + | if info.IsDir() { | |
| 325 | + | return os.MkdirAll(target, info.Mode()) | |
| 326 | + | } | |
| 327 | + | in, err := os.Open(path) | |
| 328 | + | if err != nil { | |
| 329 | + | return err | |
| 330 | + | } | |
| 331 | + | defer in.Close() | |
| 332 | + | out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) | |
| 333 | + | if err != nil { | |
| 334 | + | return err | |
| 335 | + | } | |
| 336 | + | defer out.Close() | |
| 337 | + | _, err = io.Copy(out, in) | |
| 338 | + | return err | |
| 339 | + | }) | |
| 340 | + | } | |
| @@ -0,0 +1,340 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // runExtCommand dispatches `zot ext ...` subcommands. Returns | |||
| 14 | + | // (handled=true, err) if rawArgs starts with "ext"; otherwise | |||
| 15 | + | // (handled=false, nil) so the main router falls through to the | |||
| 16 | + | // regular flag parser. | |||
| 17 | + | func runExtCommand(rawArgs []string) (handled bool, err error) { | |||
| 18 | + | if len(rawArgs) == 0 || rawArgs[0] != "ext" { | |||
| 19 | + | return false, nil | |||
| 20 | + | } | |||
| 21 | + | if len(rawArgs) == 1 { | |||
| 22 | + | printExtHelp() | |||
| 23 | + | return true, nil | |||
| 24 | + | } | |||
| 25 | + | switch rawArgs[1] { | |||
| 26 | + | case "list": | |||
| 27 | + | return true, extList() | |||
| 28 | + | case "logs": | |||
| 29 | + | return true, extLogs(rawArgs[2:]) | |||
| 30 | + | case "enable": | |||
| 31 | + | return true, extToggle(rawArgs[2:], true) | |||
| 32 | + | case "disable": | |||
| 33 | + | return true, extToggle(rawArgs[2:], false) | |||
| 34 | + | case "remove", "rm": | |||
| 35 | + | return true, extRemove(rawArgs[2:]) | |||
| 36 | + | case "install": | |||
| 37 | + | return true, extInstall(rawArgs[2:]) | |||
| 38 | + | case "help", "-h", "--help": | |||
| 39 | + | printExtHelp() | |||
| 40 | + | return true, nil | |||
| 41 | + | default: | |||
| 42 | + | printExtHelp() | |||
| 43 | + | return true, fmt.Errorf("unknown ext subcommand: %s", rawArgs[1]) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func printExtHelp() { | |||
| 48 | + | fmt.Fprintln(os.Stderr, `zot ext — manage extensions | |||
| 49 | + | ||||
| 50 | + | usage: | |||
| 51 | + | zot ext list list installed extensions and their state | |||
| 52 | + | zot ext logs <name> [-f] cat / tail an extension's stderr log | |||
| 53 | + | zot ext enable <name> re-enable a disabled extension | |||
| 54 | + | zot ext disable <name> disable without removing | |||
| 55 | + | zot ext remove <name> delete an extension directory | |||
| 56 | + | zot ext install <path|git-url> copy / clone an extension into $ZOT_HOME/extensions/ | |||
| 57 | + | ||||
| 58 | + | extensions live under: | |||
| 59 | + | $ZOT_HOME/extensions/<name>/extension.json (global) | |||
| 60 | + | ./.zot/extensions/<name>/extension.json (project-local)`) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // extList walks both the global and project-local extension dirs and | |||
| 64 | + | // prints a one-row-per-extension table. | |||
| 65 | + | func extList() error { | |||
| 66 | + | type row struct { | |||
| 67 | + | Scope string | |||
| 68 | + | Name string | |||
| 69 | + | Version string | |||
| 70 | + | Enabled string | |||
| 71 | + | Language string | |||
| 72 | + | Dir string | |||
| 73 | + | } | |||
| 74 | + | var rows []row | |||
| 75 | + | for scope, dir := range extensionDirs() { | |||
| 76 | + | entries, err := os.ReadDir(dir) | |||
| 77 | + | if err != nil { | |||
| 78 | + | continue | |||
| 79 | + | } | |||
| 80 | + | for _, e := range entries { | |||
| 81 | + | if !e.IsDir() { | |||
| 82 | + | continue | |||
| 83 | + | } | |||
| 84 | + | extDir := filepath.Join(dir, e.Name()) | |||
| 85 | + | mfPath := filepath.Join(extDir, "extension.json") | |||
| 86 | + | raw, err := os.ReadFile(mfPath) | |||
| 87 | + | if err != nil { | |||
| 88 | + | continue | |||
| 89 | + | } | |||
| 90 | + | var m struct { | |||
| 91 | + | Name string `json:"name"` | |||
| 92 | + | Version string `json:"version"` | |||
| 93 | + | Language string `json:"language"` | |||
| 94 | + | Enabled *bool `json:"enabled"` | |||
| 95 | + | } | |||
| 96 | + | if err := json.Unmarshal(raw, &m); err != nil { | |||
| 97 | + | continue | |||
| 98 | + | } | |||
| 99 | + | enabled := "yes" | |||
| 100 | + | if m.Enabled != nil && !*m.Enabled { | |||
| 101 | + | enabled = "no" | |||
| 102 | + | } | |||
| 103 | + | rows = append(rows, row{ | |||
| 104 | + | Scope: scope, Name: m.Name, Version: m.Version, | |||
| 105 | + | Enabled: enabled, Language: m.Language, Dir: extDir, | |||
| 106 | + | }) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | if len(rows) == 0 { | |||
| 110 | + | fmt.Fprintln(os.Stderr, "no extensions installed") | |||
| 111 | + | fmt.Fprintln(os.Stderr, "see docs/extensions.md to write your own, or `zot ext install <path|url>`") | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 114 | + | fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n", "scope", "name", "version", "enabled", "language", "dir") | |||
| 115 | + | for _, r := range rows { | |||
| 116 | + | fmt.Printf("%-12s %-20s %-10s %-8s %-10s %s\n", | |||
| 117 | + | r.Scope, r.Name, dashIfEmpty(r.Version), | |||
| 118 | + | r.Enabled, dashIfEmpty(r.Language), r.Dir) | |||
| 119 | + | } | |||
| 120 | + | return nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // extLogs locates the named extension's log file and either cats or | |||
| 124 | + | // tails it (-f). | |||
| 125 | + | func extLogs(args []string) error { | |||
| 126 | + | if len(args) == 0 { | |||
| 127 | + | return fmt.Errorf("usage: zot ext logs <name> [-f]") | |||
| 128 | + | } | |||
| 129 | + | name := args[0] | |||
| 130 | + | follow := false | |||
| 131 | + | for _, a := range args[1:] { | |||
| 132 | + | if a == "-f" || a == "--follow" { | |||
| 133 | + | follow = true | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | logPath := filepath.Join(ZotHome(), "logs", "ext-"+name+".log") | |||
| 137 | + | if _, err := os.Stat(logPath); err != nil { | |||
| 138 | + | return fmt.Errorf("no log for %q at %s", name, logPath) | |||
| 139 | + | } | |||
| 140 | + | if !follow { | |||
| 141 | + | f, err := os.Open(logPath) | |||
| 142 | + | if err != nil { | |||
| 143 | + | return err | |||
| 144 | + | } | |||
| 145 | + | defer f.Close() | |||
| 146 | + | _, err = io.Copy(os.Stdout, f) | |||
| 147 | + | return err | |||
| 148 | + | } | |||
| 149 | + | cmd := exec.Command("tail", "-F", logPath) | |||
| 150 | + | cmd.Stdout = os.Stdout | |||
| 151 | + | cmd.Stderr = os.Stderr | |||
| 152 | + | return cmd.Run() | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // extToggle flips the enabled flag in an extension's manifest. | |||
| 156 | + | func extToggle(args []string, enabled bool) error { | |||
| 157 | + | if len(args) == 0 { | |||
| 158 | + | verb := "enable" | |||
| 159 | + | if !enabled { | |||
| 160 | + | verb = "disable" | |||
| 161 | + | } | |||
| 162 | + | return fmt.Errorf("usage: zot ext %s <name>", verb) | |||
| 163 | + | } | |||
| 164 | + | name := args[0] | |||
| 165 | + | dir, err := findExtensionDir(name) | |||
| 166 | + | if err != nil { | |||
| 167 | + | return err | |||
| 168 | + | } | |||
| 169 | + | mfPath := filepath.Join(dir, "extension.json") | |||
| 170 | + | raw, err := os.ReadFile(mfPath) | |||
| 171 | + | if err != nil { | |||
| 172 | + | return err | |||
| 173 | + | } | |||
| 174 | + | var generic map[string]any | |||
| 175 | + | if err := json.Unmarshal(raw, &generic); err != nil { | |||
| 176 | + | return fmt.Errorf("parse manifest: %w", err) | |||
| 177 | + | } | |||
| 178 | + | generic["enabled"] = enabled | |||
| 179 | + | out, err := json.MarshalIndent(generic, "", " ") | |||
| 180 | + | if err != nil { | |||
| 181 | + | return err | |||
| 182 | + | } | |||
| 183 | + | if err := os.WriteFile(mfPath, append(out, '\n'), 0o644); err != nil { | |||
| 184 | + | return err | |||
| 185 | + | } | |||
| 186 | + | state := "enabled" | |||
| 187 | + | if !enabled { | |||
| 188 | + | state = "disabled" | |||
| 189 | + | } | |||
| 190 | + | fmt.Fprintf(os.Stderr, "%s %s\n", state, name) | |||
| 191 | + | return nil | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // extRemove deletes an extension's directory after a confirmation | |||
| 195 | + | // prompt (skip with --yes). | |||
| 196 | + | func extRemove(args []string) error { | |||
| 197 | + | if len(args) == 0 { | |||
| 198 | + | return fmt.Errorf("usage: zot ext remove <name> [--yes]") | |||
| 199 | + | } | |||
| 200 | + | name := args[0] | |||
| 201 | + | yes := false | |||
| 202 | + | for _, a := range args[1:] { | |||
| 203 | + | if a == "--yes" || a == "-y" { | |||
| 204 | + | yes = true | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | dir, err := findExtensionDir(name) | |||
| 208 | + | if err != nil { | |||
| 209 | + | return err | |||
| 210 | + | } | |||
| 211 | + | if !yes { | |||
| 212 | + | fmt.Fprintf(os.Stderr, "remove %s ? [y/N] ", dir) | |||
| 213 | + | var resp string | |||
| 214 | + | _, _ = fmt.Scanln(&resp) | |||
| 215 | + | if !strings.EqualFold(strings.TrimSpace(resp), "y") { | |||
| 216 | + | fmt.Fprintln(os.Stderr, "aborted") | |||
| 217 | + | return nil | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | if err := os.RemoveAll(dir); err != nil { | |||
| 221 | + | return err | |||
| 222 | + | } | |||
| 223 | + | fmt.Fprintf(os.Stderr, "removed %s\n", dir) | |||
| 224 | + | return nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // extInstall copies a local directory or shallow-clones a git URL | |||
| 228 | + | // into $ZOT_HOME/extensions/. Validates the destination contains an | |||
| 229 | + | // extension.json before reporting success. | |||
| 230 | + | func extInstall(args []string) error { | |||
| 231 | + | if len(args) == 0 { | |||
| 232 | + | return fmt.Errorf("usage: zot ext install <path|git-url>") | |||
| 233 | + | } | |||
| 234 | + | src := args[0] | |||
| 235 | + | dest := filepath.Join(ZotHome(), "extensions") | |||
| 236 | + | if err := os.MkdirAll(dest, 0o755); err != nil { | |||
| 237 | + | return err | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "git@") || strings.HasSuffix(src, ".git") { | |||
| 241 | + | // Git clone path. Pick the destination name from the repo basename. | |||
| 242 | + | name := strings.TrimSuffix(filepath.Base(src), ".git") | |||
| 243 | + | out := filepath.Join(dest, name) | |||
| 244 | + | if _, err := os.Stat(out); err == nil { | |||
| 245 | + | return fmt.Errorf("destination %s already exists; remove it first", out) | |||
| 246 | + | } | |||
| 247 | + | cmd := exec.Command("git", "clone", "--depth", "1", src, out) | |||
| 248 | + | cmd.Stdout = os.Stderr | |||
| 249 | + | cmd.Stderr = os.Stderr | |||
| 250 | + | if err := cmd.Run(); err != nil { | |||
| 251 | + | return fmt.Errorf("git clone: %w", err) | |||
| 252 | + | } | |||
| 253 | + | if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil { | |||
| 254 | + | _ = os.RemoveAll(out) | |||
| 255 | + | return fmt.Errorf("installed dir lacks extension.json; aborted and rolled back") | |||
| 256 | + | } | |||
| 257 | + | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |||
| 258 | + | return nil | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // Local path: must be a directory containing extension.json. | |||
| 262 | + | info, err := os.Stat(src) | |||
| 263 | + | if err != nil { | |||
| 264 | + | return err | |||
| 265 | + | } | |||
| 266 | + | if !info.IsDir() { | |||
| 267 | + | return fmt.Errorf("not a directory: %s", src) | |||
| 268 | + | } | |||
| 269 | + | if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil { | |||
| 270 | + | return fmt.Errorf("source lacks extension.json") | |||
| 271 | + | } | |||
| 272 | + | name := filepath.Base(src) | |||
| 273 | + | out := filepath.Join(dest, name) | |||
| 274 | + | if _, err := os.Stat(out); err == nil { | |||
| 275 | + | return fmt.Errorf("destination %s already exists; remove it first", out) | |||
| 276 | + | } | |||
| 277 | + | if err := copyDir(src, out); err != nil { | |||
| 278 | + | return err | |||
| 279 | + | } | |||
| 280 | + | fmt.Fprintf(os.Stderr, "installed %s\n", out) | |||
| 281 | + | return nil | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func extensionDirs() map[string]string { | |||
| 285 | + | out := map[string]string{} | |||
| 286 | + | if h := ZotHome(); h != "" { | |||
| 287 | + | out["global"] = filepath.Join(h, "extensions") | |||
| 288 | + | } | |||
| 289 | + | if cwd, err := os.Getwd(); err == nil { | |||
| 290 | + | out["project"] = filepath.Join(cwd, ".zot", "extensions") | |||
| 291 | + | } | |||
| 292 | + | return out | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func findExtensionDir(name string) (string, error) { | |||
| 296 | + | for _, dir := range extensionDirs() { | |||
| 297 | + | candidate := filepath.Join(dir, name) | |||
| 298 | + | if _, err := os.Stat(filepath.Join(candidate, "extension.json")); err == nil { | |||
| 299 | + | return candidate, nil | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | return "", fmt.Errorf("extension %q not found", name) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func dashIfEmpty(s string) string { | |||
| 306 | + | if s == "" { | |||
| 307 | + | return "-" | |||
| 308 | + | } | |||
| 309 | + | return s | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // copyDir does a recursive copy of src to dst preserving file mode | |||
| 313 | + | // bits. Used by `zot ext install <local-path>`. | |||
| 314 | + | func copyDir(src, dst string) error { | |||
| 315 | + | return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { | |||
| 316 | + | if err != nil { | |||
| 317 | + | return err | |||
| 318 | + | } | |||
| 319 | + | rel, err := filepath.Rel(src, path) | |||
| 320 | + | if err != nil { | |||
| 321 | + | return err | |||
| 322 | + | } | |||
| 323 | + | target := filepath.Join(dst, rel) | |||
| 324 | + | if info.IsDir() { | |||
| 325 | + | return os.MkdirAll(target, info.Mode()) | |||
| 326 | + | } | |||
| 327 | + | in, err := os.Open(path) | |||
| 328 | + | if err != nil { | |||
| 329 | + | return err | |||
| 330 | + | } | |||
| 331 | + | defer in.Close() | |||
| 332 | + | out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) | |||
| 333 | + | if err != nil { | |||
| 334 | + | return err | |||
| 335 | + | } | |||
| 336 | + | defer out.Close() | |||
| 337 | + | _, err = io.Copy(out, in) | |||
| 338 | + | return err | |||
| 339 | + | }) | |||
| 340 | + | } | |||
A internal/agent/extensions/manager.go +496 -0
| @@ -0,0 +1,496 @@ | |||
| 1 | + | // Package extensions implements the host side of zot's subprocess | |
| 2 | + | // extension protocol. The Manager discovers extensions in well-known | |
| 3 | + | // directories, spawns each one, completes the hello handshake, and | |
| 4 | + | // routes slash commands to the right extension. | |
| 5 | + | // | |
| 6 | + | // Each extension is its own process, communicating with zot over its | |
| 7 | + | // stdin/stdout in newline-delimited JSON. Stderr is redirected to a | |
| 8 | + | // per-extension log file under $ZOT_HOME/logs/. Crashing one | |
| 9 | + | // extension does not affect the others or the host. | |
| 10 | + | // | |
| 11 | + | // See docs/extensions.md for the user-facing reference and | |
| 12 | + | // internal/extproto for the wire format. | |
| 13 | + | package extensions | |
| 14 | + | ||
| 15 | + | import ( | |
| 16 | + | "bufio" | |
| 17 | + | "context" | |
| 18 | + | "encoding/json" | |
| 19 | + | "errors" | |
| 20 | + | "fmt" | |
| 21 | + | "io" | |
| 22 | + | "os" | |
| 23 | + | "os/exec" | |
| 24 | + | "path/filepath" | |
| 25 | + | "strings" | |
| 26 | + | "sync" | |
| 27 | + | "syscall" | |
| 28 | + | "time" | |
| 29 | + | ||
| 30 | + | "github.com/patriceckhart/zot/internal/extproto" | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | // Manifest is the extension.json file shipped alongside an | |
| 34 | + | // extension's executable. It tells zot how to launch the extension | |
| 35 | + | // and provides display metadata. | |
| 36 | + | type Manifest struct { | |
| 37 | + | Name string `json:"name"` | |
| 38 | + | Version string `json:"version,omitempty"` | |
| 39 | + | Exec string `json:"exec"` // executable path, relative to manifest dir | |
| 40 | + | Args []string `json:"args,omitempty"` // extra argv passed to exec | |
| 41 | + | Language string `json:"language,omitempty"` // informational ("go", "python", "typescript", ...) | |
| 42 | + | Enabled *bool `json:"enabled,omitempty"` // nil = enabled | |
| 43 | + | Description string `json:"description,omitempty"` | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // IsEnabled returns the manifest's effective enabled state. Default | |
| 47 | + | // is true so adding a new extension folder Just Works without an | |
| 48 | + | // extra zot ext enable command. | |
| 49 | + | func (m Manifest) IsEnabled() bool { | |
| 50 | + | return m.Enabled == nil || *m.Enabled | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Extension is a running extension subprocess and the metadata zot | |
| 54 | + | // tracks about it. | |
| 55 | + | type Extension struct { | |
| 56 | + | Manifest Manifest | |
| 57 | + | Dir string // absolute path to extension directory | |
| 58 | + | LogPath string | |
| 59 | + | ||
| 60 | + | cmd *exec.Cmd | |
| 61 | + | stdin io.WriteCloser | |
| 62 | + | stdout io.ReadCloser | |
| 63 | + | logFile *os.File | |
| 64 | + | helloAck bool | |
| 65 | + | commands []extproto.RegisterCommandFromExt | |
| 66 | + | ||
| 67 | + | // pending command invocations waiting on a CommandResponseFromExt | |
| 68 | + | // keyed by the id we sent in CommandInvokedFromHost. | |
| 69 | + | mu sync.Mutex | |
| 70 | + | pending map[string]chan extproto.CommandResponseFromExt | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // HostHooks is the small interface the manager calls back into the | |
| 74 | + | // running TUI through. Decouples extensions from interactive.go. | |
| 75 | + | type HostHooks interface { | |
| 76 | + | // Notify pushes an ext-originated status message into the chat. | |
| 77 | + | // Level is one of "info", "warn", "error", "success". | |
| 78 | + | Notify(extName, level, message string) | |
| 79 | + | ||
| 80 | + | // Submit feeds text as if the user had typed and pressed enter, | |
| 81 | + | // running it through the agent loop. | |
| 82 | + | Submit(text string) | |
| 83 | + | ||
| 84 | + | // Insert places text at the cursor in the editor. | |
| 85 | + | Insert(text string) | |
| 86 | + | ||
| 87 | + | // Display appends a one-shot styled note to the chat without | |
| 88 | + | // invoking the model and without writing to the transcript. | |
| 89 | + | Display(extName, text string) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Manager owns every extension subprocess for the lifetime of zot. | |
| 93 | + | type Manager struct { | |
| 94 | + | zotHome string | |
| 95 | + | cwd string | |
| 96 | + | zotVersion string | |
| 97 | + | provider string | |
| 98 | + | model string | |
| 99 | + | hooks HostHooks | |
| 100 | + | ||
| 101 | + | mu sync.RWMutex | |
| 102 | + | ext map[string]*Extension // keyed by manifest name | |
| 103 | + | ||
| 104 | + | // commandIndex maps a slash-command name (without the leading /) | |
| 105 | + | // to the extension that registered it. First-come-first-served: | |
| 106 | + | // later registrations of the same command are dropped with a | |
| 107 | + | // warning. | |
| 108 | + | commandIndex map[string]*Extension | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // New constructs an empty Manager. Call Discover to populate it from | |
| 112 | + | // the on-disk extension directories. | |
| 113 | + | func New(zotHome, cwd, zotVersion, provider, model string, hooks HostHooks) *Manager { | |
| 114 | + | return &Manager{ | |
| 115 | + | zotHome: zotHome, | |
| 116 | + | cwd: cwd, | |
| 117 | + | zotVersion: zotVersion, | |
| 118 | + | provider: provider, | |
| 119 | + | model: model, | |
| 120 | + | hooks: hooks, | |
| 121 | + | ext: map[string]*Extension{}, | |
| 122 | + | commandIndex: map[string]*Extension{}, | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Discover scans the global and project extension dirs and starts | |
| 127 | + | // every extension whose manifest is enabled. Returns a slice of | |
| 128 | + | // errors encountered (one per extension); a single bad extension | |
| 129 | + | // doesn't abort the rest. | |
| 130 | + | func (m *Manager) Discover(ctx context.Context) []error { | |
| 131 | + | var errs []error | |
| 132 | + | for _, dir := range m.searchDirs() { | |
| 133 | + | entries, err := os.ReadDir(dir) | |
| 134 | + | if err != nil { | |
| 135 | + | continue // missing directory is fine | |
| 136 | + | } | |
| 137 | + | for _, e := range entries { | |
| 138 | + | if !e.IsDir() { | |
| 139 | + | continue | |
| 140 | + | } | |
| 141 | + | extDir := filepath.Join(dir, e.Name()) | |
| 142 | + | if err := m.loadOne(ctx, extDir); err != nil { | |
| 143 | + | errs = append(errs, fmt.Errorf("%s: %w", extDir, err)) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | return errs | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // searchDirs returns the directories the discoverer walks, in | |
| 151 | + | // priority order: project-local first (so a project can override | |
| 152 | + | // global behavior), then global. | |
| 153 | + | func (m *Manager) searchDirs() []string { | |
| 154 | + | var dirs []string | |
| 155 | + | if m.cwd != "" { | |
| 156 | + | dirs = append(dirs, filepath.Join(m.cwd, ".zot", "extensions")) | |
| 157 | + | } | |
| 158 | + | if m.zotHome != "" { | |
| 159 | + | dirs = append(dirs, filepath.Join(m.zotHome, "extensions")) | |
| 160 | + | } | |
| 161 | + | return dirs | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // loadOne reads a single extension's manifest and, if enabled, | |
| 165 | + | // spawns its subprocess + completes the hello handshake. | |
| 166 | + | func (m *Manager) loadOne(ctx context.Context, dir string) error { | |
| 167 | + | manifestPath := filepath.Join(dir, "extension.json") | |
| 168 | + | raw, err := os.ReadFile(manifestPath) | |
| 169 | + | if err != nil { | |
| 170 | + | return fmt.Errorf("read manifest: %w", err) | |
| 171 | + | } | |
| 172 | + | var mf Manifest | |
| 173 | + | if err := json.Unmarshal(raw, &mf); err != nil { | |
| 174 | + | return fmt.Errorf("parse manifest: %w", err) | |
| 175 | + | } | |
| 176 | + | if mf.Name == "" { | |
| 177 | + | return errors.New("manifest: name is required") | |
| 178 | + | } | |
| 179 | + | if mf.Exec == "" { | |
| 180 | + | return errors.New("manifest: exec is required") | |
| 181 | + | } | |
| 182 | + | if !mf.IsEnabled() { | |
| 183 | + | // Quietly skip disabled extensions; zot ext list will show them. | |
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | m.mu.RLock() | |
| 188 | + | _, dup := m.ext[mf.Name] | |
| 189 | + | m.mu.RUnlock() | |
| 190 | + | if dup { | |
| 191 | + | // Project-local copy already won; ignore the global one. | |
| 192 | + | return nil | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | ext := &Extension{ | |
| 196 | + | Manifest: mf, | |
| 197 | + | Dir: dir, | |
| 198 | + | pending: map[string]chan extproto.CommandResponseFromExt{}, | |
| 199 | + | } | |
| 200 | + | if err := m.spawn(ctx, ext); err != nil { | |
| 201 | + | return err | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | m.mu.Lock() | |
| 205 | + | m.ext[mf.Name] = ext | |
| 206 | + | for _, c := range ext.commands { | |
| 207 | + | if _, exists := m.commandIndex[c.Name]; exists { | |
| 208 | + | // Conflict: keep the existing registration; drop this one. | |
| 209 | + | fmt.Fprintf(ext.logFile, "[zot] /%s already registered, ignoring\n", c.Name) | |
| 210 | + | continue | |
| 211 | + | } | |
| 212 | + | m.commandIndex[c.Name] = ext | |
| 213 | + | } | |
| 214 | + | m.mu.Unlock() | |
| 215 | + | return nil | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // spawn launches the subprocess, hooks up pipes, logs stderr, and | |
| 219 | + | // runs the synchronous portion of the hello handshake. Asynchronous | |
| 220 | + | // frames are processed in a goroutine started here. | |
| 221 | + | func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |
| 222 | + | logsDir := filepath.Join(m.zotHome, "logs") | |
| 223 | + | _ = os.MkdirAll(logsDir, 0o755) | |
| 224 | + | logPath := filepath.Join(logsDir, "ext-"+ext.Manifest.Name+".log") | |
| 225 | + | logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) | |
| 226 | + | if err != nil { | |
| 227 | + | return fmt.Errorf("open log: %w", err) | |
| 228 | + | } | |
| 229 | + | ext.LogPath = logPath | |
| 230 | + | ext.logFile = logFile | |
| 231 | + | fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339)) | |
| 232 | + | ||
| 233 | + | execPath := ext.Manifest.Exec | |
| 234 | + | if !filepath.IsAbs(execPath) { | |
| 235 | + | execPath = filepath.Join(ext.Dir, execPath) | |
| 236 | + | } | |
| 237 | + | cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...) | |
| 238 | + | cmd.Dir = ext.Dir | |
| 239 | + | cmd.Stderr = logFile | |
| 240 | + | ||
| 241 | + | stdin, err := cmd.StdinPipe() | |
| 242 | + | if err != nil { | |
| 243 | + | return err | |
| 244 | + | } | |
| 245 | + | stdout, err := cmd.StdoutPipe() | |
| 246 | + | if err != nil { | |
| 247 | + | return err | |
| 248 | + | } | |
| 249 | + | if err := cmd.Start(); err != nil { | |
| 250 | + | return fmt.Errorf("spawn: %w", err) | |
| 251 | + | } | |
| 252 | + | ext.cmd = cmd | |
| 253 | + | ext.stdin = stdin | |
| 254 | + | ext.stdout = stdout | |
| 255 | + | ||
| 256 | + | // Hello handshake. Read the extension's HelloFromExt synchronously | |
| 257 | + | // so we can fail fast on a broken extension; everything after is | |
| 258 | + | // processed in the read goroutine. | |
| 259 | + | scanner := bufio.NewScanner(stdout) | |
| 260 | + | scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | |
| 261 | + | if !scanner.Scan() { | |
| 262 | + | return fmt.Errorf("extension exited before hello: %w", scanner.Err()) | |
| 263 | + | } | |
| 264 | + | var hello extproto.HelloFromExt | |
| 265 | + | if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil { | |
| 266 | + | return fmt.Errorf("parse hello: %w", err) | |
| 267 | + | } | |
| 268 | + | if hello.Type != "hello" || hello.Name == "" { | |
| 269 | + | return fmt.Errorf("first frame must be hello (got %q)", hello.Type) | |
| 270 | + | } | |
| 271 | + | // Trust the manifest's name; ignore mismatch from the hello. | |
| 272 | + | ext.helloAck = true | |
| 273 | + | ||
| 274 | + | ack, _ := extproto.Encode(extproto.HelloAckFromHost{ | |
| 275 | + | Type: "hello_ack", | |
| 276 | + | ProtocolVersion: extproto.ProtocolVersion, | |
| 277 | + | ZotVersion: m.zotVersion, | |
| 278 | + | Provider: m.provider, | |
| 279 | + | Model: m.model, | |
| 280 | + | CWD: m.cwd, | |
| 281 | + | }) | |
| 282 | + | if _, err := stdin.Write(ack); err != nil { | |
| 283 | + | return fmt.Errorf("send hello_ack: %w", err) | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // Spin up the read loop now that the handshake is done. | |
| 287 | + | go m.readLoop(ext, scanner) | |
| 288 | + | return nil | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // readLoop processes every frame the extension sends after hello. | |
| 292 | + | // Returns when stdout closes. | |
| 293 | + | func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |
| 294 | + | defer func() { | |
| 295 | + | // On close, drop every command this extension owned so future | |
| 296 | + | // invocations don't dangle. The subprocess is gone; we won't | |
| 297 | + | // hear back about its commands anymore. | |
| 298 | + | m.mu.Lock() | |
| 299 | + | for name, owner := range m.commandIndex { | |
| 300 | + | if owner == ext { | |
| 301 | + | delete(m.commandIndex, name) | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | m.mu.Unlock() | |
| 305 | + | fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339)) | |
| 306 | + | }() | |
| 307 | + | ||
| 308 | + | for scanner.Scan() { | |
| 309 | + | line := scanner.Bytes() | |
| 310 | + | var frame extproto.Frame | |
| 311 | + | if err := json.Unmarshal(line, &frame); err != nil { | |
| 312 | + | fmt.Fprintf(ext.logFile, "[zot] malformed json from extension: %v\n", err) | |
| 313 | + | continue | |
| 314 | + | } | |
| 315 | + | switch frame.Type { | |
| 316 | + | case "register_command": | |
| 317 | + | var rc extproto.RegisterCommandFromExt | |
| 318 | + | if err := json.Unmarshal(line, &rc); err == nil { | |
| 319 | + | ext.commands = append(ext.commands, rc) | |
| 320 | + | m.mu.Lock() | |
| 321 | + | if _, exists := m.commandIndex[rc.Name]; !exists { | |
| 322 | + | m.commandIndex[rc.Name] = ext | |
| 323 | + | } | |
| 324 | + | m.mu.Unlock() | |
| 325 | + | } | |
| 326 | + | case "notify": | |
| 327 | + | var n extproto.NotifyFromExt | |
| 328 | + | if err := json.Unmarshal(line, &n); err == nil { | |
| 329 | + | m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message) | |
| 330 | + | } | |
| 331 | + | case "command_response": | |
| 332 | + | var cr extproto.CommandResponseFromExt | |
| 333 | + | if err := json.Unmarshal(line, &cr); err == nil { | |
| 334 | + | ext.mu.Lock() | |
| 335 | + | ch, ok := ext.pending[cr.ID] | |
| 336 | + | if ok { | |
| 337 | + | delete(ext.pending, cr.ID) | |
| 338 | + | } | |
| 339 | + | ext.mu.Unlock() | |
| 340 | + | if ok { | |
| 341 | + | select { | |
| 342 | + | case ch <- cr: | |
| 343 | + | default: | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | case "shutdown_ack": | |
| 348 | + | // Caller of Stop is waiting on the process exit, not this frame. | |
| 349 | + | default: | |
| 350 | + | fmt.Fprintf(ext.logFile, "[zot] unknown frame type %q\n", frame.Type) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // Commands returns a snapshot of every (extension, command) pair | |
| 356 | + | // currently registered. Used by the slash autocomplete + /help. | |
| 357 | + | func (m *Manager) Commands() []CommandInfo { | |
| 358 | + | m.mu.RLock() | |
| 359 | + | defer m.mu.RUnlock() | |
| 360 | + | var out []CommandInfo | |
| 361 | + | for _, ext := range m.ext { | |
| 362 | + | for _, c := range ext.commands { | |
| 363 | + | out = append(out, CommandInfo{ | |
| 364 | + | Extension: ext.Manifest.Name, | |
| 365 | + | Name: c.Name, | |
| 366 | + | Description: c.Description, | |
| 367 | + | }) | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | return out | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // CommandInfo is one extension-registered slash command, surfaced to | |
| 374 | + | // the rest of zot for display purposes. | |
| 375 | + | type CommandInfo struct { | |
| 376 | + | Extension string | |
| 377 | + | Name string | |
| 378 | + | Description string | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // HasCommand reports whether name is registered by any extension. | |
| 382 | + | func (m *Manager) HasCommand(name string) bool { | |
| 383 | + | m.mu.RLock() | |
| 384 | + | defer m.mu.RUnlock() | |
| 385 | + | _, ok := m.commandIndex[name] | |
| 386 | + | return ok | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // Invoke fires the named slash command's handler in the owning | |
| 390 | + | // extension and waits up to timeout for the response. Returns the | |
| 391 | + | // extension's CommandResponse so the caller can act on the action | |
| 392 | + | // (prompt / insert / display). | |
| 393 | + | func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) { | |
| 394 | + | m.mu.RLock() | |
| 395 | + | ext, ok := m.commandIndex[name] | |
| 396 | + | m.mu.RUnlock() | |
| 397 | + | if !ok { | |
| 398 | + | return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | id := newCorrelationID() | |
| 402 | + | ch := make(chan extproto.CommandResponseFromExt, 1) | |
| 403 | + | ext.mu.Lock() | |
| 404 | + | ext.pending[id] = ch | |
| 405 | + | ext.mu.Unlock() | |
| 406 | + | ||
| 407 | + | frame, _ := extproto.Encode(extproto.CommandInvokedFromHost{ | |
| 408 | + | Type: "command_invoked", | |
| 409 | + | ID: id, | |
| 410 | + | Name: name, | |
| 411 | + | Args: args, | |
| 412 | + | }) | |
| 413 | + | if _, err := ext.stdin.Write(frame); err != nil { | |
| 414 | + | ext.mu.Lock() | |
| 415 | + | delete(ext.pending, id) | |
| 416 | + | ext.mu.Unlock() | |
| 417 | + | return extproto.CommandResponseFromExt{}, fmt.Errorf("write: %w", err) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | select { | |
| 421 | + | case resp := <-ch: | |
| 422 | + | return resp, nil | |
| 423 | + | case <-time.After(timeout): | |
| 424 | + | ext.mu.Lock() | |
| 425 | + | delete(ext.pending, id) | |
| 426 | + | ext.mu.Unlock() | |
| 427 | + | return extproto.CommandResponseFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name) | |
| 428 | + | case <-ctx.Done(): | |
| 429 | + | ext.mu.Lock() | |
| 430 | + | delete(ext.pending, id) | |
| 431 | + | ext.mu.Unlock() | |
| 432 | + | return extproto.CommandResponseFromExt{}, ctx.Err() | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | // Stop cleanly terminates every extension. Sends ShutdownFromHost, | |
| 437 | + | // waits up to gracePeriod for each subprocess to exit, then SIGTERMs | |
| 438 | + | // (and SIGKILLs after another second) the holdouts. | |
| 439 | + | func (m *Manager) Stop(gracePeriod time.Duration) { | |
| 440 | + | m.mu.RLock() | |
| 441 | + | exts := make([]*Extension, 0, len(m.ext)) | |
| 442 | + | for _, e := range m.ext { | |
| 443 | + | exts = append(exts, e) | |
| 444 | + | } | |
| 445 | + | m.mu.RUnlock() | |
| 446 | + | ||
| 447 | + | for _, ext := range exts { | |
| 448 | + | if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil { | |
| 449 | + | _, _ = ext.stdin.Write(frame) | |
| 450 | + | } | |
| 451 | + | _ = ext.stdin.Close() | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | deadline := time.Now().Add(gracePeriod) | |
| 455 | + | for _, ext := range exts { | |
| 456 | + | remaining := time.Until(deadline) | |
| 457 | + | if remaining <= 0 { | |
| 458 | + | remaining = 100 * time.Millisecond | |
| 459 | + | } | |
| 460 | + | done := make(chan struct{}) | |
| 461 | + | go func() { _ = ext.cmd.Wait(); close(done) }() | |
| 462 | + | select { | |
| 463 | + | case <-done: | |
| 464 | + | case <-time.After(remaining): | |
| 465 | + | _ = ext.cmd.Process.Signal(syscall.SIGTERM) | |
| 466 | + | select { | |
| 467 | + | case <-done: | |
| 468 | + | case <-time.After(time.Second): | |
| 469 | + | _ = ext.cmd.Process.Kill() | |
| 470 | + | <-done | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | if ext.logFile != nil { | |
| 474 | + | _ = ext.logFile.Close() | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // All returns every extension currently tracked, enabled or not. | |
| 480 | + | // Used by `zot ext list`. | |
| 481 | + | func (m *Manager) All() []*Extension { | |
| 482 | + | m.mu.RLock() | |
| 483 | + | defer m.mu.RUnlock() | |
| 484 | + | out := make([]*Extension, 0, len(m.ext)) | |
| 485 | + | for _, e := range m.ext { | |
| 486 | + | out = append(out, e) | |
| 487 | + | } | |
| 488 | + | return out | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // newCorrelationID returns a short non-cryptographic id. We don't | |
| 492 | + | // need uniqueness across processes, just within the lifetime of one | |
| 493 | + | // extension's pending map. | |
| 494 | + | func newCorrelationID() string { | |
| 495 | + | return strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "") | |
| 496 | + | } | |
| @@ -0,0 +1,496 @@ | |||||
| 1 | + | // Package extensions implements the host side of zot's subprocess | |||
| 2 | + | // extension protocol. The Manager discovers extensions in well-known | |||
| 3 | + | // directories, spawns each one, completes the hello handshake, and | |||
| 4 | + | // routes slash commands to the right extension. | |||
| 5 | + | // | |||
| 6 | + | // Each extension is its own process, communicating with zot over its | |||
| 7 | + | // stdin/stdout in newline-delimited JSON. Stderr is redirected to a | |||
| 8 | + | // per-extension log file under $ZOT_HOME/logs/. Crashing one | |||
| 9 | + | // extension does not affect the others or the host. | |||
| 10 | + | // | |||
| 11 | + | // See docs/extensions.md for the user-facing reference and | |||
| 12 | + | // internal/extproto for the wire format. | |||
| 13 | + | package extensions | |||
| 14 | + | ||||
| 15 | + | import ( | |||
| 16 | + | "bufio" | |||
| 17 | + | "context" | |||
| 18 | + | "encoding/json" | |||
| 19 | + | "errors" | |||
| 20 | + | "fmt" | |||
| 21 | + | "io" | |||
| 22 | + | "os" | |||
| 23 | + | "os/exec" | |||
| 24 | + | "path/filepath" | |||
| 25 | + | "strings" | |||
| 26 | + | "sync" | |||
| 27 | + | "syscall" | |||
| 28 | + | "time" | |||
| 29 | + | ||||
| 30 | + | "github.com/patriceckhart/zot/internal/extproto" | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | // Manifest is the extension.json file shipped alongside an | |||
| 34 | + | // extension's executable. It tells zot how to launch the extension | |||
| 35 | + | // and provides display metadata. | |||
| 36 | + | type Manifest struct { | |||
| 37 | + | Name string `json:"name"` | |||
| 38 | + | Version string `json:"version,omitempty"` | |||
| 39 | + | Exec string `json:"exec"` // executable path, relative to manifest dir | |||
| 40 | + | Args []string `json:"args,omitempty"` // extra argv passed to exec | |||
| 41 | + | Language string `json:"language,omitempty"` // informational ("go", "python", "typescript", ...) | |||
| 42 | + | Enabled *bool `json:"enabled,omitempty"` // nil = enabled | |||
| 43 | + | Description string `json:"description,omitempty"` | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // IsEnabled returns the manifest's effective enabled state. Default | |||
| 47 | + | // is true so adding a new extension folder Just Works without an | |||
| 48 | + | // extra zot ext enable command. | |||
| 49 | + | func (m Manifest) IsEnabled() bool { | |||
| 50 | + | return m.Enabled == nil || *m.Enabled | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Extension is a running extension subprocess and the metadata zot | |||
| 54 | + | // tracks about it. | |||
| 55 | + | type Extension struct { | |||
| 56 | + | Manifest Manifest | |||
| 57 | + | Dir string // absolute path to extension directory | |||
| 58 | + | LogPath string | |||
| 59 | + | ||||
| 60 | + | cmd *exec.Cmd | |||
| 61 | + | stdin io.WriteCloser | |||
| 62 | + | stdout io.ReadCloser | |||
| 63 | + | logFile *os.File | |||
| 64 | + | helloAck bool | |||
| 65 | + | commands []extproto.RegisterCommandFromExt | |||
| 66 | + | ||||
| 67 | + | // pending command invocations waiting on a CommandResponseFromExt | |||
| 68 | + | // keyed by the id we sent in CommandInvokedFromHost. | |||
| 69 | + | mu sync.Mutex | |||
| 70 | + | pending map[string]chan extproto.CommandResponseFromExt | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // HostHooks is the small interface the manager calls back into the | |||
| 74 | + | // running TUI through. Decouples extensions from interactive.go. | |||
| 75 | + | type HostHooks interface { | |||
| 76 | + | // Notify pushes an ext-originated status message into the chat. | |||
| 77 | + | // Level is one of "info", "warn", "error", "success". | |||
| 78 | + | Notify(extName, level, message string) | |||
| 79 | + | ||||
| 80 | + | // Submit feeds text as if the user had typed and pressed enter, | |||
| 81 | + | // running it through the agent loop. | |||
| 82 | + | Submit(text string) | |||
| 83 | + | ||||
| 84 | + | // Insert places text at the cursor in the editor. | |||
| 85 | + | Insert(text string) | |||
| 86 | + | ||||
| 87 | + | // Display appends a one-shot styled note to the chat without | |||
| 88 | + | // invoking the model and without writing to the transcript. | |||
| 89 | + | Display(extName, text string) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Manager owns every extension subprocess for the lifetime of zot. | |||
| 93 | + | type Manager struct { | |||
| 94 | + | zotHome string | |||
| 95 | + | cwd string | |||
| 96 | + | zotVersion string | |||
| 97 | + | provider string | |||
| 98 | + | model string | |||
| 99 | + | hooks HostHooks | |||
| 100 | + | ||||
| 101 | + | mu sync.RWMutex | |||
| 102 | + | ext map[string]*Extension // keyed by manifest name | |||
| 103 | + | ||||
| 104 | + | // commandIndex maps a slash-command name (without the leading /) | |||
| 105 | + | // to the extension that registered it. First-come-first-served: | |||
| 106 | + | // later registrations of the same command are dropped with a | |||
| 107 | + | // warning. | |||
| 108 | + | commandIndex map[string]*Extension | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // New constructs an empty Manager. Call Discover to populate it from | |||
| 112 | + | // the on-disk extension directories. | |||
| 113 | + | func New(zotHome, cwd, zotVersion, provider, model string, hooks HostHooks) *Manager { | |||
| 114 | + | return &Manager{ | |||
| 115 | + | zotHome: zotHome, | |||
| 116 | + | cwd: cwd, | |||
| 117 | + | zotVersion: zotVersion, | |||
| 118 | + | provider: provider, | |||
| 119 | + | model: model, | |||
| 120 | + | hooks: hooks, | |||
| 121 | + | ext: map[string]*Extension{}, | |||
| 122 | + | commandIndex: map[string]*Extension{}, | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // Discover scans the global and project extension dirs and starts | |||
| 127 | + | // every extension whose manifest is enabled. Returns a slice of | |||
| 128 | + | // errors encountered (one per extension); a single bad extension | |||
| 129 | + | // doesn't abort the rest. | |||
| 130 | + | func (m *Manager) Discover(ctx context.Context) []error { | |||
| 131 | + | var errs []error | |||
| 132 | + | for _, dir := range m.searchDirs() { | |||
| 133 | + | entries, err := os.ReadDir(dir) | |||
| 134 | + | if err != nil { | |||
| 135 | + | continue // missing directory is fine | |||
| 136 | + | } | |||
| 137 | + | for _, e := range entries { | |||
| 138 | + | if !e.IsDir() { | |||
| 139 | + | continue | |||
| 140 | + | } | |||
| 141 | + | extDir := filepath.Join(dir, e.Name()) | |||
| 142 | + | if err := m.loadOne(ctx, extDir); err != nil { | |||
| 143 | + | errs = append(errs, fmt.Errorf("%s: %w", extDir, err)) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | return errs | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // searchDirs returns the directories the discoverer walks, in | |||
| 151 | + | // priority order: project-local first (so a project can override | |||
| 152 | + | // global behavior), then global. | |||
| 153 | + | func (m *Manager) searchDirs() []string { | |||
| 154 | + | var dirs []string | |||
| 155 | + | if m.cwd != "" { | |||
| 156 | + | dirs = append(dirs, filepath.Join(m.cwd, ".zot", "extensions")) | |||
| 157 | + | } | |||
| 158 | + | if m.zotHome != "" { | |||
| 159 | + | dirs = append(dirs, filepath.Join(m.zotHome, "extensions")) | |||
| 160 | + | } | |||
| 161 | + | return dirs | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // loadOne reads a single extension's manifest and, if enabled, | |||
| 165 | + | // spawns its subprocess + completes the hello handshake. | |||
| 166 | + | func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 167 | + | manifestPath := filepath.Join(dir, "extension.json") | |||
| 168 | + | raw, err := os.ReadFile(manifestPath) | |||
| 169 | + | if err != nil { | |||
| 170 | + | return fmt.Errorf("read manifest: %w", err) | |||
| 171 | + | } | |||
| 172 | + | var mf Manifest | |||
| 173 | + | if err := json.Unmarshal(raw, &mf); err != nil { | |||
| 174 | + | return fmt.Errorf("parse manifest: %w", err) | |||
| 175 | + | } | |||
| 176 | + | if mf.Name == "" { | |||
| 177 | + | return errors.New("manifest: name is required") | |||
| 178 | + | } | |||
| 179 | + | if mf.Exec == "" { | |||
| 180 | + | return errors.New("manifest: exec is required") | |||
| 181 | + | } | |||
| 182 | + | if !mf.IsEnabled() { | |||
| 183 | + | // Quietly skip disabled extensions; zot ext list will show them. | |||
| 184 | + | return nil | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | m.mu.RLock() | |||
| 188 | + | _, dup := m.ext[mf.Name] | |||
| 189 | + | m.mu.RUnlock() | |||
| 190 | + | if dup { | |||
| 191 | + | // Project-local copy already won; ignore the global one. | |||
| 192 | + | return nil | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | ext := &Extension{ | |||
| 196 | + | Manifest: mf, | |||
| 197 | + | Dir: dir, | |||
| 198 | + | pending: map[string]chan extproto.CommandResponseFromExt{}, | |||
| 199 | + | } | |||
| 200 | + | if err := m.spawn(ctx, ext); err != nil { | |||
| 201 | + | return err | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | m.mu.Lock() | |||
| 205 | + | m.ext[mf.Name] = ext | |||
| 206 | + | for _, c := range ext.commands { | |||
| 207 | + | if _, exists := m.commandIndex[c.Name]; exists { | |||
| 208 | + | // Conflict: keep the existing registration; drop this one. | |||
| 209 | + | fmt.Fprintf(ext.logFile, "[zot] /%s already registered, ignoring\n", c.Name) | |||
| 210 | + | continue | |||
| 211 | + | } | |||
| 212 | + | m.commandIndex[c.Name] = ext | |||
| 213 | + | } | |||
| 214 | + | m.mu.Unlock() | |||
| 215 | + | return nil | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // spawn launches the subprocess, hooks up pipes, logs stderr, and | |||
| 219 | + | // runs the synchronous portion of the hello handshake. Asynchronous | |||
| 220 | + | // frames are processed in a goroutine started here. | |||
| 221 | + | func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 222 | + | logsDir := filepath.Join(m.zotHome, "logs") | |||
| 223 | + | _ = os.MkdirAll(logsDir, 0o755) | |||
| 224 | + | logPath := filepath.Join(logsDir, "ext-"+ext.Manifest.Name+".log") | |||
| 225 | + | logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) | |||
| 226 | + | if err != nil { | |||
| 227 | + | return fmt.Errorf("open log: %w", err) | |||
| 228 | + | } | |||
| 229 | + | ext.LogPath = logPath | |||
| 230 | + | ext.logFile = logFile | |||
| 231 | + | fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339)) | |||
| 232 | + | ||||
| 233 | + | execPath := ext.Manifest.Exec | |||
| 234 | + | if !filepath.IsAbs(execPath) { | |||
| 235 | + | execPath = filepath.Join(ext.Dir, execPath) | |||
| 236 | + | } | |||
| 237 | + | cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...) | |||
| 238 | + | cmd.Dir = ext.Dir | |||
| 239 | + | cmd.Stderr = logFile | |||
| 240 | + | ||||
| 241 | + | stdin, err := cmd.StdinPipe() | |||
| 242 | + | if err != nil { | |||
| 243 | + | return err | |||
| 244 | + | } | |||
| 245 | + | stdout, err := cmd.StdoutPipe() | |||
| 246 | + | if err != nil { | |||
| 247 | + | return err | |||
| 248 | + | } | |||
| 249 | + | if err := cmd.Start(); err != nil { | |||
| 250 | + | return fmt.Errorf("spawn: %w", err) | |||
| 251 | + | } | |||
| 252 | + | ext.cmd = cmd | |||
| 253 | + | ext.stdin = stdin | |||
| 254 | + | ext.stdout = stdout | |||
| 255 | + | ||||
| 256 | + | // Hello handshake. Read the extension's HelloFromExt synchronously | |||
| 257 | + | // so we can fail fast on a broken extension; everything after is | |||
| 258 | + | // processed in the read goroutine. | |||
| 259 | + | scanner := bufio.NewScanner(stdout) | |||
| 260 | + | scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | |||
| 261 | + | if !scanner.Scan() { | |||
| 262 | + | return fmt.Errorf("extension exited before hello: %w", scanner.Err()) | |||
| 263 | + | } | |||
| 264 | + | var hello extproto.HelloFromExt | |||
| 265 | + | if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil { | |||
| 266 | + | return fmt.Errorf("parse hello: %w", err) | |||
| 267 | + | } | |||
| 268 | + | if hello.Type != "hello" || hello.Name == "" { | |||
| 269 | + | return fmt.Errorf("first frame must be hello (got %q)", hello.Type) | |||
| 270 | + | } | |||
| 271 | + | // Trust the manifest's name; ignore mismatch from the hello. | |||
| 272 | + | ext.helloAck = true | |||
| 273 | + | ||||
| 274 | + | ack, _ := extproto.Encode(extproto.HelloAckFromHost{ | |||
| 275 | + | Type: "hello_ack", | |||
| 276 | + | ProtocolVersion: extproto.ProtocolVersion, | |||
| 277 | + | ZotVersion: m.zotVersion, | |||
| 278 | + | Provider: m.provider, | |||
| 279 | + | Model: m.model, | |||
| 280 | + | CWD: m.cwd, | |||
| 281 | + | }) | |||
| 282 | + | if _, err := stdin.Write(ack); err != nil { | |||
| 283 | + | return fmt.Errorf("send hello_ack: %w", err) | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // Spin up the read loop now that the handshake is done. | |||
| 287 | + | go m.readLoop(ext, scanner) | |||
| 288 | + | return nil | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // readLoop processes every frame the extension sends after hello. | |||
| 292 | + | // Returns when stdout closes. | |||
| 293 | + | func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 294 | + | defer func() { | |||
| 295 | + | // On close, drop every command this extension owned so future | |||
| 296 | + | // invocations don't dangle. The subprocess is gone; we won't | |||
| 297 | + | // hear back about its commands anymore. | |||
| 298 | + | m.mu.Lock() | |||
| 299 | + | for name, owner := range m.commandIndex { | |||
| 300 | + | if owner == ext { | |||
| 301 | + | delete(m.commandIndex, name) | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | m.mu.Unlock() | |||
| 305 | + | fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339)) | |||
| 306 | + | }() | |||
| 307 | + | ||||
| 308 | + | for scanner.Scan() { | |||
| 309 | + | line := scanner.Bytes() | |||
| 310 | + | var frame extproto.Frame | |||
| 311 | + | if err := json.Unmarshal(line, &frame); err != nil { | |||
| 312 | + | fmt.Fprintf(ext.logFile, "[zot] malformed json from extension: %v\n", err) | |||
| 313 | + | continue | |||
| 314 | + | } | |||
| 315 | + | switch frame.Type { | |||
| 316 | + | case "register_command": | |||
| 317 | + | var rc extproto.RegisterCommandFromExt | |||
| 318 | + | if err := json.Unmarshal(line, &rc); err == nil { | |||
| 319 | + | ext.commands = append(ext.commands, rc) | |||
| 320 | + | m.mu.Lock() | |||
| 321 | + | if _, exists := m.commandIndex[rc.Name]; !exists { | |||
| 322 | + | m.commandIndex[rc.Name] = ext | |||
| 323 | + | } | |||
| 324 | + | m.mu.Unlock() | |||
| 325 | + | } | |||
| 326 | + | case "notify": | |||
| 327 | + | var n extproto.NotifyFromExt | |||
| 328 | + | if err := json.Unmarshal(line, &n); err == nil { | |||
| 329 | + | m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message) | |||
| 330 | + | } | |||
| 331 | + | case "command_response": | |||
| 332 | + | var cr extproto.CommandResponseFromExt | |||
| 333 | + | if err := json.Unmarshal(line, &cr); err == nil { | |||
| 334 | + | ext.mu.Lock() | |||
| 335 | + | ch, ok := ext.pending[cr.ID] | |||
| 336 | + | if ok { | |||
| 337 | + | delete(ext.pending, cr.ID) | |||
| 338 | + | } | |||
| 339 | + | ext.mu.Unlock() | |||
| 340 | + | if ok { | |||
| 341 | + | select { | |||
| 342 | + | case ch <- cr: | |||
| 343 | + | default: | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | } | |||
| 347 | + | case "shutdown_ack": | |||
| 348 | + | // Caller of Stop is waiting on the process exit, not this frame. | |||
| 349 | + | default: | |||
| 350 | + | fmt.Fprintf(ext.logFile, "[zot] unknown frame type %q\n", frame.Type) | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // Commands returns a snapshot of every (extension, command) pair | |||
| 356 | + | // currently registered. Used by the slash autocomplete + /help. | |||
| 357 | + | func (m *Manager) Commands() []CommandInfo { | |||
| 358 | + | m.mu.RLock() | |||
| 359 | + | defer m.mu.RUnlock() | |||
| 360 | + | var out []CommandInfo | |||
| 361 | + | for _, ext := range m.ext { | |||
| 362 | + | for _, c := range ext.commands { | |||
| 363 | + | out = append(out, CommandInfo{ | |||
| 364 | + | Extension: ext.Manifest.Name, | |||
| 365 | + | Name: c.Name, | |||
| 366 | + | Description: c.Description, | |||
| 367 | + | }) | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | return out | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // CommandInfo is one extension-registered slash command, surfaced to | |||
| 374 | + | // the rest of zot for display purposes. | |||
| 375 | + | type CommandInfo struct { | |||
| 376 | + | Extension string | |||
| 377 | + | Name string | |||
| 378 | + | Description string | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // HasCommand reports whether name is registered by any extension. | |||
| 382 | + | func (m *Manager) HasCommand(name string) bool { | |||
| 383 | + | m.mu.RLock() | |||
| 384 | + | defer m.mu.RUnlock() | |||
| 385 | + | _, ok := m.commandIndex[name] | |||
| 386 | + | return ok | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | // Invoke fires the named slash command's handler in the owning | |||
| 390 | + | // extension and waits up to timeout for the response. Returns the | |||
| 391 | + | // extension's CommandResponse so the caller can act on the action | |||
| 392 | + | // (prompt / insert / display). | |||
| 393 | + | func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) { | |||
| 394 | + | m.mu.RLock() | |||
| 395 | + | ext, ok := m.commandIndex[name] | |||
| 396 | + | m.mu.RUnlock() | |||
| 397 | + | if !ok { | |||
| 398 | + | return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | id := newCorrelationID() | |||
| 402 | + | ch := make(chan extproto.CommandResponseFromExt, 1) | |||
| 403 | + | ext.mu.Lock() | |||
| 404 | + | ext.pending[id] = ch | |||
| 405 | + | ext.mu.Unlock() | |||
| 406 | + | ||||
| 407 | + | frame, _ := extproto.Encode(extproto.CommandInvokedFromHost{ | |||
| 408 | + | Type: "command_invoked", | |||
| 409 | + | ID: id, | |||
| 410 | + | Name: name, | |||
| 411 | + | Args: args, | |||
| 412 | + | }) | |||
| 413 | + | if _, err := ext.stdin.Write(frame); err != nil { | |||
| 414 | + | ext.mu.Lock() | |||
| 415 | + | delete(ext.pending, id) | |||
| 416 | + | ext.mu.Unlock() | |||
| 417 | + | return extproto.CommandResponseFromExt{}, fmt.Errorf("write: %w", err) | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | select { | |||
| 421 | + | case resp := <-ch: | |||
| 422 | + | return resp, nil | |||
| 423 | + | case <-time.After(timeout): | |||
| 424 | + | ext.mu.Lock() | |||
| 425 | + | delete(ext.pending, id) | |||
| 426 | + | ext.mu.Unlock() | |||
| 427 | + | return extproto.CommandResponseFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name) | |||
| 428 | + | case <-ctx.Done(): | |||
| 429 | + | ext.mu.Lock() | |||
| 430 | + | delete(ext.pending, id) | |||
| 431 | + | ext.mu.Unlock() | |||
| 432 | + | return extproto.CommandResponseFromExt{}, ctx.Err() | |||
| 433 | + | } | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | // Stop cleanly terminates every extension. Sends ShutdownFromHost, | |||
| 437 | + | // waits up to gracePeriod for each subprocess to exit, then SIGTERMs | |||
| 438 | + | // (and SIGKILLs after another second) the holdouts. | |||
| 439 | + | func (m *Manager) Stop(gracePeriod time.Duration) { | |||
| 440 | + | m.mu.RLock() | |||
| 441 | + | exts := make([]*Extension, 0, len(m.ext)) | |||
| 442 | + | for _, e := range m.ext { | |||
| 443 | + | exts = append(exts, e) | |||
| 444 | + | } | |||
| 445 | + | m.mu.RUnlock() | |||
| 446 | + | ||||
| 447 | + | for _, ext := range exts { | |||
| 448 | + | if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil { | |||
| 449 | + | _, _ = ext.stdin.Write(frame) | |||
| 450 | + | } | |||
| 451 | + | _ = ext.stdin.Close() | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | deadline := time.Now().Add(gracePeriod) | |||
| 455 | + | for _, ext := range exts { | |||
| 456 | + | remaining := time.Until(deadline) | |||
| 457 | + | if remaining <= 0 { | |||
| 458 | + | remaining = 100 * time.Millisecond | |||
| 459 | + | } | |||
| 460 | + | done := make(chan struct{}) | |||
| 461 | + | go func() { _ = ext.cmd.Wait(); close(done) }() | |||
| 462 | + | select { | |||
| 463 | + | case <-done: | |||
| 464 | + | case <-time.After(remaining): | |||
| 465 | + | _ = ext.cmd.Process.Signal(syscall.SIGTERM) | |||
| 466 | + | select { | |||
| 467 | + | case <-done: | |||
| 468 | + | case <-time.After(time.Second): | |||
| 469 | + | _ = ext.cmd.Process.Kill() | |||
| 470 | + | <-done | |||
| 471 | + | } | |||
| 472 | + | } | |||
| 473 | + | if ext.logFile != nil { | |||
| 474 | + | _ = ext.logFile.Close() | |||
| 475 | + | } | |||
| 476 | + | } | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | // All returns every extension currently tracked, enabled or not. | |||
| 480 | + | // Used by `zot ext list`. | |||
| 481 | + | func (m *Manager) All() []*Extension { | |||
| 482 | + | m.mu.RLock() | |||
| 483 | + | defer m.mu.RUnlock() | |||
| 484 | + | out := make([]*Extension, 0, len(m.ext)) | |||
| 485 | + | for _, e := range m.ext { | |||
| 486 | + | out = append(out, e) | |||
| 487 | + | } | |||
| 488 | + | return out | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // newCorrelationID returns a short non-cryptographic id. We don't | |||
| 492 | + | // need uniqueness across processes, just within the lifetime of one | |||
| 493 | + | // extension's pending map. | |||
| 494 | + | func newCorrelationID() string { | |||
| 495 | + | return strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "") | |||
| 496 | + | } | |||
A internal/agent/extensions/manager_test.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package extensions | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "sync" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // stubHooks records every callback so the test can assert on them. | |
| 15 | + | type stubHooks struct { | |
| 16 | + | mu sync.Mutex | |
| 17 | + | notifies []string | |
| 18 | + | displays []string | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (s *stubHooks) Notify(name, level, message string) { | |
| 22 | + | s.mu.Lock() | |
| 23 | + | defer s.mu.Unlock() | |
| 24 | + | s.notifies = append(s.notifies, name+":"+level+":"+message) | |
| 25 | + | } | |
| 26 | + | func (s *stubHooks) Submit(string) {} | |
| 27 | + | func (s *stubHooks) Insert(string) {} | |
| 28 | + | func (s *stubHooks) Display(name, text string) { | |
| 29 | + | s.mu.Lock() | |
| 30 | + | defer s.mu.Unlock() | |
| 31 | + | s.displays = append(s.displays, name+":"+text) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // writeMockExtension creates a minimal extension on disk that uses a | |
| 35 | + | // shell script (or batch file on windows) to drive the protocol. The | |
| 36 | + | // script reads commands from stdin and emits hard-coded responses, | |
| 37 | + | // exercising the manager's spawn/handshake/dispatch path without | |
| 38 | + | // needing the SDK. | |
| 39 | + | func writeMockExtension(t *testing.T, root string) { | |
| 40 | + | t.Helper() | |
| 41 | + | if runtime.GOOS == "windows" { | |
| 42 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | dir := filepath.Join(root, "mock") | |
| 46 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Shell script: emit hello, read frames, respond. Reads until | |
| 51 | + | // stdin closes; tail's -F keeps the pipe alive long enough for | |
| 52 | + | // the manager to send command_invoked. | |
| 53 | + | script := `#!/bin/sh | |
| 54 | + | printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}' | |
| 55 | + | printf '%s\n' '{"type":"register_command","name":"ping","description":"ping/pong"}' | |
| 56 | + | while IFS= read -r line; do | |
| 57 | + | case "$line" in | |
| 58 | + | *'"type":"command_invoked"'*) | |
| 59 | + | id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') | |
| 60 | + | printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}" | |
| 61 | + | ;; | |
| 62 | + | *'"type":"shutdown"'*) | |
| 63 | + | printf '%s\n' '{"type":"shutdown_ack"}' | |
| 64 | + | exit 0 | |
| 65 | + | ;; | |
| 66 | + | esac | |
| 67 | + | done | |
| 68 | + | ` | |
| 69 | + | scriptPath := filepath.Join(dir, "run.sh") | |
| 70 | + | if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | manifest := map[string]any{ | |
| 75 | + | "name": "mock", | |
| 76 | + | "version": "0.0.1", | |
| 77 | + | "exec": "./run.sh", | |
| 78 | + | } | |
| 79 | + | mfb, _ := json.Marshal(manifest) | |
| 80 | + | if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestManagerSpawnAndInvoke(t *testing.T) { | |
| 86 | + | tmp := t.TempDir() | |
| 87 | + | extRoot := filepath.Join(tmp, "extensions") | |
| 88 | + | if err := os.MkdirAll(extRoot, 0o755); err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | writeMockExtension(t, extRoot) | |
| 92 | + | ||
| 93 | + | hooks := &stubHooks{} | |
| 94 | + | mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks) | |
| 95 | + | ||
| 96 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |
| 97 | + | t.Fatalf("discover errors: %v", errs) | |
| 98 | + | } | |
| 99 | + | defer mgr.Stop(2 * time.Second) | |
| 100 | + | ||
| 101 | + | // Give the extension a beat to send register_command frames after | |
| 102 | + | // the hello handshake. | |
| 103 | + | time.Sleep(150 * time.Millisecond) | |
| 104 | + | ||
| 105 | + | cmds := mgr.Commands() | |
| 106 | + | if len(cmds) != 1 || cmds[0].Name != "ping" { | |
| 107 | + | t.Fatalf("expected one command 'ping', got %#v", cmds) | |
| 108 | + | } | |
| 109 | + | if !mgr.HasCommand("ping") { | |
| 110 | + | t.Fatal("HasCommand(\"ping\") = false") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | resp, err := mgr.Invoke(context.Background(), "ping", "", 2*time.Second) | |
| 114 | + | if err != nil { | |
| 115 | + | t.Fatalf("invoke: %v", err) | |
| 116 | + | } | |
| 117 | + | if resp.Action != "display" { | |
| 118 | + | t.Errorf("expected action=display, got %q", resp.Action) | |
| 119 | + | } | |
| 120 | + | if resp.Display != "pong" { | |
| 121 | + | t.Errorf("expected display=pong, got %q", resp.Display) | |
| 122 | + | } | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package extensions | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "runtime" | |||
| 9 | + | "sync" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // stubHooks records every callback so the test can assert on them. | |||
| 15 | + | type stubHooks struct { | |||
| 16 | + | mu sync.Mutex | |||
| 17 | + | notifies []string | |||
| 18 | + | displays []string | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (s *stubHooks) Notify(name, level, message string) { | |||
| 22 | + | s.mu.Lock() | |||
| 23 | + | defer s.mu.Unlock() | |||
| 24 | + | s.notifies = append(s.notifies, name+":"+level+":"+message) | |||
| 25 | + | } | |||
| 26 | + | func (s *stubHooks) Submit(string) {} | |||
| 27 | + | func (s *stubHooks) Insert(string) {} | |||
| 28 | + | func (s *stubHooks) Display(name, text string) { | |||
| 29 | + | s.mu.Lock() | |||
| 30 | + | defer s.mu.Unlock() | |||
| 31 | + | s.displays = append(s.displays, name+":"+text) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // writeMockExtension creates a minimal extension on disk that uses a | |||
| 35 | + | // shell script (or batch file on windows) to drive the protocol. The | |||
| 36 | + | // script reads commands from stdin and emits hard-coded responses, | |||
| 37 | + | // exercising the manager's spawn/handshake/dispatch path without | |||
| 38 | + | // needing the SDK. | |||
| 39 | + | func writeMockExtension(t *testing.T, root string) { | |||
| 40 | + | t.Helper() | |||
| 41 | + | if runtime.GOOS == "windows" { | |||
| 42 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | dir := filepath.Join(root, "mock") | |||
| 46 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |||
| 47 | + | t.Fatal(err) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Shell script: emit hello, read frames, respond. Reads until | |||
| 51 | + | // stdin closes; tail's -F keeps the pipe alive long enough for | |||
| 52 | + | // the manager to send command_invoked. | |||
| 53 | + | script := `#!/bin/sh | |||
| 54 | + | printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}' | |||
| 55 | + | printf '%s\n' '{"type":"register_command","name":"ping","description":"ping/pong"}' | |||
| 56 | + | while IFS= read -r line; do | |||
| 57 | + | case "$line" in | |||
| 58 | + | *'"type":"command_invoked"'*) | |||
| 59 | + | id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') | |||
| 60 | + | printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}" | |||
| 61 | + | ;; | |||
| 62 | + | *'"type":"shutdown"'*) | |||
| 63 | + | printf '%s\n' '{"type":"shutdown_ack"}' | |||
| 64 | + | exit 0 | |||
| 65 | + | ;; | |||
| 66 | + | esac | |||
| 67 | + | done | |||
| 68 | + | ` | |||
| 69 | + | scriptPath := filepath.Join(dir, "run.sh") | |||
| 70 | + | if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { | |||
| 71 | + | t.Fatal(err) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | manifest := map[string]any{ | |||
| 75 | + | "name": "mock", | |||
| 76 | + | "version": "0.0.1", | |||
| 77 | + | "exec": "./run.sh", | |||
| 78 | + | } | |||
| 79 | + | mfb, _ := json.Marshal(manifest) | |||
| 80 | + | if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil { | |||
| 81 | + | t.Fatal(err) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestManagerSpawnAndInvoke(t *testing.T) { | |||
| 86 | + | tmp := t.TempDir() | |||
| 87 | + | extRoot := filepath.Join(tmp, "extensions") | |||
| 88 | + | if err := os.MkdirAll(extRoot, 0o755); err != nil { | |||
| 89 | + | t.Fatal(err) | |||
| 90 | + | } | |||
| 91 | + | writeMockExtension(t, extRoot) | |||
| 92 | + | ||||
| 93 | + | hooks := &stubHooks{} | |||
| 94 | + | mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks) | |||
| 95 | + | ||||
| 96 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |||
| 97 | + | t.Fatalf("discover errors: %v", errs) | |||
| 98 | + | } | |||
| 99 | + | defer mgr.Stop(2 * time.Second) | |||
| 100 | + | ||||
| 101 | + | // Give the extension a beat to send register_command frames after | |||
| 102 | + | // the hello handshake. | |||
| 103 | + | time.Sleep(150 * time.Millisecond) | |||
| 104 | + | ||||
| 105 | + | cmds := mgr.Commands() | |||
| 106 | + | if len(cmds) != 1 || cmds[0].Name != "ping" { | |||
| 107 | + | t.Fatalf("expected one command 'ping', got %#v", cmds) | |||
| 108 | + | } | |||
| 109 | + | if !mgr.HasCommand("ping") { | |||
| 110 | + | t.Fatal("HasCommand(\"ping\") = false") | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | resp, err := mgr.Invoke(context.Background(), "ping", "", 2*time.Second) | |||
| 114 | + | if err != nil { | |||
| 115 | + | t.Fatalf("invoke: %v", err) | |||
| 116 | + | } | |||
| 117 | + | if resp.Action != "display" { | |||
| 118 | + | t.Errorf("expected action=display, got %q", resp.Action) | |||
| 119 | + | } | |||
| 120 | + | if resp.Display != "pong" { | |||
| 121 | + | t.Errorf("expected display=pong, got %q", resp.Display) | |||
| 122 | + | } | |||
| 123 | + | } | |||
M internal/agent/modes/interactive.go +141 -7
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "sync" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | "github.com/patriceckhart/zot/internal/agent/extensions" | |
| 11 | 12 | "github.com/patriceckhart/zot/internal/agent/tools" | |
| 12 | 13 | "github.com/patriceckhart/zot/internal/auth" | |
| 13 | 14 | "github.com/patriceckhart/zot/internal/core" | |
| @@ -78,6 +79,11 @@ type InteractiveConfig struct { | |||
| 78 | 79 | ||
| 79 | 80 | OnAssistant func(m provider.Message) | |
| 80 | 81 | OnToolResult func(id string, r core.ToolResult) | |
| 82 | + | ||
| 83 | + | // Extensions, if non-nil, lets users invoke extension-registered | |
| 84 | + | // slash commands. Commands declared by extensions are looked up | |
| 85 | + | // AFTER the built-in catalog so a built-in name always wins. | |
| 86 | + | Extensions *extensions.Manager | |
| 81 | 87 | } | |
| 82 | 88 | ||
| 83 | 89 | // Interactive is the TUI chat loop. | |
| @@ -146,6 +152,11 @@ type Interactive struct { | |||
| 146 | 152 | // banner shows the binary version for welcomeVersionDuration | |
| 147 | 153 | // after this point and reverts to plain text after. | |
| 148 | 154 | welcomeStart time.Time | |
| 155 | + | ||
| 156 | + | // extNotes are one-shot styled lines pushed by extensions via | |
| 157 | + | // Notify / Display. They live above the editor (just below the | |
| 158 | + | // transcript) until cleared by /clear or another reset. | |
| 159 | + | extNotes []string | |
| 149 | 160 | } | |
| 150 | 161 | ||
| 151 | 162 | // welcomeVersionDuration is how long the welcome banner shows the | |
| @@ -486,6 +497,13 @@ func (i *Interactive) redraw() { | |||
| 486 | 497 | chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "") | |
| 487 | 498 | } | |
| 488 | 499 | ||
| 500 | + | // Extension notes (notify / display) live just under the | |
| 501 | + | // transcript, above the dialog/editor band. Cleared by /clear. | |
| 502 | + | if len(i.extNotes) > 0 { | |
| 503 | + | chat = append(chat, i.extNotes...) | |
| 504 | + | chat = append(chat, "") | |
| 505 | + | } | |
| 506 | + | ||
| 489 | 507 | // Dialogs (login or model picker) render between chat and the editor. | |
| 490 | 508 | var dialog []string | |
| 491 | 509 | switch { | |
| @@ -503,6 +521,19 @@ func (i *Interactive) redraw() { | |||
| 503 | 521 | ||
| 504 | 522 | // Slash-command autocomplete: popup above the status line, only | |
| 505 | 523 | // when the editor starts with "/" and no dialog is already open. | |
| 524 | + | // Feed extension-registered commands into the suggester first so | |
| 525 | + | // they show up in tab-complete + the popup alongside the built-ins. | |
| 526 | + | if i.cfg.Extensions != nil { | |
| 527 | + | catalog := i.cfg.Extensions.Commands() | |
| 528 | + | extra := make([]slashCommand, 0, len(catalog)) | |
| 529 | + | for _, c := range catalog { | |
| 530 | + | extra = append(extra, slashCommand{ | |
| 531 | + | Name: "/" + c.Name, | |
| 532 | + | Desc: c.Description + " (ext: " + c.Extension + ")", | |
| 533 | + | }) | |
| 534 | + | } | |
| 535 | + | i.suggest.SetExtra(extra) | |
| 536 | + | } | |
| 506 | 537 | var suggest []string | |
| 507 | 538 | currentInput := i.ed.Value() | |
| 508 | 539 | if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy { | |
| @@ -874,10 +905,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 874 | 905 | i.suggest.Reset() | |
| 875 | 906 | ||
| 876 | 907 | if looksLikeSlashCommand(text) { | |
| 908 | + | head := text | |
| 909 | + | rest := "" | |
| 910 | + | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |
| 911 | + | head = text[:idx] | |
| 912 | + | rest = strings.TrimSpace(text[idx:]) | |
| 913 | + | } | |
| 877 | 914 | if !isKnownSlashCommand(text) { | |
| 878 | - | head := text | |
| 879 | - | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |
| 880 | - | head = text[:idx] | |
| 915 | + | // Try extensions before giving up. Extensions register | |
| 916 | + | // commands by bare name (no leading slash); strip it here. | |
| 917 | + | extName := strings.TrimPrefix(head, "/") | |
| 918 | + | if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) { | |
| 919 | + | go i.invokeExtensionCommand(ctx, extName, rest) | |
| 920 | + | return false | |
| 881 | 921 | } | |
| 882 | 922 | i.mu.Lock() | |
| 883 | 923 | i.statusErr = "unknown command " + head + " — type /help to see the list" | |
| @@ -892,10 +932,6 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 892 | 932 | // race with a streaming response. Safe commands (/help, | |
| 893 | 933 | // /jump, /sessions, /lock, /unlock, /exit) run immediately | |
| 894 | 934 | // without disturbing the active turn. | |
| 895 | - | head := text | |
| 896 | - | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |
| 897 | - | head = text[:idx] | |
| 898 | - | } | |
| 899 | 935 | if slashCancelsTurn(head) { | |
| 900 | 936 | i.cancelAndWaitForIdle() | |
| 901 | 937 | } | |
| @@ -924,6 +960,103 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 924 | 960 | return false | |
| 925 | 961 | } | |
| 926 | 962 | ||
| 963 | + | // invokeExtensionCommand fires an extension-registered slash command | |
| 964 | + | // in a background goroutine, awaits the response, and applies the | |
| 965 | + | // requested action (prompt / insert / display / noop). Errors and | |
| 966 | + | // timeouts surface as a status_err line. | |
| 967 | + | func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) { | |
| 968 | + | resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second) | |
| 969 | + | if err != nil { | |
| 970 | + | i.mu.Lock() | |
| 971 | + | i.statusErr = "extension /" + name + ": " + err.Error() | |
| 972 | + | i.statusOK = "" | |
| 973 | + | i.mu.Unlock() | |
| 974 | + | i.invalidate() | |
| 975 | + | return | |
| 976 | + | } | |
| 977 | + | if resp.Error != "" { | |
| 978 | + | i.mu.Lock() | |
| 979 | + | i.statusErr = "extension /" + name + ": " + resp.Error | |
| 980 | + | i.statusOK = "" | |
| 981 | + | i.mu.Unlock() | |
| 982 | + | i.invalidate() | |
| 983 | + | return | |
| 984 | + | } | |
| 985 | + | switch resp.Action { | |
| 986 | + | case "prompt": | |
| 987 | + | if strings.TrimSpace(resp.Prompt) == "" { | |
| 988 | + | return | |
| 989 | + | } | |
| 990 | + | i.startTurn(i.runCtx, resp.Prompt) | |
| 991 | + | case "insert": | |
| 992 | + | i.ed.Insert(resp.Insert) | |
| 993 | + | i.invalidate() | |
| 994 | + | case "display": | |
| 995 | + | i.appendExtensionNote(name, resp.Display, "info") | |
| 996 | + | case "noop", "": | |
| 997 | + | // nothing | |
| 998 | + | default: | |
| 999 | + | i.mu.Lock() | |
| 1000 | + | i.statusErr = "extension /" + name + ": unknown action " + resp.Action | |
| 1001 | + | i.mu.Unlock() | |
| 1002 | + | i.invalidate() | |
| 1003 | + | } | |
| 1004 | + | } | |
| 1005 | + | ||
| 1006 | + | // appendExtensionNote renders an extension-originated note in the | |
| 1007 | + | // chat. Levels: "info" (muted), "warn" (warning), "error" (error), | |
| 1008 | + | // "success" (tool/ok green). | |
| 1009 | + | func (i *Interactive) appendExtensionNote(extName, msg, level string) { | |
| 1010 | + | if msg == "" { | |
| 1011 | + | return | |
| 1012 | + | } | |
| 1013 | + | i.mu.Lock() | |
| 1014 | + | defer i.mu.Unlock() | |
| 1015 | + | color := i.cfg.Theme.Muted | |
| 1016 | + | switch level { | |
| 1017 | + | case "warn": | |
| 1018 | + | color = i.cfg.Theme.Warning | |
| 1019 | + | case "error": | |
| 1020 | + | color = i.cfg.Theme.Error | |
| 1021 | + | case "success": | |
| 1022 | + | color = i.cfg.Theme.Tool | |
| 1023 | + | } | |
| 1024 | + | prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ") | |
| 1025 | + | for _, line := range strings.Split(msg, "\n") { | |
| 1026 | + | i.statusOK = "" // clear any stale ok | |
| 1027 | + | i.statusErr = "" | |
| 1028 | + | i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line)) | |
| 1029 | + | } | |
| 1030 | + | } | |
| 1031 | + | ||
| 1032 | + | // HostHooks implementation for the extension manager. The manager | |
| 1033 | + | // holds an interface, not a concrete *Interactive, so these methods | |
| 1034 | + | // are the only thing the manager sees. | |
| 1035 | + | ||
| 1036 | + | // Notify is the manager's NotifyFromExt entry point. | |
| 1037 | + | func (i *Interactive) Notify(extName, level, message string) { | |
| 1038 | + | i.appendExtensionNote(extName, message, level) | |
| 1039 | + | i.invalidate() | |
| 1040 | + | } | |
| 1041 | + | ||
| 1042 | + | // Submit feeds text through the agent loop as if the user had typed it. | |
| 1043 | + | func (i *Interactive) Submit(text string) { | |
| 1044 | + | i.startTurn(i.runCtx, text) | |
| 1045 | + | } | |
| 1046 | + | ||
| 1047 | + | // Insert places text at the cursor in the editor. | |
| 1048 | + | func (i *Interactive) Insert(text string) { | |
| 1049 | + | i.ed.Insert(text) | |
| 1050 | + | i.invalidate() | |
| 1051 | + | } | |
| 1052 | + | ||
| 1053 | + | // Display appends a styled note from extName to the chat without a | |
| 1054 | + | // model call. | |
| 1055 | + | func (i *Interactive) Display(extName, text string) { | |
| 1056 | + | i.appendExtensionNote(extName, text, "info") | |
| 1057 | + | i.invalidate() | |
| 1058 | + | } | |
| 1059 | + | ||
| 927 | 1060 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |
| 928 | 1061 | parts := strings.Fields(cmd) | |
| 929 | 1062 | switch parts[0] { | |
| @@ -942,6 +1075,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 942 | 1075 | i.parkedTurn = 0 | |
| 943 | 1076 | i.parkedTotal = 0 | |
| 944 | 1077 | i.scrollOffset = 0 | |
| 1078 | + | i.extNotes = nil | |
| 945 | 1079 | i.view.InvalidateRenderCache() | |
| 946 | 1080 | i.mu.Unlock() | |
| 947 | 1081 | case "/help": | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "sync" | 8 | "sync" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/patriceckhart/zot/internal/agent/extensions" | |||
| 11 | "github.com/patriceckhart/zot/internal/agent/tools" | 12 | "github.com/patriceckhart/zot/internal/agent/tools" | ||
| 12 | "github.com/patriceckhart/zot/internal/auth" | 13 | "github.com/patriceckhart/zot/internal/auth" | ||
| 13 | "github.com/patriceckhart/zot/internal/core" | 14 | "github.com/patriceckhart/zot/internal/core" | ||
| @@ -78,6 +79,11 @@ type InteractiveConfig struct { | |||||
| 78 | 79 | ||||
| 79 | OnAssistant func(m provider.Message) | 80 | OnAssistant func(m provider.Message) | ||
| 80 | OnToolResult func(id string, r core.ToolResult) | 81 | OnToolResult func(id string, r core.ToolResult) | ||
| 82 | + | ||||
| 83 | + | // Extensions, if non-nil, lets users invoke extension-registered | |||
| 84 | + | // slash commands. Commands declared by extensions are looked up | |||
| 85 | + | // AFTER the built-in catalog so a built-in name always wins. | |||
| 86 | + | Extensions *extensions.Manager | |||
| 81 | } | 87 | } | ||
| 82 | 88 | ||||
| 83 | // Interactive is the TUI chat loop. | 89 | // Interactive is the TUI chat loop. | ||
| @@ -146,6 +152,11 @@ type Interactive struct { | |||||
| 146 | // banner shows the binary version for welcomeVersionDuration | 152 | // banner shows the binary version for welcomeVersionDuration | ||
| 147 | // after this point and reverts to plain text after. | 153 | // after this point and reverts to plain text after. | ||
| 148 | welcomeStart time.Time | 154 | welcomeStart time.Time | ||
| 155 | + | ||||
| 156 | + | // extNotes are one-shot styled lines pushed by extensions via | |||
| 157 | + | // Notify / Display. They live above the editor (just below the | |||
| 158 | + | // transcript) until cleared by /clear or another reset. | |||
| 159 | + | extNotes []string | |||
| 149 | } | 160 | } | ||
| 150 | 161 | ||||
| 151 | // welcomeVersionDuration is how long the welcome banner shows the | 162 | // welcomeVersionDuration is how long the welcome banner shows the | ||
| @@ -486,6 +497,13 @@ func (i *Interactive) redraw() { | |||||
| 486 | chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "") | 497 | chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "") | ||
| 487 | } | 498 | } | ||
| 488 | 499 | ||||
| 500 | + | // Extension notes (notify / display) live just under the | |||
| 501 | + | // transcript, above the dialog/editor band. Cleared by /clear. | |||
| 502 | + | if len(i.extNotes) > 0 { | |||
| 503 | + | chat = append(chat, i.extNotes...) | |||
| 504 | + | chat = append(chat, "") | |||
| 505 | + | } | |||
| 506 | + | ||||
| 489 | // Dialogs (login or model picker) render between chat and the editor. | 507 | // Dialogs (login or model picker) render between chat and the editor. | ||
| 490 | var dialog []string | 508 | var dialog []string | ||
| 491 | switch { | 509 | switch { | ||
| @@ -503,6 +521,19 @@ func (i *Interactive) redraw() { | |||||
| 503 | 521 | ||||
| 504 | // Slash-command autocomplete: popup above the status line, only | 522 | // Slash-command autocomplete: popup above the status line, only | ||
| 505 | // when the editor starts with "/" and no dialog is already open. | 523 | // when the editor starts with "/" and no dialog is already open. | ||
| 524 | + | // Feed extension-registered commands into the suggester first so | |||
| 525 | + | // they show up in tab-complete + the popup alongside the built-ins. | |||
| 526 | + | if i.cfg.Extensions != nil { | |||
| 527 | + | catalog := i.cfg.Extensions.Commands() | |||
| 528 | + | extra := make([]slashCommand, 0, len(catalog)) | |||
| 529 | + | for _, c := range catalog { | |||
| 530 | + | extra = append(extra, slashCommand{ | |||
| 531 | + | Name: "/" + c.Name, | |||
| 532 | + | Desc: c.Description + " (ext: " + c.Extension + ")", | |||
| 533 | + | }) | |||
| 534 | + | } | |||
| 535 | + | i.suggest.SetExtra(extra) | |||
| 536 | + | } | |||
| 506 | var suggest []string | 537 | var suggest []string | ||
| 507 | currentInput := i.ed.Value() | 538 | currentInput := i.ed.Value() | ||
| 508 | if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy { | 539 | if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy { | ||
| @@ -874,10 +905,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 874 | i.suggest.Reset() | 905 | i.suggest.Reset() | ||
| 875 | 906 | ||||
| 876 | if looksLikeSlashCommand(text) { | 907 | if looksLikeSlashCommand(text) { | ||
| 908 | + | head := text | |||
| 909 | + | rest := "" | |||
| 910 | + | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |||
| 911 | + | head = text[:idx] | |||
| 912 | + | rest = strings.TrimSpace(text[idx:]) | |||
| 913 | + | } | |||
| 877 | if !isKnownSlashCommand(text) { | 914 | if !isKnownSlashCommand(text) { | ||
| 878 | - | head := text | 915 | + | // Try extensions before giving up. Extensions register |
| 879 | - | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | 916 | + | // commands by bare name (no leading slash); strip it here. |
| 880 | - | head = text[:idx] | 917 | + | extName := strings.TrimPrefix(head, "/") |
| 918 | + | if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) { | |||
| 919 | + | go i.invokeExtensionCommand(ctx, extName, rest) | |||
| 920 | + | return false | |||
| 881 | } | 921 | } | ||
| 882 | i.mu.Lock() | 922 | i.mu.Lock() | ||
| 883 | i.statusErr = "unknown command " + head + " — type /help to see the list" | 923 | i.statusErr = "unknown command " + head + " — type /help to see the list" | ||
| @@ -892,10 +932,6 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 892 | // race with a streaming response. Safe commands (/help, | 932 | // race with a streaming response. Safe commands (/help, | ||
| 893 | // /jump, /sessions, /lock, /unlock, /exit) run immediately | 933 | // /jump, /sessions, /lock, /unlock, /exit) run immediately | ||
| 894 | // without disturbing the active turn. | 934 | // without disturbing the active turn. | ||
| 895 | - | head := text | |||
| 896 | - | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |||
| 897 | - | head = text[:idx] | |||
| 898 | - | } | |||
| 899 | if slashCancelsTurn(head) { | 935 | if slashCancelsTurn(head) { | ||
| 900 | i.cancelAndWaitForIdle() | 936 | i.cancelAndWaitForIdle() | ||
| 901 | } | 937 | } | ||
| @@ -924,6 +960,103 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 924 | return false | 960 | return false | ||
| 925 | } | 961 | } | ||
| 926 | 962 | ||||
| 963 | + | // invokeExtensionCommand fires an extension-registered slash command | |||
| 964 | + | // in a background goroutine, awaits the response, and applies the | |||
| 965 | + | // requested action (prompt / insert / display / noop). Errors and | |||
| 966 | + | // timeouts surface as a status_err line. | |||
| 967 | + | func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) { | |||
| 968 | + | resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second) | |||
| 969 | + | if err != nil { | |||
| 970 | + | i.mu.Lock() | |||
| 971 | + | i.statusErr = "extension /" + name + ": " + err.Error() | |||
| 972 | + | i.statusOK = "" | |||
| 973 | + | i.mu.Unlock() | |||
| 974 | + | i.invalidate() | |||
| 975 | + | return | |||
| 976 | + | } | |||
| 977 | + | if resp.Error != "" { | |||
| 978 | + | i.mu.Lock() | |||
| 979 | + | i.statusErr = "extension /" + name + ": " + resp.Error | |||
| 980 | + | i.statusOK = "" | |||
| 981 | + | i.mu.Unlock() | |||
| 982 | + | i.invalidate() | |||
| 983 | + | return | |||
| 984 | + | } | |||
| 985 | + | switch resp.Action { | |||
| 986 | + | case "prompt": | |||
| 987 | + | if strings.TrimSpace(resp.Prompt) == "" { | |||
| 988 | + | return | |||
| 989 | + | } | |||
| 990 | + | i.startTurn(i.runCtx, resp.Prompt) | |||
| 991 | + | case "insert": | |||
| 992 | + | i.ed.Insert(resp.Insert) | |||
| 993 | + | i.invalidate() | |||
| 994 | + | case "display": | |||
| 995 | + | i.appendExtensionNote(name, resp.Display, "info") | |||
| 996 | + | case "noop", "": | |||
| 997 | + | // nothing | |||
| 998 | + | default: | |||
| 999 | + | i.mu.Lock() | |||
| 1000 | + | i.statusErr = "extension /" + name + ": unknown action " + resp.Action | |||
| 1001 | + | i.mu.Unlock() | |||
| 1002 | + | i.invalidate() | |||
| 1003 | + | } | |||
| 1004 | + | } | |||
| 1005 | + | ||||
| 1006 | + | // appendExtensionNote renders an extension-originated note in the | |||
| 1007 | + | // chat. Levels: "info" (muted), "warn" (warning), "error" (error), | |||
| 1008 | + | // "success" (tool/ok green). | |||
| 1009 | + | func (i *Interactive) appendExtensionNote(extName, msg, level string) { | |||
| 1010 | + | if msg == "" { | |||
| 1011 | + | return | |||
| 1012 | + | } | |||
| 1013 | + | i.mu.Lock() | |||
| 1014 | + | defer i.mu.Unlock() | |||
| 1015 | + | color := i.cfg.Theme.Muted | |||
| 1016 | + | switch level { | |||
| 1017 | + | case "warn": | |||
| 1018 | + | color = i.cfg.Theme.Warning | |||
| 1019 | + | case "error": | |||
| 1020 | + | color = i.cfg.Theme.Error | |||
| 1021 | + | case "success": | |||
| 1022 | + | color = i.cfg.Theme.Tool | |||
| 1023 | + | } | |||
| 1024 | + | prefix := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "["+extName+"] ") | |||
| 1025 | + | for _, line := range strings.Split(msg, "\n") { | |||
| 1026 | + | i.statusOK = "" // clear any stale ok | |||
| 1027 | + | i.statusErr = "" | |||
| 1028 | + | i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FG256(color, line)) | |||
| 1029 | + | } | |||
| 1030 | + | } | |||
| 1031 | + | ||||
| 1032 | + | // HostHooks implementation for the extension manager. The manager | |||
| 1033 | + | // holds an interface, not a concrete *Interactive, so these methods | |||
| 1034 | + | // are the only thing the manager sees. | |||
| 1035 | + | ||||
| 1036 | + | // Notify is the manager's NotifyFromExt entry point. | |||
| 1037 | + | func (i *Interactive) Notify(extName, level, message string) { | |||
| 1038 | + | i.appendExtensionNote(extName, message, level) | |||
| 1039 | + | i.invalidate() | |||
| 1040 | + | } | |||
| 1041 | + | ||||
| 1042 | + | // Submit feeds text through the agent loop as if the user had typed it. | |||
| 1043 | + | func (i *Interactive) Submit(text string) { | |||
| 1044 | + | i.startTurn(i.runCtx, text) | |||
| 1045 | + | } | |||
| 1046 | + | ||||
| 1047 | + | // Insert places text at the cursor in the editor. | |||
| 1048 | + | func (i *Interactive) Insert(text string) { | |||
| 1049 | + | i.ed.Insert(text) | |||
| 1050 | + | i.invalidate() | |||
| 1051 | + | } | |||
| 1052 | + | ||||
| 1053 | + | // Display appends a styled note from extName to the chat without a | |||
| 1054 | + | // model call. | |||
| 1055 | + | func (i *Interactive) Display(extName, text string) { | |||
| 1056 | + | i.appendExtensionNote(extName, text, "info") | |||
| 1057 | + | i.invalidate() | |||
| 1058 | + | } | |||
| 1059 | + | ||||
| 927 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | 1060 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | ||
| 928 | parts := strings.Fields(cmd) | 1061 | parts := strings.Fields(cmd) | ||
| 929 | switch parts[0] { | 1062 | switch parts[0] { | ||
| @@ -942,6 +1075,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 942 | i.parkedTurn = 0 | 1075 | i.parkedTurn = 0 | ||
| 943 | i.parkedTotal = 0 | 1076 | i.parkedTotal = 0 | ||
| 944 | i.scrollOffset = 0 | 1077 | i.scrollOffset = 0 | ||
| 1078 | + | i.extNotes = nil | |||
| 945 | i.view.InvalidateRenderCache() | 1079 | i.view.InvalidateRenderCache() | ||
| 946 | i.mu.Unlock() | 1080 | i.mu.Unlock() | ||
| 947 | case "/help": | 1081 | case "/help": | ||
M internal/agent/modes/slash_suggest.go +37 -2
| @@ -46,6 +46,39 @@ var slashCatalog = []slashCommand{ | |||
| 46 | 46 | // with "/". It does not own any input state — the editor drives. | |
| 47 | 47 | type slashSuggester struct { | |
| 48 | 48 | cursor int | |
| 49 | + | ||
| 50 | + | // extra are commands contributed by extensions, refreshed each | |
| 51 | + | // frame from the extension manager. Empty when no extensions | |
| 52 | + | // have registered any. | |
| 53 | + | extra []slashCommand | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // SetExtra updates the extension-contributed command list. Called | |
| 57 | + | // once per render with the live snapshot from the extension manager. | |
| 58 | + | func (s *slashSuggester) SetExtra(cmds []slashCommand) { s.extra = cmds } | |
| 59 | + | ||
| 60 | + | // allCatalog returns slashCatalog plus the current extra commands | |
| 61 | + | // (extension-registered). Extra entries are only kept if they don't | |
| 62 | + | // collide with a built-in name; the built-in always wins. | |
| 63 | + | func (s *slashSuggester) allCatalog() []slashCommand { | |
| 64 | + | if len(s.extra) == 0 { | |
| 65 | + | return slashCatalog | |
| 66 | + | } | |
| 67 | + | out := make([]slashCommand, 0, len(slashCatalog)+len(s.extra)) | |
| 68 | + | out = append(out, slashCatalog...) | |
| 69 | + | for _, c := range s.extra { | |
| 70 | + | dup := false | |
| 71 | + | for _, b := range slashCatalog { | |
| 72 | + | if b.Name == c.Name { | |
| 73 | + | dup = true | |
| 74 | + | break | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | if !dup { | |
| 78 | + | out = append(out, c) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | return out | |
| 49 | 82 | } | |
| 50 | 83 | ||
| 51 | 84 | // looksLikeSlashCommand reports whether text is an attempt at a slash | |
| @@ -78,7 +111,9 @@ func looksLikeSlashCommand(text string) bool { | |||
| 78 | 111 | } | |
| 79 | 112 | ||
| 80 | 113 | // isKnownSlashCommand reports whether text's head matches a registered | |
| 81 | - | // slash command name in slashCatalog. | |
| 114 | + | // slash command name in slashCatalog. Built-in only; extension | |
| 115 | + | // commands are looked up separately by the dispatcher (which | |
| 116 | + | // consults the extension manager). | |
| 82 | 117 | func isKnownSlashCommand(text string) bool { | |
| 83 | 118 | text = strings.TrimSpace(text) | |
| 84 | 119 | if text == "" || text[0] != '/' { | |
| @@ -110,7 +145,7 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||
| 110 | 145 | return nil | |
| 111 | 146 | } | |
| 112 | 147 | var out []slashCommand | |
| 113 | - | for _, c := range slashCatalog { | |
| 148 | + | for _, c := range s.allCatalog() { | |
| 114 | 149 | if strings.HasPrefix(c.Name, input) { | |
| 115 | 150 | out = append(out, c) | |
| 116 | 151 | } | |
| @@ -46,6 +46,39 @@ var slashCatalog = []slashCommand{ | |||||
| 46 | // with "/". It does not own any input state — the editor drives. | 46 | // with "/". It does not own any input state — the editor drives. | ||
| 47 | type slashSuggester struct { | 47 | type slashSuggester struct { | ||
| 48 | cursor int | 48 | cursor int | ||
| 49 | + | ||||
| 50 | + | // extra are commands contributed by extensions, refreshed each | |||
| 51 | + | // frame from the extension manager. Empty when no extensions | |||
| 52 | + | // have registered any. | |||
| 53 | + | extra []slashCommand | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // SetExtra updates the extension-contributed command list. Called | |||
| 57 | + | // once per render with the live snapshot from the extension manager. | |||
| 58 | + | func (s *slashSuggester) SetExtra(cmds []slashCommand) { s.extra = cmds } | |||
| 59 | + | ||||
| 60 | + | // allCatalog returns slashCatalog plus the current extra commands | |||
| 61 | + | // (extension-registered). Extra entries are only kept if they don't | |||
| 62 | + | // collide with a built-in name; the built-in always wins. | |||
| 63 | + | func (s *slashSuggester) allCatalog() []slashCommand { | |||
| 64 | + | if len(s.extra) == 0 { | |||
| 65 | + | return slashCatalog | |||
| 66 | + | } | |||
| 67 | + | out := make([]slashCommand, 0, len(slashCatalog)+len(s.extra)) | |||
| 68 | + | out = append(out, slashCatalog...) | |||
| 69 | + | for _, c := range s.extra { | |||
| 70 | + | dup := false | |||
| 71 | + | for _, b := range slashCatalog { | |||
| 72 | + | if b.Name == c.Name { | |||
| 73 | + | dup = true | |||
| 74 | + | break | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | if !dup { | |||
| 78 | + | out = append(out, c) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | return out | |||
| 49 | } | 82 | } | ||
| 50 | 83 | ||||
| 51 | // looksLikeSlashCommand reports whether text is an attempt at a slash | 84 | // looksLikeSlashCommand reports whether text is an attempt at a slash | ||
| @@ -78,7 +111,9 @@ func looksLikeSlashCommand(text string) bool { | |||||
| 78 | } | 111 | } | ||
| 79 | 112 | ||||
| 80 | // isKnownSlashCommand reports whether text's head matches a registered | 113 | // isKnownSlashCommand reports whether text's head matches a registered | ||
| 81 | - | // slash command name in slashCatalog. | 114 | + | // slash command name in slashCatalog. Built-in only; extension |
| 115 | + | // commands are looked up separately by the dispatcher (which | |||
| 116 | + | // consults the extension manager). | |||
| 82 | func isKnownSlashCommand(text string) bool { | 117 | func isKnownSlashCommand(text string) bool { | ||
| 83 | text = strings.TrimSpace(text) | 118 | text = strings.TrimSpace(text) | ||
| 84 | if text == "" || text[0] != '/' { | 119 | if text == "" || text[0] != '/' { | ||
| @@ -110,7 +145,7 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||||
| 110 | return nil | 145 | return nil | ||
| 111 | } | 146 | } | ||
| 112 | var out []slashCommand | 147 | var out []slashCommand | ||
| 113 | - | for _, c := range slashCatalog { | 148 | + | for _, c := range s.allCatalog() { |
| 114 | if strings.HasPrefix(c.Name, input) { | 149 | if strings.HasPrefix(c.Name, input) { | ||
| 115 | out = append(out, c) | 150 | out = append(out, c) | ||
| 116 | } | 151 | } | ||
A internal/extproto/extproto.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | // Package extproto defines the JSON-over-stdin/stdout wire format | |
| 2 | + | // spoken between zot and its extension subprocesses. Both the host | |
| 3 | + | // (internal/agent/extensions) and the SDK (pkg/zotext) marshal/ | |
| 4 | + | // unmarshal the same types, so changes here ripple through both. | |
| 5 | + | // | |
| 6 | + | // All frames are one JSON object terminated by a single LF. Object | |
| 7 | + | // boundaries follow newline boundaries; no multi-line JSON. | |
| 8 | + | // | |
| 9 | + | // Direction conventions in this file: | |
| 10 | + | // - Type names ending in "FromExt" are sent by the extension to zot. | |
| 11 | + | // - Type names ending in "FromHost" are sent by zot to the extension. | |
| 12 | + | // - Names without a suffix are direction-neutral payloads or shared | |
| 13 | + | // value types. | |
| 14 | + | // | |
| 15 | + | // Every frame has a top-level Type discriminator. Optional ID is | |
| 16 | + | // present on commands and on responses to commands so the sender can | |
| 17 | + | // correlate; events and notifications never carry an ID. | |
| 18 | + | package extproto | |
| 19 | + | ||
| 20 | + | import "encoding/json" | |
| 21 | + | ||
| 22 | + | // ProtocolVersion is the major version of this wire format. Bumped | |
| 23 | + | // for breaking changes; minor additions don't bump. | |
| 24 | + | const ProtocolVersion = 1 | |
| 25 | + | ||
| 26 | + | // Frame is the lowest-common-denominator parse target so a reader can | |
| 27 | + | // peek at the type before unmarshalling the full payload. | |
| 28 | + | type Frame struct { | |
| 29 | + | Type string `json:"type"` | |
| 30 | + | ID string `json:"id,omitempty"` | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // ---- extension -> host ---- | |
| 34 | + | ||
| 35 | + | // HelloFromExt is the first frame the extension sends after start. | |
| 36 | + | // Zot replies with HelloAckFromHost, then registration frames | |
| 37 | + | // (RegisterCommandFromExt, etc.) flow. | |
| 38 | + | type HelloFromExt struct { | |
| 39 | + | Type string `json:"type"` // "hello" | |
| 40 | + | Name string `json:"name"` | |
| 41 | + | Version string `json:"version"` | |
| 42 | + | Capabilities []string `json:"capabilities,omitempty"` | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // RegisterCommandFromExt asks zot to bind /name to this extension. | |
| 46 | + | // Description appears in the slash autocomplete + /help. | |
| 47 | + | type RegisterCommandFromExt struct { | |
| 48 | + | Type string `json:"type"` // "register_command" | |
| 49 | + | Name string `json:"name"` | |
| 50 | + | Description string `json:"description,omitempty"` | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // CommandResponseFromExt is the extension's answer to a | |
| 54 | + | // CommandInvokedFromHost. Action drives what zot does next: | |
| 55 | + | // | |
| 56 | + | // - "prompt" → submit Prompt as a fresh user message to the agent | |
| 57 | + | // - "insert" → insert Insert into the editor buffer at the cursor | |
| 58 | + | // - "display" → append Display to the chat as a one-shot note | |
| 59 | + | // (no model call, no transcript entry) | |
| 60 | + | // - "noop" → command handled internally, no UI change | |
| 61 | + | type CommandResponseFromExt struct { | |
| 62 | + | Type string `json:"type"` // "command_response" | |
| 63 | + | ID string `json:"id"` | |
| 64 | + | Action string `json:"action"` // see above | |
| 65 | + | Prompt string `json:"prompt,omitempty"` // for action=prompt | |
| 66 | + | Insert string `json:"insert,omitempty"` // for action=insert | |
| 67 | + | Display string `json:"display,omitempty"` // for action=display | |
| 68 | + | Error string `json:"error,omitempty"` // command failed; render to user | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // NotifyFromExt is a one-way status message the extension can push at | |
| 72 | + | // any time. Zot renders it in the chat as a styled note. | |
| 73 | + | type NotifyFromExt struct { | |
| 74 | + | Type string `json:"type"` // "notify" | |
| 75 | + | Level string `json:"level"` // "info" | "warn" | "error" | "success" | |
| 76 | + | Message string `json:"message"` | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // ShutdownAckFromExt acknowledges the host's shutdown request. The | |
| 80 | + | // extension should exit shortly after sending this; zot waits a few | |
| 81 | + | // seconds before SIGTERM. | |
| 82 | + | type ShutdownAckFromExt struct { | |
| 83 | + | Type string `json:"type"` // "shutdown_ack" | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // ---- host -> extension ---- | |
| 87 | + | ||
| 88 | + | // HelloAckFromHost is zot's reply to HelloFromExt. The extension may | |
| 89 | + | // inspect the host version + currently-active provider/model to decide | |
| 90 | + | // whether to register particular commands. | |
| 91 | + | type HelloAckFromHost struct { | |
| 92 | + | Type string `json:"type"` // "hello_ack" | |
| 93 | + | ProtocolVersion int `json:"protocol_version"` | |
| 94 | + | ZotVersion string `json:"zot_version"` | |
| 95 | + | Provider string `json:"provider"` | |
| 96 | + | Model string `json:"model"` | |
| 97 | + | CWD string `json:"cwd"` | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // CommandInvokedFromHost is sent when the user runs a slash command | |
| 101 | + | // the extension previously registered. Args contains everything after | |
| 102 | + | // the command name (already trimmed). | |
| 103 | + | type CommandInvokedFromHost struct { | |
| 104 | + | Type string `json:"type"` // "command_invoked" | |
| 105 | + | ID string `json:"id"` | |
| 106 | + | Name string `json:"name"` | |
| 107 | + | Args string `json:"args,omitempty"` | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // ShutdownFromHost asks the extension to clean up and exit. Zot | |
| 111 | + | // sends this when the user runs /reload-ext or zot itself is exiting | |
| 112 | + | // gracefully. Extensions that don't reply within a few seconds get | |
| 113 | + | // SIGTERM; SIGKILL after a few more. | |
| 114 | + | type ShutdownFromHost struct { | |
| 115 | + | Type string `json:"type"` // "shutdown" | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // ---- error frame (either direction) ---- | |
| 119 | + | ||
| 120 | + | // Error is a generic failure response. Used by either side when a | |
| 121 | + | // frame can't be processed (malformed JSON, unknown type, etc.). | |
| 122 | + | type Error struct { | |
| 123 | + | Type string `json:"type"` // "error" | |
| 124 | + | ID string `json:"id,omitempty"` | |
| 125 | + | Message string `json:"message"` | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // ---- helpers ---- | |
| 129 | + | ||
| 130 | + | // Encode marshals v and appends a trailing LF, ready to write to the | |
| 131 | + | // peer's pipe. Returns the marshalling error, if any. | |
| 132 | + | func Encode(v any) ([]byte, error) { | |
| 133 | + | b, err := json.Marshal(v) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, err | |
| 136 | + | } | |
| 137 | + | return append(b, '\n'), nil | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | // Package extproto defines the JSON-over-stdin/stdout wire format | |||
| 2 | + | // spoken between zot and its extension subprocesses. Both the host | |||
| 3 | + | // (internal/agent/extensions) and the SDK (pkg/zotext) marshal/ | |||
| 4 | + | // unmarshal the same types, so changes here ripple through both. | |||
| 5 | + | // | |||
| 6 | + | // All frames are one JSON object terminated by a single LF. Object | |||
| 7 | + | // boundaries follow newline boundaries; no multi-line JSON. | |||
| 8 | + | // | |||
| 9 | + | // Direction conventions in this file: | |||
| 10 | + | // - Type names ending in "FromExt" are sent by the extension to zot. | |||
| 11 | + | // - Type names ending in "FromHost" are sent by zot to the extension. | |||
| 12 | + | // - Names without a suffix are direction-neutral payloads or shared | |||
| 13 | + | // value types. | |||
| 14 | + | // | |||
| 15 | + | // Every frame has a top-level Type discriminator. Optional ID is | |||
| 16 | + | // present on commands and on responses to commands so the sender can | |||
| 17 | + | // correlate; events and notifications never carry an ID. | |||
| 18 | + | package extproto | |||
| 19 | + | ||||
| 20 | + | import "encoding/json" | |||
| 21 | + | ||||
| 22 | + | // ProtocolVersion is the major version of this wire format. Bumped | |||
| 23 | + | // for breaking changes; minor additions don't bump. | |||
| 24 | + | const ProtocolVersion = 1 | |||
| 25 | + | ||||
| 26 | + | // Frame is the lowest-common-denominator parse target so a reader can | |||
| 27 | + | // peek at the type before unmarshalling the full payload. | |||
| 28 | + | type Frame struct { | |||
| 29 | + | Type string `json:"type"` | |||
| 30 | + | ID string `json:"id,omitempty"` | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // ---- extension -> host ---- | |||
| 34 | + | ||||
| 35 | + | // HelloFromExt is the first frame the extension sends after start. | |||
| 36 | + | // Zot replies with HelloAckFromHost, then registration frames | |||
| 37 | + | // (RegisterCommandFromExt, etc.) flow. | |||
| 38 | + | type HelloFromExt struct { | |||
| 39 | + | Type string `json:"type"` // "hello" | |||
| 40 | + | Name string `json:"name"` | |||
| 41 | + | Version string `json:"version"` | |||
| 42 | + | Capabilities []string `json:"capabilities,omitempty"` | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // RegisterCommandFromExt asks zot to bind /name to this extension. | |||
| 46 | + | // Description appears in the slash autocomplete + /help. | |||
| 47 | + | type RegisterCommandFromExt struct { | |||
| 48 | + | Type string `json:"type"` // "register_command" | |||
| 49 | + | Name string `json:"name"` | |||
| 50 | + | Description string `json:"description,omitempty"` | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // CommandResponseFromExt is the extension's answer to a | |||
| 54 | + | // CommandInvokedFromHost. Action drives what zot does next: | |||
| 55 | + | // | |||
| 56 | + | // - "prompt" → submit Prompt as a fresh user message to the agent | |||
| 57 | + | // - "insert" → insert Insert into the editor buffer at the cursor | |||
| 58 | + | // - "display" → append Display to the chat as a one-shot note | |||
| 59 | + | // (no model call, no transcript entry) | |||
| 60 | + | // - "noop" → command handled internally, no UI change | |||
| 61 | + | type CommandResponseFromExt struct { | |||
| 62 | + | Type string `json:"type"` // "command_response" | |||
| 63 | + | ID string `json:"id"` | |||
| 64 | + | Action string `json:"action"` // see above | |||
| 65 | + | Prompt string `json:"prompt,omitempty"` // for action=prompt | |||
| 66 | + | Insert string `json:"insert,omitempty"` // for action=insert | |||
| 67 | + | Display string `json:"display,omitempty"` // for action=display | |||
| 68 | + | Error string `json:"error,omitempty"` // command failed; render to user | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // NotifyFromExt is a one-way status message the extension can push at | |||
| 72 | + | // any time. Zot renders it in the chat as a styled note. | |||
| 73 | + | type NotifyFromExt struct { | |||
| 74 | + | Type string `json:"type"` // "notify" | |||
| 75 | + | Level string `json:"level"` // "info" | "warn" | "error" | "success" | |||
| 76 | + | Message string `json:"message"` | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // ShutdownAckFromExt acknowledges the host's shutdown request. The | |||
| 80 | + | // extension should exit shortly after sending this; zot waits a few | |||
| 81 | + | // seconds before SIGTERM. | |||
| 82 | + | type ShutdownAckFromExt struct { | |||
| 83 | + | Type string `json:"type"` // "shutdown_ack" | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // ---- host -> extension ---- | |||
| 87 | + | ||||
| 88 | + | // HelloAckFromHost is zot's reply to HelloFromExt. The extension may | |||
| 89 | + | // inspect the host version + currently-active provider/model to decide | |||
| 90 | + | // whether to register particular commands. | |||
| 91 | + | type HelloAckFromHost struct { | |||
| 92 | + | Type string `json:"type"` // "hello_ack" | |||
| 93 | + | ProtocolVersion int `json:"protocol_version"` | |||
| 94 | + | ZotVersion string `json:"zot_version"` | |||
| 95 | + | Provider string `json:"provider"` | |||
| 96 | + | Model string `json:"model"` | |||
| 97 | + | CWD string `json:"cwd"` | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // CommandInvokedFromHost is sent when the user runs a slash command | |||
| 101 | + | // the extension previously registered. Args contains everything after | |||
| 102 | + | // the command name (already trimmed). | |||
| 103 | + | type CommandInvokedFromHost struct { | |||
| 104 | + | Type string `json:"type"` // "command_invoked" | |||
| 105 | + | ID string `json:"id"` | |||
| 106 | + | Name string `json:"name"` | |||
| 107 | + | Args string `json:"args,omitempty"` | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // ShutdownFromHost asks the extension to clean up and exit. Zot | |||
| 111 | + | // sends this when the user runs /reload-ext or zot itself is exiting | |||
| 112 | + | // gracefully. Extensions that don't reply within a few seconds get | |||
| 113 | + | // SIGTERM; SIGKILL after a few more. | |||
| 114 | + | type ShutdownFromHost struct { | |||
| 115 | + | Type string `json:"type"` // "shutdown" | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // ---- error frame (either direction) ---- | |||
| 119 | + | ||||
| 120 | + | // Error is a generic failure response. Used by either side when a | |||
| 121 | + | // frame can't be processed (malformed JSON, unknown type, etc.). | |||
| 122 | + | type Error struct { | |||
| 123 | + | Type string `json:"type"` // "error" | |||
| 124 | + | ID string `json:"id,omitempty"` | |||
| 125 | + | Message string `json:"message"` | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // ---- helpers ---- | |||
| 129 | + | ||||
| 130 | + | // Encode marshals v and appends a trailing LF, ready to write to the | |||
| 131 | + | // peer's pipe. Returns the marshalling error, if any. | |||
| 132 | + | func Encode(v any) ([]byte, error) { | |||
| 133 | + | b, err := json.Marshal(v) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return nil, err | |||
| 136 | + | } | |||
| 137 | + | return append(b, '\n'), nil | |||
| 138 | + | } | |||
M internal/tui/editor.go +4 -0
| @@ -311,6 +311,10 @@ func singleQuote(s string) string { | |||
| 311 | 311 | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | |
| 312 | 312 | } | |
| 313 | 313 | ||
| 314 | + | // Insert places s into the editor at the cursor, splitting on | |
| 315 | + | // newlines so multi-line pastes preserve their structure. | |
| 316 | + | func (e *Editor) Insert(s string) { e.insert(s) } | |
| 317 | + | ||
| 314 | 318 | func (e *Editor) insert(s string) { | |
| 315 | 319 | e.histIdx = -1 | |
| 316 | 320 | line := e.Lines[e.CursorR] | |
| @@ -311,6 +311,10 @@ func singleQuote(s string) string { | |||||
| 311 | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | 311 | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | ||
| 312 | } | 312 | } | ||
| 313 | 313 | ||||
| 314 | + | // Insert places s into the editor at the cursor, splitting on | |||
| 315 | + | // newlines so multi-line pastes preserve their structure. | |||
| 316 | + | func (e *Editor) Insert(s string) { e.insert(s) } | |||
| 317 | + | ||||
| 314 | func (e *Editor) insert(s string) { | 318 | func (e *Editor) insert(s string) { | ||
| 315 | e.histIdx = -1 | 319 | e.histIdx = -1 | ||
| 316 | line := e.Lines[e.CursorR] | 320 | line := e.Lines[e.CursorR] | ||
A pkg/zotext/zotext.go +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | // Package zotext is the Go SDK for writing zot extensions. | |
| 2 | + | // | |
| 3 | + | // An extension is a subprocess that talks to zot over its stdin/stdout | |
| 4 | + | // in newline-delimited JSON. This package wraps the wire format so | |
| 5 | + | // extension authors can write straightforward Go without reimplementing | |
| 6 | + | // the protocol. | |
| 7 | + | // | |
| 8 | + | // Minimal example (registers /hello and replies with a static prompt): | |
| 9 | + | // | |
| 10 | + | // package main | |
| 11 | + | // | |
| 12 | + | // import "github.com/patriceckhart/zot/pkg/zotext" | |
| 13 | + | // | |
| 14 | + | // func main() { | |
| 15 | + | // ext := zotext.New("hello", "1.0.0") | |
| 16 | + | // ext.Command("hello", "say hi", func(args string) zotext.Response { | |
| 17 | + | // return zotext.Prompt("Greet me in three different languages.") | |
| 18 | + | // }) | |
| 19 | + | // ext.Run() | |
| 20 | + | // } | |
| 21 | + | // | |
| 22 | + | // Build it, drop the binary + an extension.json next to it under | |
| 23 | + | // `$ZOT_HOME/extensions/hello/`, and zot picks it up on next launch. | |
| 24 | + | // | |
| 25 | + | // The same wire format also has reference clients in TypeScript and | |
| 26 | + | // Python under examples/extensions/. Use whichever language fits. | |
| 27 | + | package zotext | |
| 28 | + | ||
| 29 | + | import ( | |
| 30 | + | "bufio" | |
| 31 | + | "encoding/json" | |
| 32 | + | "fmt" | |
| 33 | + | "io" | |
| 34 | + | "os" | |
| 35 | + | "sync" | |
| 36 | + | ||
| 37 | + | "github.com/patriceckhart/zot/internal/extproto" | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | // CommandHandler is invoked when the user runs the extension's | |
| 41 | + | // registered slash command. args is everything the user typed after | |
| 42 | + | // the command name (already trimmed). Return a Response describing | |
| 43 | + | // what zot should do next. | |
| 44 | + | type CommandHandler func(args string) Response | |
| 45 | + | ||
| 46 | + | // Response tells zot how to react to a command invocation. Construct | |
| 47 | + | // one with Prompt(), Insert(), Display(), or Noop(). | |
| 48 | + | type Response struct { | |
| 49 | + | Action string // "prompt", "insert", "display", "noop" | |
| 50 | + | Prompt string | |
| 51 | + | Insert string | |
| 52 | + | Display string | |
| 53 | + | Error string | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Prompt returns a Response that submits text as a fresh user message | |
| 57 | + | // to the agent (running it through the model loop as if the user had | |
| 58 | + | // typed and pressed enter). | |
| 59 | + | func Prompt(text string) Response { return Response{Action: "prompt", Prompt: text} } | |
| 60 | + | ||
| 61 | + | // Insert returns a Response that drops text into the editor at the | |
| 62 | + | // cursor without submitting. | |
| 63 | + | func Insert(text string) Response { return Response{Action: "insert", Insert: text} } | |
| 64 | + | ||
| 65 | + | // Display returns a Response that adds a one-shot styled note to the | |
| 66 | + | // chat without invoking the model. Useful for showing a result without | |
| 67 | + | // burning tokens. | |
| 68 | + | func Display(text string) Response { return Response{Action: "display", Display: text} } | |
| 69 | + | ||
| 70 | + | // Noop returns a Response that signals "I handled it, no UI change". | |
| 71 | + | // Use after pushing your own state or notifications. | |
| 72 | + | func Noop() Response { return Response{Action: "noop"} } | |
| 73 | + | ||
| 74 | + | // Errorf returns a Response that surfaces the error in the chat as a | |
| 75 | + | // red status line. | |
| 76 | + | func Errorf(format string, args ...any) Response { | |
| 77 | + | return Response{Action: "noop", Error: fmt.Sprintf(format, args...)} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Extension is one zot extension. Construct with New, register | |
| 81 | + | // commands, then call Run. | |
| 82 | + | type Extension struct { | |
| 83 | + | name string | |
| 84 | + | version string | |
| 85 | + | ||
| 86 | + | in io.Reader | |
| 87 | + | out io.Writer | |
| 88 | + | stderr io.Writer | |
| 89 | + | writeMu sync.Mutex | |
| 90 | + | ||
| 91 | + | mu sync.Mutex | |
| 92 | + | commands map[string]CommandHandler | |
| 93 | + | descriptions []descTuple // ordered so register frames arrive in registration order | |
| 94 | + | ||
| 95 | + | // Caps reported in the hello frame. The SDK enables "commands" | |
| 96 | + | // automatically; future capabilities (tools, events) will live | |
| 97 | + | // here when those phases land. | |
| 98 | + | caps []string | |
| 99 | + | ||
| 100 | + | // hostInfo is filled in once HelloAck arrives. | |
| 101 | + | host HostInfo | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | type descTuple struct { | |
| 105 | + | name, desc string | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // HostInfo is what the host (zot) tells us in HelloAck. Useful for | |
| 109 | + | // extensions that want to behave differently per provider. | |
| 110 | + | type HostInfo struct { | |
| 111 | + | ProtocolVersion int | |
| 112 | + | ZotVersion string | |
| 113 | + | Provider string | |
| 114 | + | Model string | |
| 115 | + | CWD string | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // New constructs an Extension with the given identifier. name should | |
| 119 | + | // match the name field in extension.json. | |
| 120 | + | func New(name, version string) *Extension { | |
| 121 | + | return &Extension{ | |
| 122 | + | name: name, | |
| 123 | + | version: version, | |
| 124 | + | in: os.Stdin, | |
| 125 | + | out: os.Stdout, | |
| 126 | + | stderr: os.Stderr, | |
| 127 | + | commands: map[string]CommandHandler{}, | |
| 128 | + | caps: []string{"commands"}, | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Host returns the HostInfo received during the hello handshake. | |
| 133 | + | // Returns the zero value if Run hasn't started yet. | |
| 134 | + | func (e *Extension) Host() HostInfo { return e.host } | |
| 135 | + | ||
| 136 | + | // Logf writes a line to the extension's stderr, which zot captures to | |
| 137 | + | // $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything | |
| 138 | + | // you print to stdout would corrupt the JSON wire protocol. | |
| 139 | + | func (e *Extension) Logf(format string, args ...any) { | |
| 140 | + | fmt.Fprintf(e.stderr, "["+e.name+"] "+format+"\n", args...) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Command registers a slash-command handler. Call this BEFORE Run(). | |
| 144 | + | // Once Run is going, when the user runs /name in zot, fn is invoked | |
| 145 | + | // with the remaining args. | |
| 146 | + | // | |
| 147 | + | // Naming conflicts with built-in commands (e.g. /help) are silently | |
| 148 | + | // shadowed by the built-in; check the user's "ext logs" if a command | |
| 149 | + | // you registered isn't taking effect. | |
| 150 | + | func (e *Extension) Command(name, description string, fn CommandHandler) { | |
| 151 | + | e.mu.Lock() | |
| 152 | + | e.commands[name] = fn | |
| 153 | + | e.descriptions = append(e.descriptions, descTuple{name: name, desc: description}) | |
| 154 | + | e.mu.Unlock() | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // Notify pushes an info-level status note into zot's chat without | |
| 158 | + | // requiring a slash command from the user. | |
| 159 | + | func (e *Extension) Notify(level, message string) { | |
| 160 | + | _ = e.send(extproto.NotifyFromExt{ | |
| 161 | + | Type: "notify", | |
| 162 | + | Level: level, | |
| 163 | + | Message: message, | |
| 164 | + | }) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // Run starts the protocol loop. Blocks until stdin closes (zot has | |
| 168 | + | // shut us down). Returns the first fatal error, or nil on clean exit. | |
| 169 | + | func (e *Extension) Run() error { | |
| 170 | + | // Send hello, then re-announce all commands (covers Command calls | |
| 171 | + | // made before Run, and is also fine for those made after via | |
| 172 | + | // Command()'s direct send). | |
| 173 | + | if err := e.send(extproto.HelloFromExt{ | |
| 174 | + | Type: "hello", | |
| 175 | + | Name: e.name, | |
| 176 | + | Version: e.version, | |
| 177 | + | Capabilities: e.caps, | |
| 178 | + | }); err != nil { | |
| 179 | + | return err | |
| 180 | + | } | |
| 181 | + | e.mu.Lock() | |
| 182 | + | descs := append([]descTuple(nil), e.descriptions...) | |
| 183 | + | e.mu.Unlock() | |
| 184 | + | for _, d := range descs { | |
| 185 | + | _ = e.send(extproto.RegisterCommandFromExt{ | |
| 186 | + | Type: "register_command", | |
| 187 | + | Name: d.name, | |
| 188 | + | Description: d.desc, | |
| 189 | + | }) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | scanner := bufio.NewScanner(e.in) | |
| 193 | + | scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | |
| 194 | + | for scanner.Scan() { | |
| 195 | + | line := scanner.Bytes() | |
| 196 | + | var frame extproto.Frame | |
| 197 | + | if err := json.Unmarshal(line, &frame); err != nil { | |
| 198 | + | e.Logf("malformed frame from host: %v", err) | |
| 199 | + | continue | |
| 200 | + | } | |
| 201 | + | switch frame.Type { | |
| 202 | + | case "hello_ack": | |
| 203 | + | var ack extproto.HelloAckFromHost | |
| 204 | + | if err := json.Unmarshal(line, &ack); err == nil { | |
| 205 | + | e.host = HostInfo{ | |
| 206 | + | ProtocolVersion: ack.ProtocolVersion, | |
| 207 | + | ZotVersion: ack.ZotVersion, | |
| 208 | + | Provider: ack.Provider, | |
| 209 | + | Model: ack.Model, | |
| 210 | + | CWD: ack.CWD, | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | case "command_invoked": | |
| 214 | + | var ci extproto.CommandInvokedFromHost | |
| 215 | + | if err := json.Unmarshal(line, &ci); err != nil { | |
| 216 | + | continue | |
| 217 | + | } | |
| 218 | + | e.mu.Lock() | |
| 219 | + | fn := e.commands[ci.Name] | |
| 220 | + | e.mu.Unlock() | |
| 221 | + | if fn == nil { | |
| 222 | + | e.respond(ci.ID, Errorf("no handler for /%s", ci.Name)) | |
| 223 | + | continue | |
| 224 | + | } | |
| 225 | + | // Run the handler on its own goroutine so a slow handler | |
| 226 | + | // doesn't block subsequent commands. Order isn't promised. | |
| 227 | + | go func(id string, fn CommandHandler, args string) { | |
| 228 | + | defer func() { | |
| 229 | + | if r := recover(); r != nil { | |
| 230 | + | e.respond(id, Errorf("panic: %v", r)) | |
| 231 | + | } | |
| 232 | + | }() | |
| 233 | + | resp := fn(args) | |
| 234 | + | e.respond(id, resp) | |
| 235 | + | }(ci.ID, fn, ci.Args) | |
| 236 | + | case "shutdown": | |
| 237 | + | _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"}) | |
| 238 | + | return nil | |
| 239 | + | default: | |
| 240 | + | e.Logf("unknown frame type %q", frame.Type) | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | return scanner.Err() | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // respond serialises a CommandResponseFromExt for the given id. | |
| 247 | + | func (e *Extension) respond(id string, r Response) { | |
| 248 | + | if r.Action == "" { | |
| 249 | + | r.Action = "noop" | |
| 250 | + | } | |
| 251 | + | _ = e.send(extproto.CommandResponseFromExt{ | |
| 252 | + | Type: "command_response", | |
| 253 | + | ID: id, | |
| 254 | + | Action: r.Action, | |
| 255 | + | Prompt: r.Prompt, | |
| 256 | + | Insert: r.Insert, | |
| 257 | + | Display: r.Display, | |
| 258 | + | Error: r.Error, | |
| 259 | + | }) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // send marshals v + LF and writes it under a mutex (so concurrent | |
| 263 | + | // goroutines don't interleave bytes on stdout). | |
| 264 | + | func (e *Extension) send(v any) error { | |
| 265 | + | b, err := extproto.Encode(v) | |
| 266 | + | if err != nil { | |
| 267 | + | return err | |
| 268 | + | } | |
| 269 | + | e.writeMu.Lock() | |
| 270 | + | defer e.writeMu.Unlock() | |
| 271 | + | _, err = e.out.Write(b) | |
| 272 | + | return err | |
| 273 | + | } | |
| @@ -0,0 +1,273 @@ | |||||
| 1 | + | // Package zotext is the Go SDK for writing zot extensions. | |||
| 2 | + | // | |||
| 3 | + | // An extension is a subprocess that talks to zot over its stdin/stdout | |||
| 4 | + | // in newline-delimited JSON. This package wraps the wire format so | |||
| 5 | + | // extension authors can write straightforward Go without reimplementing | |||
| 6 | + | // the protocol. | |||
| 7 | + | // | |||
| 8 | + | // Minimal example (registers /hello and replies with a static prompt): | |||
| 9 | + | // | |||
| 10 | + | // package main | |||
| 11 | + | // | |||
| 12 | + | // import "github.com/patriceckhart/zot/pkg/zotext" | |||
| 13 | + | // | |||
| 14 | + | // func main() { | |||
| 15 | + | // ext := zotext.New("hello", "1.0.0") | |||
| 16 | + | // ext.Command("hello", "say hi", func(args string) zotext.Response { | |||
| 17 | + | // return zotext.Prompt("Greet me in three different languages.") | |||
| 18 | + | // }) | |||
| 19 | + | // ext.Run() | |||
| 20 | + | // } | |||
| 21 | + | // | |||
| 22 | + | // Build it, drop the binary + an extension.json next to it under | |||
| 23 | + | // `$ZOT_HOME/extensions/hello/`, and zot picks it up on next launch. | |||
| 24 | + | // | |||
| 25 | + | // The same wire format also has reference clients in TypeScript and | |||
| 26 | + | // Python under examples/extensions/. Use whichever language fits. | |||
| 27 | + | package zotext | |||
| 28 | + | ||||
| 29 | + | import ( | |||
| 30 | + | "bufio" | |||
| 31 | + | "encoding/json" | |||
| 32 | + | "fmt" | |||
| 33 | + | "io" | |||
| 34 | + | "os" | |||
| 35 | + | "sync" | |||
| 36 | + | ||||
| 37 | + | "github.com/patriceckhart/zot/internal/extproto" | |||
| 38 | + | ) | |||
| 39 | + | ||||
| 40 | + | // CommandHandler is invoked when the user runs the extension's | |||
| 41 | + | // registered slash command. args is everything the user typed after | |||
| 42 | + | // the command name (already trimmed). Return a Response describing | |||
| 43 | + | // what zot should do next. | |||
| 44 | + | type CommandHandler func(args string) Response | |||
| 45 | + | ||||
| 46 | + | // Response tells zot how to react to a command invocation. Construct | |||
| 47 | + | // one with Prompt(), Insert(), Display(), or Noop(). | |||
| 48 | + | type Response struct { | |||
| 49 | + | Action string // "prompt", "insert", "display", "noop" | |||
| 50 | + | Prompt string | |||
| 51 | + | Insert string | |||
| 52 | + | Display string | |||
| 53 | + | Error string | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // Prompt returns a Response that submits text as a fresh user message | |||
| 57 | + | // to the agent (running it through the model loop as if the user had | |||
| 58 | + | // typed and pressed enter). | |||
| 59 | + | func Prompt(text string) Response { return Response{Action: "prompt", Prompt: text} } | |||
| 60 | + | ||||
| 61 | + | // Insert returns a Response that drops text into the editor at the | |||
| 62 | + | // cursor without submitting. | |||
| 63 | + | func Insert(text string) Response { return Response{Action: "insert", Insert: text} } | |||
| 64 | + | ||||
| 65 | + | // Display returns a Response that adds a one-shot styled note to the | |||
| 66 | + | // chat without invoking the model. Useful for showing a result without | |||
| 67 | + | // burning tokens. | |||
| 68 | + | func Display(text string) Response { return Response{Action: "display", Display: text} } | |||
| 69 | + | ||||
| 70 | + | // Noop returns a Response that signals "I handled it, no UI change". | |||
| 71 | + | // Use after pushing your own state or notifications. | |||
| 72 | + | func Noop() Response { return Response{Action: "noop"} } | |||
| 73 | + | ||||
| 74 | + | // Errorf returns a Response that surfaces the error in the chat as a | |||
| 75 | + | // red status line. | |||
| 76 | + | func Errorf(format string, args ...any) Response { | |||
| 77 | + | return Response{Action: "noop", Error: fmt.Sprintf(format, args...)} | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Extension is one zot extension. Construct with New, register | |||
| 81 | + | // commands, then call Run. | |||
| 82 | + | type Extension struct { | |||
| 83 | + | name string | |||
| 84 | + | version string | |||
| 85 | + | ||||
| 86 | + | in io.Reader | |||
| 87 | + | out io.Writer | |||
| 88 | + | stderr io.Writer | |||
| 89 | + | writeMu sync.Mutex | |||
| 90 | + | ||||
| 91 | + | mu sync.Mutex | |||
| 92 | + | commands map[string]CommandHandler | |||
| 93 | + | descriptions []descTuple // ordered so register frames arrive in registration order | |||
| 94 | + | ||||
| 95 | + | // Caps reported in the hello frame. The SDK enables "commands" | |||
| 96 | + | // automatically; future capabilities (tools, events) will live | |||
| 97 | + | // here when those phases land. | |||
| 98 | + | caps []string | |||
| 99 | + | ||||
| 100 | + | // hostInfo is filled in once HelloAck arrives. | |||
| 101 | + | host HostInfo | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | type descTuple struct { | |||
| 105 | + | name, desc string | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // HostInfo is what the host (zot) tells us in HelloAck. Useful for | |||
| 109 | + | // extensions that want to behave differently per provider. | |||
| 110 | + | type HostInfo struct { | |||
| 111 | + | ProtocolVersion int | |||
| 112 | + | ZotVersion string | |||
| 113 | + | Provider string | |||
| 114 | + | Model string | |||
| 115 | + | CWD string | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // New constructs an Extension with the given identifier. name should | |||
| 119 | + | // match the name field in extension.json. | |||
| 120 | + | func New(name, version string) *Extension { | |||
| 121 | + | return &Extension{ | |||
| 122 | + | name: name, | |||
| 123 | + | version: version, | |||
| 124 | + | in: os.Stdin, | |||
| 125 | + | out: os.Stdout, | |||
| 126 | + | stderr: os.Stderr, | |||
| 127 | + | commands: map[string]CommandHandler{}, | |||
| 128 | + | caps: []string{"commands"}, | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // Host returns the HostInfo received during the hello handshake. | |||
| 133 | + | // Returns the zero value if Run hasn't started yet. | |||
| 134 | + | func (e *Extension) Host() HostInfo { return e.host } | |||
| 135 | + | ||||
| 136 | + | // Logf writes a line to the extension's stderr, which zot captures to | |||
| 137 | + | // $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything | |||
| 138 | + | // you print to stdout would corrupt the JSON wire protocol. | |||
| 139 | + | func (e *Extension) Logf(format string, args ...any) { | |||
| 140 | + | fmt.Fprintf(e.stderr, "["+e.name+"] "+format+"\n", args...) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // Command registers a slash-command handler. Call this BEFORE Run(). | |||
| 144 | + | // Once Run is going, when the user runs /name in zot, fn is invoked | |||
| 145 | + | // with the remaining args. | |||
| 146 | + | // | |||
| 147 | + | // Naming conflicts with built-in commands (e.g. /help) are silently | |||
| 148 | + | // shadowed by the built-in; check the user's "ext logs" if a command | |||
| 149 | + | // you registered isn't taking effect. | |||
| 150 | + | func (e *Extension) Command(name, description string, fn CommandHandler) { | |||
| 151 | + | e.mu.Lock() | |||
| 152 | + | e.commands[name] = fn | |||
| 153 | + | e.descriptions = append(e.descriptions, descTuple{name: name, desc: description}) | |||
| 154 | + | e.mu.Unlock() | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // Notify pushes an info-level status note into zot's chat without | |||
| 158 | + | // requiring a slash command from the user. | |||
| 159 | + | func (e *Extension) Notify(level, message string) { | |||
| 160 | + | _ = e.send(extproto.NotifyFromExt{ | |||
| 161 | + | Type: "notify", | |||
| 162 | + | Level: level, | |||
| 163 | + | Message: message, | |||
| 164 | + | }) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // Run starts the protocol loop. Blocks until stdin closes (zot has | |||
| 168 | + | // shut us down). Returns the first fatal error, or nil on clean exit. | |||
| 169 | + | func (e *Extension) Run() error { | |||
| 170 | + | // Send hello, then re-announce all commands (covers Command calls | |||
| 171 | + | // made before Run, and is also fine for those made after via | |||
| 172 | + | // Command()'s direct send). | |||
| 173 | + | if err := e.send(extproto.HelloFromExt{ | |||
| 174 | + | Type: "hello", | |||
| 175 | + | Name: e.name, | |||
| 176 | + | Version: e.version, | |||
| 177 | + | Capabilities: e.caps, | |||
| 178 | + | }); err != nil { | |||
| 179 | + | return err | |||
| 180 | + | } | |||
| 181 | + | e.mu.Lock() | |||
| 182 | + | descs := append([]descTuple(nil), e.descriptions...) | |||
| 183 | + | e.mu.Unlock() | |||
| 184 | + | for _, d := range descs { | |||
| 185 | + | _ = e.send(extproto.RegisterCommandFromExt{ | |||
| 186 | + | Type: "register_command", | |||
| 187 | + | Name: d.name, | |||
| 188 | + | Description: d.desc, | |||
| 189 | + | }) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | scanner := bufio.NewScanner(e.in) | |||
| 193 | + | scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | |||
| 194 | + | for scanner.Scan() { | |||
| 195 | + | line := scanner.Bytes() | |||
| 196 | + | var frame extproto.Frame | |||
| 197 | + | if err := json.Unmarshal(line, &frame); err != nil { | |||
| 198 | + | e.Logf("malformed frame from host: %v", err) | |||
| 199 | + | continue | |||
| 200 | + | } | |||
| 201 | + | switch frame.Type { | |||
| 202 | + | case "hello_ack": | |||
| 203 | + | var ack extproto.HelloAckFromHost | |||
| 204 | + | if err := json.Unmarshal(line, &ack); err == nil { | |||
| 205 | + | e.host = HostInfo{ | |||
| 206 | + | ProtocolVersion: ack.ProtocolVersion, | |||
| 207 | + | ZotVersion: ack.ZotVersion, | |||
| 208 | + | Provider: ack.Provider, | |||
| 209 | + | Model: ack.Model, | |||
| 210 | + | CWD: ack.CWD, | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | case "command_invoked": | |||
| 214 | + | var ci extproto.CommandInvokedFromHost | |||
| 215 | + | if err := json.Unmarshal(line, &ci); err != nil { | |||
| 216 | + | continue | |||
| 217 | + | } | |||
| 218 | + | e.mu.Lock() | |||
| 219 | + | fn := e.commands[ci.Name] | |||
| 220 | + | e.mu.Unlock() | |||
| 221 | + | if fn == nil { | |||
| 222 | + | e.respond(ci.ID, Errorf("no handler for /%s", ci.Name)) | |||
| 223 | + | continue | |||
| 224 | + | } | |||
| 225 | + | // Run the handler on its own goroutine so a slow handler | |||
| 226 | + | // doesn't block subsequent commands. Order isn't promised. | |||
| 227 | + | go func(id string, fn CommandHandler, args string) { | |||
| 228 | + | defer func() { | |||
| 229 | + | if r := recover(); r != nil { | |||
| 230 | + | e.respond(id, Errorf("panic: %v", r)) | |||
| 231 | + | } | |||
| 232 | + | }() | |||
| 233 | + | resp := fn(args) | |||
| 234 | + | e.respond(id, resp) | |||
| 235 | + | }(ci.ID, fn, ci.Args) | |||
| 236 | + | case "shutdown": | |||
| 237 | + | _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"}) | |||
| 238 | + | return nil | |||
| 239 | + | default: | |||
| 240 | + | e.Logf("unknown frame type %q", frame.Type) | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | return scanner.Err() | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // respond serialises a CommandResponseFromExt for the given id. | |||
| 247 | + | func (e *Extension) respond(id string, r Response) { | |||
| 248 | + | if r.Action == "" { | |||
| 249 | + | r.Action = "noop" | |||
| 250 | + | } | |||
| 251 | + | _ = e.send(extproto.CommandResponseFromExt{ | |||
| 252 | + | Type: "command_response", | |||
| 253 | + | ID: id, | |||
| 254 | + | Action: r.Action, | |||
| 255 | + | Prompt: r.Prompt, | |||
| 256 | + | Insert: r.Insert, | |||
| 257 | + | Display: r.Display, | |||
| 258 | + | Error: r.Error, | |||
| 259 | + | }) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // send marshals v + LF and writes it under a mutex (so concurrent | |||
| 263 | + | // goroutines don't interleave bytes on stdout). | |||
| 264 | + | func (e *Extension) send(v any) error { | |||
| 265 | + | b, err := extproto.Encode(v) | |||
| 266 | + | if err != nil { | |||
| 267 | + | return err | |||
| 268 | + | } | |||
| 269 | + | e.writeMu.Lock() | |||
| 270 | + | defer e.writeMu.Unlock() | |||
| 271 | + | _, err = e.out.Write(b) | |||
| 272 | + | return err | |||
| 273 | + | } | |||