From 1c7488285b06bade13d269bc6b7dde46172b85ac Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Sun, 19 Apr 2026 14:20:22 +0200 Subject: [PATCH] fix(extensions): route through manager from runSlash default branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extension commands appeared in the autocomplete popup but invoking them produced "unknown command: /summon". The submit-handler path already tried the extension manager before erroring, but the popup- enter path (suggest.Selection -> runSlash) bypassed that check and fell straight into runSlash's switch, where the default case bailed with the generic error. Fix: runSlash's default branch now also consults cfg.Extensions.HasCommand and dispatches via invokeExtensionCommand when matched. Both UI paths (typed-and-enter, popup-enter) now route identically. Built-in cases above default still always win on conflict. Also adds examples/extensions/clock — a node extension demonstrating the wire protocol from a non-Go runtime. Pure stdlib (readline + process), no npm install. Registers /now (display) and /uptime (prompt). Documented in its README; the protocol works the same from any language. --- examples/extensions/clock/README.md | 53 ++++++++ examples/extensions/clock/extension.json | 9 ++ examples/extensions/clock/index.js | 164 +++++++++++++++++++++++ internal/agent/modes/interactive.go | 14 ++ 4 files changed, 240 insertions(+) create mode 100644 examples/extensions/clock/README.md create mode 100644 examples/extensions/clock/extension.json create mode 100644 examples/extensions/clock/index.js diff --git a/examples/extensions/clock/README.md b/examples/extensions/clock/README.md new file mode 100644 index 0000000..095bfa8 --- /dev/null +++ b/examples/extensions/clock/README.md @@ -0,0 +1,53 @@ +# clock — example zot extension (Node, no dependencies) + +A minimal TypeScript-style extension showing the wire protocol from +the Node side without any SDK. Pure stdlib (`readline`, `process`). + +## Requirements + +Node 18 or newer (uses ESM). No `npm install` step. + +## Install + +From this directory: + +```bash +zot ext install . +``` + +This copies the manifest + script into `$ZOT_HOME/extensions/clock/`. +zot picks it up the next time you launch the TUI. + +## Use + +In zot: + +- `/now` — extension pushes a styled note showing local + and ISO time (no model call) +- `/uptime` — extension asks the agent to comment on how + long the clock extension has been running +- `/uptime caching` — same, but the agent's comment is steered by + the trailing args + +## Why JavaScript and not TypeScript + +The file uses JSDoc types (`@typedef`, `@param`) so it type-checks +under `tsc --checkJs` without a build step. Authentic TypeScript +authoring works too — rename `index.js` → `index.ts`, install +[`tsx`](https://www.npmjs.com/package/tsx), and update +`extension.json`: + +```json +{ + "exec": "tsx", + "args": ["index.ts"] +} +``` + +zot doesn't care which one you use; it just spawns whatever `exec` +points at and reads/writes JSON lines on its stdio. + +## See also + +- `examples/extensions/hello` — Go version using the `pkg/zotext` SDK +- `docs/extensions.md` — full protocol reference diff --git a/examples/extensions/clock/extension.json b/examples/extensions/clock/extension.json new file mode 100644 index 0000000..df4f066 --- /dev/null +++ b/examples/extensions/clock/extension.json @@ -0,0 +1,9 @@ +{ + "name": "clock", + "version": "1.0.0", + "exec": "node", + "args": ["index.js"], + "language": "typescript", + "description": "registers /now and /uptime — a tiny time toolkit", + "enabled": true +} diff --git a/examples/extensions/clock/index.js b/examples/extensions/clock/index.js new file mode 100644 index 0000000..d410ff3 --- /dev/null +++ b/examples/extensions/clock/index.js @@ -0,0 +1,164 @@ +// clock — a zot extension written in plain Node (no dependencies). +// +// Registers two slash commands: +// /now — pushes the current local time into the chat as +// a one-shot note (no model call, no transcript) +// /uptime — submits a prompt asking the agent to comment on +// how long this extension has been running +// +// Why .js and not .ts: this file uses JSDoc types so it can be +// type-checked by tsc / tsserver without a build step. Renaming to +// .ts and updating extension.json's args to ["--import","tsx", +// "index.ts"] (with tsx installed) works too. The extension protocol +// itself is language-agnostic; what matters is that `exec` produces +// a process that reads JSON lines from stdin and writes them to +// stdout. +// +// Install: +// zot ext install /path/to/this/dir +// +// Then in zot: +// /now +// /uptime + +import { createInterface } from "node:readline"; +import { stdin, stdout, stderr } from "node:process"; + +const NAME = "clock"; +const VERSION = "1.0.0"; +const STARTED_AT = Date.now(); + +/** @typedef {{type: string, id?: string, [k: string]: unknown}} Frame */ + +/** + * Send a frame to zot. One JSON object per line; flush immediately + * so the host doesn't sit waiting on a buffer. + * @param {Frame} obj + */ +function send(obj) { + stdout.write(JSON.stringify(obj) + "\n"); +} + +/** + * stderr is captured by zot to $ZOT_HOME/logs/ext-clock.log; perfect + * for debug output. Anything written to stdout would corrupt the + * protocol stream. + * @param {string} msg + */ +function log(msg) { + stderr.write(`[${NAME}] ${msg}\n`); +} + +// 1. Hello first. +send({ + type: "hello", + name: NAME, + version: VERSION, + capabilities: ["commands"], +}); + +// 2. Register every command we can handle. +send({ + type: "register_command", + name: "now", + description: "show the current local time (no model call)", +}); +send({ + type: "register_command", + name: "uptime", + description: "ask the agent to riff on how long the clock ext has run", +}); + +// 3. Read frames until stdin closes (zot shuts us down). +const rl = createInterface({ input: stdin, crlfDelay: Infinity }); + +rl.on("line", (line) => { + /** @type {Frame} */ + let frame; + try { + frame = JSON.parse(line); + } catch (err) { + log(`malformed frame: ${err}`); + return; + } + + switch (frame.type) { + case "hello_ack": + log( + `connected to zot ${frame.zot_version} (${frame.provider}/${frame.model})`, + ); + break; + + case "command_invoked": + handleCommand(frame); + break; + + case "shutdown": + send({ type: "shutdown_ack" }); + rl.close(); + break; + + default: + log(`unknown frame type: ${frame.type}`); + } +}); + +rl.on("close", () => { + log("read loop closed; exiting"); + process.exit(0); +}); + +/** + * @param {Frame & {name?: string, args?: string}} frame + */ +function handleCommand(frame) { + const name = String(frame.name ?? ""); + const args = String(frame.args ?? "").trim(); + const id = String(frame.id ?? ""); + + switch (name) { + case "now": { + const now = new Date(); + const human = now.toLocaleString(undefined, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const iso = now.toISOString(); + send({ + type: "command_response", + id, + action: "display", + display: `local: ${human}\niso : ${iso}`, + }); + return; + } + + case "uptime": { + const ms = Date.now() - STARTED_AT; + const seconds = Math.round(ms / 1000); + const focus = args ? `Focus on the topic: ${args}.` : ""; + send({ + type: "command_response", + id, + action: "prompt", + prompt: + `The clock extension has been running for ${seconds}s in this zot session. ` + + `Riff on that briefly in one short sentence — be a little dramatic. ${focus}`.trim(), + }); + return; + } + + default: + send({ + type: "command_response", + id, + action: "noop", + error: `clock: unknown command /${name}`, + }); + } +} diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 9bfa214..a620774 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -1135,6 +1135,20 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { i.statusErr = "" i.mu.Unlock() default: + // Last-resort fallback: try the extension manager. Built-in + // cases above always win; this branch only fires for slash + // commands the extension manager registered. Same routing as + // the editor's submit-handler dispatch path so the autocomplete + // "enter on highlighted suggestion" flow also works. + extName := strings.TrimPrefix(parts[0], "/") + if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) { + rest := "" + if len(parts) > 1 { + rest = strings.Join(parts[1:], " ") + } + go i.invokeExtensionCommand(ctx, extName, rest) + return false + } i.mu.Lock() i.statusErr = "unknown command: " + parts[0] i.mu.Unlock()