detached 0c92d6e

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.
77 - four tools (read, write, edit, bash).
88 - three run modes (interactive tui, print, json).
99 - built-in telegram bot.
10-- no extensions atm.
10+- extensions in any language via subprocess + json-rpc; see [docs/extensions.md](docs/extensions.md).
1111 - no community atm.
1212
1313 ## 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 ## install13 ## 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 (
77 "io"
88 "os"
99 "strings"
10+ "time"
1011
12+ "github.com/patriceckhart/zot/internal/agent/extensions"
1113 "github.com/patriceckhart/zot/internal/agent/modes"
1214 "github.com/patriceckhart/zot/internal/auth"
1315 "github.com/patriceckhart/zot/internal/core"
@@ -15,6 +17,42 @@ import (
1517 "github.com/patriceckhart/zot/internal/tui"
1618 )
1719
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+
1856 // Run is the top-level entrypoint for the zot binary.
1957 func Run(rawArgs []string, version string) error {
2058 // Subcommand router: `zot bot ...` is handled separately so the
@@ -22,6 +60,9 @@ func Run(rawArgs []string, version string) error {
2260 if handled, err := runBotCommand(rawArgs, version); handled {
2361 return err
2462 }
63+ if handled, err := runExtCommand(rawArgs); handled {
64+ return err
65+ }
2566 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
2667 // spawn the binary with a clean argv. Strip the leading 'rpc'
2768 // 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 {
223264 }
224265 }()
225266
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{
227281 Terminal: term,
228282 Theme: tui.Dark,
229283 Model: r.Model,
@@ -245,6 +299,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
245299 BuildAgent: buildAgent,
246300 BuildAgentFor: buildAgentFor,
247301 LoadSession: loadSession,
302+ Extensions: extMgr,
248303 PersistModel: func(providerName, model string) {
249304 // Update config.json so next launch uses the same pick.
250305 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 the58 // 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 err61 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 can66 // `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 (
88 "sync"
99 "time"
1010
11+ "github.com/patriceckhart/zot/internal/agent/extensions"
1112 "github.com/patriceckhart/zot/internal/agent/tools"
1213 "github.com/patriceckhart/zot/internal/auth"
1314 "github.com/patriceckhart/zot/internal/core"
@@ -78,6 +79,11 @@ type InteractiveConfig struct {
7879
7980 OnAssistant func(m provider.Message)
8081 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
8187 }
8288
8389 // Interactive is the TUI chat loop.
@@ -146,6 +152,11 @@ type Interactive struct {
146152 // banner shows the binary version for welcomeVersionDuration
147153 // after this point and reverts to plain text after.
148154 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
149160 }
150161
151162 // welcomeVersionDuration is how long the welcome banner shows the
@@ -486,6 +497,13 @@ func (i *Interactive) redraw() {
486497 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
487498 }
488499
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+
489507 // Dialogs (login or model picker) render between chat and the editor.
490508 var dialog []string
491509 switch {
@@ -503,6 +521,19 @@ func (i *Interactive) redraw() {
503521
504522 // Slash-command autocomplete: popup above the status line, only
505523 // 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+ }
506537 var suggest []string
507538 currentInput := i.ed.Value()
508539 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) {
874905 i.suggest.Reset()
875906
876907 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+ }
877914 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
881921 }
882922 i.mu.Lock()
883923 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) {
892932 // race with a streaming response. Safe commands (/help,
893933 // /jump, /sessions, /lock, /unlock, /exit) run immediately
894934 // without disturbing the active turn.
895- head := text
896- if idx := strings.IndexAny(text, " \t"); idx >= 0 {
897- head = text[:idx]
898- }
899935 if slashCancelsTurn(head) {
900936 i.cancelAndWaitForIdle()
901937 }
@@ -924,6 +960,103 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
924960 return false
925961 }
926962
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+
9271060 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
9281061 parts := strings.Fields(cmd)
9291062 switch parts[0] {
@@ -942,6 +1075,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
9421075 i.parkedTurn = 0
9431076 i.parkedTotal = 0
9441077 i.scrollOffset = 0
1078+ i.extNotes = nil
9451079 i.view.InvalidateRenderCache()
9461080 i.mu.Unlock()
9471081 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 welcomeVersionDuration152 // 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.Time154 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 the162 // 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 []string508 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, only522 // 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 []string537 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 := text915+ // 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 immediately933 // /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 false960 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 = 01075 i.parkedTurn = 0
943 i.parkedTotal = 01076 i.parkedTotal = 0
944 i.scrollOffset = 01077 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{
4646 // with "/". It does not own any input state — the editor drives.
4747 type slashSuggester struct {
4848 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
4982 }
5083
5184 // looksLikeSlashCommand reports whether text is an attempt at a slash
@@ -78,7 +111,9 @@ func looksLikeSlashCommand(text string) bool {
78111 }
79112
80113 // 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).
82117 func isKnownSlashCommand(text string) bool {
83118 text = strings.TrimSpace(text)
84119 if text == "" || text[0] != '/' {
@@ -110,7 +145,7 @@ func (s *slashSuggester) matches(input string) []slashCommand {
110145 return nil
111146 }
112147 var out []slashCommand
113- for _, c := range slashCatalog {
148+ for _, c := range s.allCatalog() {
114149 if strings.HasPrefix(c.Name, input) {
115150 out = append(out, c)
116151 }
@@ -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 int48 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 slash84 // 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 registered113 // 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 nil145 return nil
111 }146 }
112 var out []slashCommand147 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 {
311311 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
312312 }
313313
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+
314318 func (e *Editor) insert(s string) {
315319 e.histIdx = -1
316320 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 = -1319 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close