zidane 7.0.14 → 7.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -9
- package/dist/{acp-CZpX91YE.js → acp-Bw2b_kka.js} +3 -3
- package/dist/{acp-CZpX91YE.js.map → acp-Bw2b_kka.js.map} +1 -1
- package/dist/acp-cli.js +4 -4
- package/dist/acp.d.ts +1 -1
- package/dist/acp.js +1 -1
- package/dist/{agent-B1jsFaDq.js → agent-B_uQo4f1.js} +649 -48
- package/dist/agent-B_uQo4f1.js.map +1 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/{anthropic-Da39cE-d.js → anthropic-QbHqqTkV.js} +2 -2
- package/dist/{anthropic-Da39cE-d.js.map → anthropic-QbHqqTkV.js.map} +1 -1
- package/dist/{approvals-S100xf1d.js → approvals-B9aIQJRQ.js} +287 -45
- package/dist/approvals-B9aIQJRQ.js.map +1 -0
- package/dist/artifacts-CV2hUKnL.js +112 -0
- package/dist/artifacts-CV2hUKnL.js.map +1 -0
- package/dist/{auth-DfEWnkC7.js → auth-plw6npda.js} +19 -7
- package/dist/auth-plw6npda.js.map +1 -0
- package/dist/chat/pure.d.ts +3 -3
- package/dist/chat/pure.js +1 -1
- package/dist/chat.d.ts +6 -6
- package/dist/chat.js +5 -5
- package/dist/contexts/daytona.d.ts +1 -1
- package/dist/contexts/e2b.d.ts +1 -1
- package/dist/daemon-cli.js +1 -1
- package/dist/eval.d.ts +1 -1
- package/dist/eval.js +1 -1
- package/dist/extensions.d.ts +2 -2
- package/dist/extensions.js +2 -2
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +2 -2
- package/dist/{index-DFGECPfm.d.ts → index-Dc_zYpEu.d.ts} +351 -2
- package/dist/index-Dc_zYpEu.d.ts.map +1 -0
- package/dist/{index-DP7xYXr9.d.ts → index-Df2CH6bn.d.ts} +92 -7
- package/dist/index-Df2CH6bn.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +7 -6
- package/dist/{ipc-BLLscoIl.js → ipc-T_pPnHkp.js} +2 -2
- package/dist/{ipc-BLLscoIl.js.map → ipc-T_pPnHkp.js.map} +1 -1
- package/dist/ipc.d.ts +54 -6
- package/dist/ipc.d.ts.map +1 -1
- package/dist/ipc.js +3 -3
- package/dist/mcp.d.ts +1 -1
- package/dist/{oauth-C4GvY_p1.js → oauth-EIBUuSDK.js} +2 -2
- package/dist/{oauth-C4GvY_p1.js.map → oauth-EIBUuSDK.js.map} +1 -1
- package/dist/output/stream-json.d.ts +1 -1
- package/dist/output/terminal.d.ts +1 -1
- package/dist/plugins.d.ts +1 -1
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +2 -2
- package/dist/providers/anthropic.d.ts +1 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/arcee.d.ts +1 -1
- package/dist/providers/baseten.d.ts +1 -1
- package/dist/providers/cerebras.d.ts +1 -1
- package/dist/providers/local.d.ts +1 -1
- package/dist/providers/openai-compat.d.ts +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openai.js +1 -1
- package/dist/providers/openrouter.d.ts +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers/xai.js +2 -2
- package/dist/{providers-BYgyPb15.js → providers-CoK1rZ1p.js} +2 -2
- package/dist/{providers-BYgyPb15.js.map → providers-CoK1rZ1p.js.map} +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +3 -3
- package/dist/restate.d.ts +1 -1
- package/dist/restate.js +1 -1
- package/dist/{server-C8gULJ0d.js → server-DXM8ZbGi.js} +64 -6
- package/dist/server-DXM8ZbGi.js.map +1 -0
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/session/sqlite.d.ts.map +1 -1
- package/dist/session/sqlite.js +117 -1
- package/dist/session/sqlite.js.map +1 -1
- package/dist/{session-K3icbPH9.js → session-DPZnncaQ.js} +60 -1
- package/dist/session-DPZnncaQ.js.map +1 -0
- package/dist/session.d.ts +2 -2
- package/dist/session.js +3 -2
- package/dist/skills.d.ts +1 -1
- package/dist/{tool-formatters-C-s_-u0v.d.ts → tool-formatters-BgdOYAy9.d.ts} +2 -2
- package/dist/{tool-formatters-C-s_-u0v.d.ts.map → tool-formatters-BgdOYAy9.d.ts.map} +1 -1
- package/dist/tools/fetch-url.d.ts +1 -1
- package/dist/tools/web-search.d.ts +1 -1
- package/dist/tools.d.ts +2 -2
- package/dist/tools.js +2 -2
- package/dist/{transcript-anchors-D1ToF-EW.d.ts → transcript-anchors-CtnwuQJM.d.ts} +3 -3
- package/dist/transcript-anchors-CtnwuQJM.d.ts.map +1 -0
- package/dist/tui.d.ts +3 -3
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +576 -102
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-CZLsNgw-.js → turn-operations-DkJ1R0p8.js} +4 -3
- package/dist/turn-operations-DkJ1R0p8.js.map +1 -0
- package/dist/{turn-operations-Bp5-BAki.d.ts → turn-operations-khAvr7KJ.d.ts} +5 -3
- package/dist/turn-operations-khAvr7KJ.d.ts.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/{xai-DILdFGzN.js → xai-CazjStOK.js} +3 -3
- package/dist/{xai-DILdFGzN.js.map → xai-CazjStOK.js.map} +1 -1
- package/docs/ACP.md +6 -0
- package/docs/ARCHITECTURE.md +179 -356
- package/docs/ARTIFACTS.md +130 -0
- package/docs/CHAT.md +426 -1312
- package/docs/EXECUTION_CONTEXT.md +46 -19
- package/docs/EXTENSIONS.md +234 -584
- package/docs/HEADLESS.md +5 -0
- package/docs/INTERACTIONS.md +21 -22
- package/docs/IPC.md +318 -335
- package/docs/PLUGINS.md +2 -0
- package/docs/README.md +57 -0
- package/docs/RESTATE.md +199 -491
- package/docs/RUN_IN_BACKGROUND.md +107 -558
- package/docs/SKILL.md +171 -238
- package/docs/TUI.md +213 -591
- package/package.json +1 -1
- package/dist/agent-B1jsFaDq.js.map +0 -1
- package/dist/approvals-S100xf1d.js.map +0 -1
- package/dist/auth-DfEWnkC7.js.map +0 -1
- package/dist/index-DFGECPfm.d.ts.map +0 -1
- package/dist/index-DP7xYXr9.d.ts.map +0 -1
- package/dist/server-C8gULJ0d.js.map +0 -1
- package/dist/session-K3icbPH9.js.map +0 -1
- package/dist/transcript-anchors-D1ToF-EW.d.ts.map +0 -1
- package/dist/turn-operations-Bp5-BAki.d.ts.map +0 -1
- package/dist/turn-operations-CZLsNgw-.js.map +0 -1
package/docs/EXTENSIONS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Zidane Extensions
|
|
2
2
|
|
|
3
|
-
User-authored TypeScript / JavaScript modules that plug into a running zidane host without touching the upstream codebase. Drop `index.ts` into `.zidane/extensions/<name>/`, ship a
|
|
3
|
+
User-authored TypeScript / JavaScript modules that plug into a running zidane host without touching the upstream codebase. Drop `index.ts` into `.zidane/extensions/<name>/`, ship a `setup(ctx)` function, and contribute tools, hooks, MCP servers, skills, themes, agent profiles, keybindings, runtime transforms, storage/session state, context-panel sections, and more.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
// .zidane/extensions/hello/index.ts
|
|
@@ -16,39 +16,34 @@ export default defineExtension({
|
|
|
16
16
|
})
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Restart the TUI
|
|
19
|
+
Restart the TUI. The extension shows up in `Settings → Extensions` for on/off toggling and its keybindings (if any) in `Settings → Keybindings`.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
The existing surfaces (`mcps.json`, `SKILL.md`, `keybindings.json`) cover static configuration. Extensions cover the parts you can only express as code: a custom tool, an observability hook, a streaming UI panel, a slash-command provider hitting an internal API. Anyone can vibe-code one and try it without forking zidane.
|
|
21
|
+
Contents: [Discovery](#discovery) · [defineExtension API](#the-defineextension-api) · [The ctx surface](#the-ctx-surface) · [How it boots](#how-it-boots) · [Enabling / disabling](#enabling--disabling) · [Installing from GitHub / npm](#installing-from-github--npm) · [Collisions](#collisions--precedence) · [Hooks: parent vs subagent](#hooks-parent-vs-subagent) · [Authoring patterns](#authoring-patterns) · [Settings UI](#settings-ui) · [Limitations](#limitations--gotchas-v1) · [SDK surface](#sdk-surface) · [Preset vs extensions](#preset-vs-extensions) · [File layout](#file-layout-reference)
|
|
24
22
|
|
|
25
23
|
## Discovery
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
First-found per `name` wins (same convention as skills + `mcps.json`):
|
|
28
26
|
|
|
29
27
|
1. `{project}/.agents/extensions/<name>/`
|
|
30
28
|
2. `{project}/.zidane/extensions/<name>/`
|
|
31
29
|
3. `~/.agents/extensions/<name>/`
|
|
32
30
|
4. `~/.<prefix>/extensions/<name>/` (e.g. `~/.zidane/extensions/<name>/`)
|
|
33
31
|
|
|
34
|
-
`{project}` is the git root walked up from `cwd` (or `cwd` itself
|
|
35
|
-
|
|
36
|
-
Bun loads `.ts` / `.tsx` natively — both under `bun run` and in `bun build --compile` binaries. No transpiler dependency.
|
|
32
|
+
`{project}` is the git root walked up from `cwd` (or `cwd` itself outside a repo). Each `<name>/` directory must contain one of (probed in order): `index.ts`, `index.tsx`, `index.mjs`, `index.js`. Bun loads `.ts`/`.tsx` natively, in `bun run` and in `bun build --compile` binaries.
|
|
37
33
|
|
|
38
|
-
Optional manifest metadata
|
|
34
|
+
Optional manifest metadata lives in `<name>/extension.json` or the `zidane` field of `<name>/package.json`:
|
|
39
35
|
|
|
40
36
|
```json
|
|
41
37
|
{
|
|
42
38
|
"apiVersion": 1,
|
|
43
39
|
"name": "hello",
|
|
44
40
|
"displayName": "Hello Tools",
|
|
45
|
-
"description": "Logs every tool call",
|
|
46
41
|
"capabilities": ["hooks", "storage"],
|
|
47
42
|
"permissions": ["storage:write"]
|
|
48
43
|
}
|
|
49
44
|
```
|
|
50
45
|
|
|
51
|
-
The loader rejects unsupported `apiVersion` values and
|
|
46
|
+
The loader rejects unsupported `apiVersion` values and manifest/module name mismatches, so the persisted extension identity stays stable.
|
|
52
47
|
|
|
53
48
|
## The `defineExtension` API
|
|
54
49
|
|
|
@@ -58,26 +53,25 @@ import type { ExtensionDefinition } from 'zidane'
|
|
|
58
53
|
interface ExtensionDefinition {
|
|
59
54
|
name: string // stable, kebab-case-ish
|
|
60
55
|
apiVersion?: 1 // omitted means v1
|
|
61
|
-
displayName?: string
|
|
56
|
+
displayName?: string
|
|
62
57
|
permissions?: readonly ExtensionPermission[] // descriptive, not a sandbox
|
|
63
58
|
capabilities?: readonly ExtensionCapability[] // shown before setup runs
|
|
64
|
-
version?: string
|
|
65
|
-
description?: string
|
|
59
|
+
version?: string
|
|
60
|
+
description?: string
|
|
66
61
|
setup: (ctx: ExtensionContext) => void | Promise<void>
|
|
67
62
|
teardown?: () => void | Promise<void> // reserved (no hot-reload yet)
|
|
68
63
|
}
|
|
69
64
|
```
|
|
70
65
|
|
|
71
|
-
`setup(ctx)` runs once per registry build — boot, `/reload`, or a cwd change.
|
|
66
|
+
`setup(ctx)` runs once per registry build — boot, `/reload`, or a cwd change. Registration calls after `setup` resolves are dropped with a `logger.warn` (runtime affordances like `ctx.tui.*` and `submitPrompt` stay live for the extension's whole lifetime). Async `setup` is awaited before the extension is "loaded"; extensions load in parallel, so one slow one only blocks itself.
|
|
72
67
|
|
|
73
|
-
Throws inside `setup` are caught and surfaced on the extension's row in `Settings → Extensions
|
|
68
|
+
Throws inside `setup` are caught and surfaced on the extension's row in `Settings → Extensions`. The rest of boot continues.
|
|
74
69
|
|
|
75
70
|
### Zero-import alternative
|
|
76
71
|
|
|
77
|
-
Compiled `zidane-tui` binaries don't expose their bundled `node_modules/zidane` to dynamic imports outside the bundle. For binaries, drop the `import`
|
|
72
|
+
Compiled `zidane-tui` binaries don't expose their bundled `node_modules/zidane` to dynamic imports outside the bundle. For binaries, drop the `import` — the loader accepts a plain object:
|
|
78
73
|
|
|
79
74
|
```ts
|
|
80
|
-
// .zidane/extensions/hello/index.ts — no runtime imports
|
|
81
75
|
export default {
|
|
82
76
|
name: 'hello',
|
|
83
77
|
setup(ctx) {
|
|
@@ -86,16 +80,14 @@ export default {
|
|
|
86
80
|
}
|
|
87
81
|
```
|
|
88
82
|
|
|
89
|
-
For type-checking
|
|
83
|
+
For type-checking `ctx`, use a **type-only** import (erased at runtime, no resolver needed):
|
|
90
84
|
|
|
91
85
|
```ts
|
|
92
86
|
import type { ExtensionContext } from 'zidane'
|
|
93
87
|
|
|
94
88
|
export default {
|
|
95
89
|
name: 'hello',
|
|
96
|
-
setup(ctx: ExtensionContext) {
|
|
97
|
-
ctx.registerHook('tool:after', e => ctx.logger.info('ran', e))
|
|
98
|
-
},
|
|
90
|
+
setup(ctx: ExtensionContext) { /* … */ },
|
|
99
91
|
}
|
|
100
92
|
```
|
|
101
93
|
|
|
@@ -111,66 +103,44 @@ Three namespaces:
|
|
|
111
103
|
|
|
112
104
|
### Renderer-agnostic
|
|
113
105
|
|
|
114
|
-
```ts
|
|
115
|
-
ctx.name // your extension name
|
|
116
|
-
ctx.version // optional
|
|
117
|
-
ctx.dir // absolute path to the extension folder (undefined for inline)
|
|
118
|
-
ctx.scope // 'project' | 'user' | 'inline' | 'builtin'
|
|
119
|
-
ctx.logger // Logger scoped to `extension:<name>`
|
|
120
|
-
ctx.paths // cwd/projectRoot/dataDir/cacheDir/configDir/stateDir
|
|
121
|
-
ctx.storage.path(scope, ...segments)
|
|
122
|
-
ctx.session.get(key) / set(key, value) / delete(key)
|
|
123
|
-
ctx.config.get(key)
|
|
124
|
-
|
|
125
|
-
ctx.registerTool(name, toolDef)
|
|
126
|
-
ctx.registerHook(event, handler)
|
|
127
|
-
ctx.registerMcpServer(serverConfig)
|
|
128
|
-
ctx.registerSkill(skillConfig)
|
|
129
|
-
ctx.registerCompletionProvider(provider)
|
|
130
|
-
ctx.registerInlineAutocompleteProvider(provider)
|
|
131
|
-
ctx.registerCommand(command)
|
|
132
|
-
ctx.registerTheme(theme)
|
|
133
|
-
ctx.registerAgent(profile)
|
|
134
|
-
ctx.registerProvider(descriptor)
|
|
135
|
-
ctx.registerSystemPromptFragment(text)
|
|
136
|
-
ctx.registerContextBreakdownSection(contributor)
|
|
137
|
-
ctx.registerContextPanelSection(section)
|
|
138
|
-
ctx.registerToolResultMetadata(registration)
|
|
139
|
-
ctx.registerRunTransform(transform)
|
|
140
|
-
ctx.registerBehaviorTransform(transform)
|
|
141
|
-
ctx.registerProviderMiddleware(middleware)
|
|
142
|
-
ctx.registerToolCatalogTransform(transform)
|
|
143
|
-
ctx.registerMcpConfigTransform(transform)
|
|
144
|
-
ctx.registerSkillPolicy(policy)
|
|
145
|
-
ctx.registerConfigCategory(category)
|
|
146
|
-
ctx.registerConfigItem(categoryId?, item)
|
|
147
|
-
```
|
|
148
|
-
|
|
149
106
|
| API | Use it for | Notes |
|
|
150
107
|
|---|---|---|
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
158
|
-
| `
|
|
159
|
-
| `
|
|
160
|
-
| `
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `
|
|
165
|
-
| `
|
|
166
|
-
| `
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
|
|
173
|
-
`
|
|
108
|
+
| `name` / `version` / `dir` / `scope` / `logger` / `paths` / `signal` | Identity, a `Logger` scoped `extension:<name>`, cwd/projectRoot/dataDir/cacheDir/configDir/stateDir, and an `AbortSignal` that flips when setup times out or the registry disposes | `dir` is `undefined` for inline extensions |
|
|
109
|
+
| `storage.path(scope, ...segments)` | A path under the host's data/cache/config/project root | Clamped to `<scope>/extensions/<name>/...`; `..` segments can't escape it |
|
|
110
|
+
| `session.get(key)` / `set(key, value)` / `delete(key)` | Extension-scoped, JSON-only, byte-bounded state on the active `Session` | Isolated per extension name; live during agent runs |
|
|
111
|
+
| `config.get(key)` | Read this extension's own config values, default-first | Safe during `setup` (returns defaults) and in hooks/tools (live values) |
|
|
112
|
+
| `disabled` | `true` when this build only declares config schema for a disabled extension | Gate side-effecting setup work on `!ctx.disabled` |
|
|
113
|
+
| `registerTool(name, ToolDef)` | Custom agent tools | Profile tools win on name collision; built-in interaction tools (`ask_user`, `present_plan`) always win |
|
|
114
|
+
| `registerHook(event, handler)` | Observability / policy on `AgentHooks` | Fires on the **parent** agent only — subscribe to `child:tool:before` etc. for subagents |
|
|
115
|
+
| `registerMcpServer(McpServerConfig)` | Programmatic MCP server (no `mcps.json` edit) | Discovered `mcps.json` wins on `name` collision |
|
|
116
|
+
| `registerSkill(SkillConfig)` | Programmatic `defineSkill()` injection | Force-included in the user's `enabledSkills` allowlist |
|
|
117
|
+
| `loadAgentPlugin(dir \| options)` | Load an Agent Plugin (agent-plugins.org) — skills + MCP servers in one call | Must be awaited inside `setup`; malformed manifests come back as diagnostics on the result, never a thrown error |
|
|
118
|
+
| `registerCompletionProvider(provider)` | New `/` `@` `!` triggers in the prompt | One-character trigger; collides with built-ins (`/` `@` `#`) → ignored |
|
|
119
|
+
| `registerInlineAutocompleteProvider({ id, suggest })` | Ghost-text ahead of the cursor, no trigger | Gated by the user's `inlineAutocomplete` setting (off by default); first non-empty answer wins, 150 ms deadline |
|
|
120
|
+
| `registerCommand(Command)` | A `/<name>` workflow | Names dedupe first-registration-wins; see [Authoring patterns](#a-custom-slash-command) |
|
|
121
|
+
| `registerTheme(Theme)` | New `Settings → Theme` option | Built-in theme ids shadow extension ids on collision |
|
|
122
|
+
| `registerAgent(AgentProfile)` | A complete agent profile in the `shift+tab` cycle | Built-in ids (`build`, `plan`) win on collision |
|
|
123
|
+
| `registerSubagent(ExtensionSubagentDef)` | A named overlay the model selects via `spawn`'s `subagent_type` | Adds beat the host base on name collision |
|
|
124
|
+
| `registerSubagentOverride(name, fn)` | Edit an existing subagent (host- or extension-provided) by name | Applied after adds, so overrides win over both; `fn` returning `undefined` deletes the entry |
|
|
125
|
+
| `registerProvider(ProviderDescriptor)` | New LLM provider in the auth + model pickers | Host-supplied providers win on `key` collision; a `models` list REPLACES the built-in registry for that provider |
|
|
126
|
+
| `registerSystemPromptFragment(text)` | Append a paragraph to every system prompt | Joined with `\n\n`, re-attached on every agent rebuild |
|
|
127
|
+
| `registerContextBreakdownSection(fn)` | Existing low-level context panel contributor | May return one section or an array; async is supported |
|
|
128
|
+
| `registerContextPanelSection(section)` | Richer context panel section (`id`, `title`, rows, optional async `load`) | Normalized into the same model as above |
|
|
129
|
+
| `registerToolResultMetadata(registration)` | Interpret namespaced metadata attached to tool results | Can project entries into context-panel rows |
|
|
130
|
+
| `registerArtifactKind({ kind, label, detect?, validateMeta?, render?, resolve? })` | Give meaning to a session artifact kind | Global namespace, first registration wins. See [ARTIFACTS.md](./ARTIFACTS.md) |
|
|
131
|
+
| `artifacts.list(query)` / `artifacts.find(kind, uri)` | Read the artifact store | Late-bound like `sessions`: `[]` when detached. See [ARTIFACTS.md](./ARTIFACTS.md) |
|
|
132
|
+
| `registerRunTransform(fn)` | Rewrite `agent.run()` options before precondition checks | Synchronous, applied in extension order; throws logged under `ZIDANE_DEBUG` and ignored |
|
|
133
|
+
| `registerBehaviorTransform(fn)` | Rewrite resolved behavior (`maxTurns`, retry, budgets) | Run-level `behavior` options re-applied after, so explicit caller choices win |
|
|
134
|
+
| `registerProviderMiddleware(fn)` | Wrap provider instances (tracing, fallback, metrics, policy) | Runs per provider-factory call; `undefined` return = pass-through |
|
|
135
|
+
| `registerToolCatalogTransform(fn)` | Hide, rename, or replace tools before disclosure/execution | Runs for both context estimates and actual runs |
|
|
136
|
+
| `registerMcpConfigTransform(fn)` | Rewrite MCP server configs before connection | Runs after discovered/user configs are merged |
|
|
137
|
+
| `registerSkillPolicy(policy)` | Auto-activate a skill based on run context | `policy.when` may be async; failures are fail-open |
|
|
138
|
+
| `registerToolPolicy({ tools?, decide })` | Gate tool calls pre-execution: allow / `{ block: reason }` / `{ patch: input }` | Patches are re-validated against the tool's schema; earlier verdicts (built-in guards, approval flow) win |
|
|
139
|
+
| `registerToolResultTransform(fn)` | Chained middleware over finished tool results | Rides `tool:transform`; byte budgets still apply after |
|
|
140
|
+
| `registerContextTransform(fn)` | Rewrite wire messages per provider call | Sugar over `registerProviderMiddleware` |
|
|
141
|
+
| `registerPromptTransform(fn)` | Transform or consume a submitted prompt | Runs after `/` command interception, before skill-reference resolution |
|
|
142
|
+
| `registerConfigCategory(category)` / `registerConfigItem(categoryId?, item)` | Typed settings in `Settings → Extensions` | See [Settings UI](#settings-ui) |
|
|
143
|
+
| `bus?.emit(payload, opts)` / `bus?.onMessage(fn)` | Private cross-window message channel | Present only when the definition declares the `'ipc'` permission; payloads are JSON ≤ 64 KiB |
|
|
174
144
|
|
|
175
145
|
### TUI-specific
|
|
176
146
|
|
|
@@ -178,33 +148,34 @@ ctx.registerConfigItem(categoryId?, item)
|
|
|
178
148
|
ctx.tui.registerKeybinding({
|
|
179
149
|
id: 'my-ext.openPanel', // globally unique, namespace by extension
|
|
180
150
|
default: 'ctrl+shift+j', // empty string = unbound by default
|
|
181
|
-
label: 'Jokes',
|
|
151
|
+
label: 'Jokes',
|
|
182
152
|
description: 'Open the joke panel',
|
|
183
153
|
onTrigger: dispatch => dispatch.openModal('jokes'),
|
|
184
154
|
})
|
|
185
155
|
|
|
186
156
|
ctx.tui.registerFooterHint({ key: '^J', label: 'jokes' })
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
157
|
+
ctx.tui.registerModal({ id: 'jokes', node: <JokesPanel /> })
|
|
158
|
+
|
|
159
|
+
// HUD — fire-and-forget, callable for the extension's whole lifetime.
|
|
160
|
+
// No-ops on hosts without a UI.
|
|
161
|
+
ctx.tui.notify('Indexed 42 files', { tone: 'info' }) // one-shot transcript line
|
|
162
|
+
ctx.tui.setStatus('indexing… 42/117', { loader: true }) // dim line at the transcript tail
|
|
163
|
+
ctx.tui.setStatus('✓ indexed', { timeout: 3000 }) // transient — auto-clears
|
|
164
|
+
ctx.tui.setBanner(['review pending: 3 files'], { timeout: 10_000 }) // block above the prompt
|
|
165
|
+
ctx.tui.setStatus(null) // clear (also wiped on disposal)
|
|
166
|
+
|
|
167
|
+
// Artifact detail pane in the /artifacts modal (row rendering is on the
|
|
168
|
+
// renderer-agnostic registerArtifactKind({ render })):
|
|
169
|
+
ctx.tui.registerArtifactRenderer({
|
|
170
|
+
kind: 'my-ext.deployment',
|
|
171
|
+
renderDetail: artifact => <DeploymentStatus url={artifact.uri} meta={artifact.meta} />,
|
|
191
172
|
})
|
|
192
173
|
|
|
193
|
-
// HUD — fire-and-forget, callable for the extension's whole lifetime
|
|
194
|
-
// (setup, hooks, commands, timers). No-ops on hosts without a UI.
|
|
195
|
-
ctx.tui.notify('Indexed 42 files', { tone: 'info' }) // one-shot transcript line
|
|
196
|
-
ctx.tui.setStatus('indexing… 42/117', { loader: true }) // single dim line at the transcript tail, with working glyph
|
|
197
|
-
ctx.tui.setStatus('✓ indexed', { timeout: 3000 }) // transient — auto-clears after 3s
|
|
198
|
-
ctx.tui.setBanner(['review pending: 3 files'], { timeout: 10_000 }) // block above the prompt, next to the todo indicator
|
|
199
|
-
ctx.tui.setStatus(null) // clear (also wiped automatically on disposal)
|
|
200
|
-
|
|
201
174
|
// Custom transcript rendering — view-layer only, fallback-mandatory:
|
|
202
175
|
ctx.tui.registerToolRenderer({
|
|
203
176
|
tools: ['shell'], // omit = offer for every tool
|
|
204
177
|
render: ({ text }) =>
|
|
205
|
-
looksLikeTestOutput(text)
|
|
206
|
-
? <TestSummaryTable output={text} /> // replaces the default ┃ block
|
|
207
|
-
: null, // null = fall through to default
|
|
178
|
+
looksLikeTestOutput(text) ? <TestSummaryTable output={text} /> : null, // null = default view
|
|
208
179
|
})
|
|
209
180
|
|
|
210
181
|
ctx.tui.registerRenderer('commit-graph', data => // auto-namespaced: '<ext>/commit-graph'
|
|
@@ -217,54 +188,11 @@ ctx.appendNote([{
|
|
|
217
188
|
// command emit() takes the same block shape (full '<ext>/<id>' required there)
|
|
218
189
|
```
|
|
219
190
|
|
|
220
|
-
**
|
|
221
|
-
ever persisted: tool renderers are views over the tool turns you already
|
|
222
|
-
have (zero schema change), and `custom` blocks persist `{ renderer-id,
|
|
223
|
-
JSON-safe data, fallback markdown }` — the fallback IS the persisted output,
|
|
224
|
-
so a pre-custom host (or an old zidane) renders these turns natively.
|
|
225
|
-
Resolution happens at render time against the live filtered registry:
|
|
226
|
-
disable the extension in Settings and rows fall back instantly; `/reload`
|
|
227
|
-
re-skins old rows with new renderer code; a throwing renderer is
|
|
228
|
-
error-bounded per row (fallback + dim notice — the transcript survives).
|
|
229
|
-
V1 boundary: components are display-only (no focus/keyboard inside
|
|
230
|
-
transcript rows) and TUI-scoped like modals — the fallback is the
|
|
231
|
-
headless/GUI story. Tool renderers re-skin result rows, they don't
|
|
232
|
-
resurrect them: `showToolResults: false` hides custom views too — output
|
|
233
|
-
that must stay visible unconditionally belongs in a `custom` block.
|
|
234
|
-
|
|
235
|
-
**Async data (fetch in a renderer):** `render` itself is synchronous by
|
|
236
|
-
design — but the node it returns mounts as a real React subtree, so async
|
|
237
|
-
work happens the standard React way, inside your component:
|
|
238
|
-
|
|
239
|
-
```tsx
|
|
240
|
-
function PrStatus({ pr }: { pr: number }) {
|
|
241
|
-
const [state, setState] = useState<'loading' | { title: string }>('loading')
|
|
242
|
-
useEffect(() => {
|
|
243
|
-
let cancelled = false
|
|
244
|
-
fetch(`https://api.github.com/repos/o/r/pulls/${pr}`)
|
|
245
|
-
.then(r => r.json())
|
|
246
|
-
.then((data) => {
|
|
247
|
-
if (!cancelled)
|
|
248
|
-
setState({ title: data.title })
|
|
249
|
-
})
|
|
250
|
-
.catch(() => {}) // keep the loading state; the row must never throw upward
|
|
251
|
-
return () => {
|
|
252
|
-
cancelled = true
|
|
253
|
-
}
|
|
254
|
-
}, [pr])
|
|
255
|
-
return <text>{state === 'loading' ? '⏳ loading…' : `#${pr} ${state.title}`}</text>
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
ctx.tui.registerRenderer('pr-status', data => <PrStatus pr={(data as { pr: number }).pr} />)
|
|
259
|
-
```
|
|
191
|
+
**Rendering invariant — data stays data.** Nothing a renderer paints is persisted: tool renderers are views over tool turns you already have; `custom` blocks persist `{ renderer-id, JSON-safe data, fallback markdown }` — the fallback IS the persisted output, so a pre-custom host renders natively. Disabling the extension falls rows back instantly; `/reload` re-skins them; a throwing renderer is error-bounded per row. Components are display-only (no focus/keyboard inside transcript rows); `showToolResults: false` hides custom views too.
|
|
260
192
|
|
|
261
|
-
|
|
262
|
-
an async `render` signature would re-implement all of that (per-row promise
|
|
263
|
-
caches, race handling) for no added capability, so it's deliberately not
|
|
264
|
-
offered.
|
|
193
|
+
**Async data:** `render` is synchronous, but the node it returns mounts as a real React subtree — `fetch`/`useEffect` inside your component like any React code.
|
|
265
194
|
|
|
266
|
-
**Caching + viewport gating
|
|
267
|
-
extensions thanks to host-module mapping):
|
|
195
|
+
**Caching + viewport gating** (`useRowVisible`, `useSessionState` from `zidane/tui`):
|
|
268
196
|
|
|
269
197
|
```tsx
|
|
270
198
|
import { useRowVisible, useSessionState } from 'zidane/tui'
|
|
@@ -273,112 +201,58 @@ function PrStatus({ pr }: { pr: number }) {
|
|
|
273
201
|
const visible = useRowVisible() // false until scrolled in (latched)
|
|
274
202
|
const [data, setData] = useSessionState<Pr | null>('pr', null)
|
|
275
203
|
useEffect(() => {
|
|
276
|
-
if (!visible || data) return
|
|
277
|
-
fetchPr(pr).then(setData)
|
|
204
|
+
if (!visible || data) return
|
|
205
|
+
fetchPr(pr).then(setData)
|
|
278
206
|
}, [visible, data, pr])
|
|
279
207
|
return <text>{data ? data.title : visible ? '⏳' : '·'}</text>
|
|
280
208
|
}
|
|
281
209
|
```
|
|
282
210
|
|
|
283
|
-
- `useSessionState(id, initial)` — durable per-ROW state, keyed
|
|
284
|
-
|
|
285
|
-
(your own namespace — inspect or clear it via `ctx.session`). Reload the
|
|
286
|
-
session and the row rehydrates instead of refetching. Values are
|
|
287
|
-
JSON-safe by type; live rows share the persisted turn's id, so keys agree
|
|
288
|
-
live and after reload.
|
|
289
|
-
- `useRowVisible(margin?)` — `true` once the row has entered the viewport
|
|
290
|
-
(±margin rows of warm-up), LATCHED — mechanically, not just semantically:
|
|
291
|
-
once visible, the row UNSUBSCRIBES from the tracker, so scrolling it out
|
|
292
|
-
and back in causes zero re-renders and zero effect re-runs (test-pinned).
|
|
293
|
-
Re-run-on-revisit belongs to your own state keys. Reports `true`
|
|
294
|
-
immediately when no visibility tracker exists (headless-ish hosts) — work
|
|
295
|
-
is never gated behind a tracker that isn't there. One shared ~200ms poll
|
|
296
|
-
for the whole transcript, no per-row timers, and scrolling doesn't
|
|
297
|
-
re-render the transcript either (subscription store, not React state).
|
|
211
|
+
- `useSessionState(id, initial)` — durable per-ROW state, keyed `renderer:<localId>:<turnId>:<id>` in the session's extension-state bag. Reload and the row rehydrates instead of refetching.
|
|
212
|
+
- `useRowVisible(margin?)` — `true` once the row enters the viewport, LATCHED: once visible it unsubscribes from the tracker, so scrolling out and back causes zero re-renders. `true` immediately on hosts without a visibility tracker.
|
|
298
213
|
- Both throw outside an extension-rendered row.
|
|
299
214
|
|
|
300
|
-
**Module identity —
|
|
301
|
-
`react/jsx-runtime`, and the `@opentui/*` packages to its OWN live module
|
|
302
|
-
instances before any extension loads (a Bun virtual-module plugin — see
|
|
303
|
-
`extensions/host-modules.ts`), so `import { useState } from 'react'` in an
|
|
304
|
-
extension always gets the reconciler's copy. Without this, bare imports
|
|
305
|
-
resolve from the extension's directory — landing on nothing, or on a second
|
|
306
|
-
react whose hooks throw "Invalid hook call". Don't bundle or depend on your
|
|
307
|
-
own `react`/`@opentui/*`; import them bare and let the host supply them.
|
|
308
|
-
|
|
309
|
-
The loader itself maps `zidane` and `zidane/providers` the same way (renderer-neutral,
|
|
310
|
-
so headless / ACP hosts get it too) — `import { anthropic, registerCustomOAuthProvider }
|
|
311
|
-
from 'zidane'` in an extension resolves to the host's LIVE modules, which matters for
|
|
312
|
-
anything stateful: the provider factories, the credential store, and pi-ai's OAuth
|
|
313
|
-
registry must be the host's copies, not a second instance loaded from your directory.
|
|
215
|
+
**Module identity.** The TUI maps `react`, `react/jsx-runtime`, and `@opentui/*` to its own live module instances before any extension loads (a Bun virtual-module plugin, see `src/extensions/host-modules.ts`), so `import { useState } from 'react'` gets the reconciler's copy — don't bundle your own. The loader maps `zidane` / `zidane/providers` the same way (renderer-neutral, headless/ACP hosts included), which matters for stateful singletons: provider factories, the credential store, the OAuth registry.
|
|
314
216
|
|
|
315
217
|
```tsx
|
|
316
|
-
|
|
317
218
|
// Prompt + interception surfaces
|
|
318
219
|
ctx.submitPrompt('run the tests', { delivery: 'followUp' }) // as-if-typed; 'auto' | 'steer' | 'followUp'
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
: 'allow',
|
|
324
|
-
})
|
|
325
|
-
ctx.registerToolResultTransform(({ tool, result }) => // chained result middleware
|
|
326
|
-
tool === 'shell' ? { result: redact(result) } : undefined)
|
|
327
|
-
ctx.registerContextTransform(messages => // rewrite wire messages per provider call
|
|
328
|
-
messages.filter(keep))
|
|
329
|
-
ctx.registerPromptTransform(prompt => // transform/consume submitted prompts
|
|
330
|
-
prompt.startsWith('!q ') ? `Be brief: ${prompt.slice(3)}` : undefined)
|
|
331
|
-
// Not a parallel pipeline: `registerToolPolicy` registers into the SAME
|
|
332
|
-
// `tool:gate` hook bucket as the agent's built-in guards, and
|
|
333
|
-
// `registerToolResultTransform` rides `tool:transform` — one mechanism,
|
|
334
|
-
// one composition order. These are ergonomic (typed verdicts, schema
|
|
335
|
-
// re-validation) layers over hooks, not a second interception system.
|
|
220
|
+
|
|
221
|
+
// Not a parallel pipeline: registerToolPolicy rides the SAME `tool:gate`
|
|
222
|
+
// hook bucket as the agent's built-in guards, and registerToolResultTransform
|
|
223
|
+
// rides `tool:transform` — one mechanism, one composition order.
|
|
336
224
|
|
|
337
225
|
// Late-bound host controls (null/false/empty until the host attaches)
|
|
338
226
|
ctx.runtime.model() // active model id
|
|
339
|
-
ctx.runtime.models() // { provider: { key, name }, model: ModelInfo }[]
|
|
227
|
+
ctx.runtime.models() // { provider: { key, name }, model: ModelInfo }[]
|
|
340
228
|
ctx.runtime.setThinking('high')
|
|
341
229
|
await ctx.sessions.list() // { id, title?, updatedAt? }[]
|
|
342
230
|
await ctx.sessions.switch(id) // or ctx.sessions.create()
|
|
343
231
|
ctx.appendNote([{ text: '### Indexed\n42 files' }]) // durable display-only transcript note
|
|
344
232
|
|
|
345
233
|
// Shell + files through the SESSION'S execution context — work happens
|
|
346
|
-
// wherever the agent's tools run (host, docker, remote sandbox)
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// sandboxed contexts — ctx.workspace is the sandbox-correct way.
|
|
234
|
+
// wherever the agent's tools run (host, docker, remote sandbox). Hand-rolled
|
|
235
|
+
// child_process always runs on the HOST and silently targets the wrong
|
|
236
|
+
// filesystem under sandboxed contexts.
|
|
350
237
|
const { stdout, exitCode } = await ctx.workspace.exec('git status --porcelain')
|
|
351
238
|
const pkg = await ctx.workspace.readFile('package.json')
|
|
352
239
|
await ctx.workspace.writeFile('.cache/report.md', report)
|
|
353
240
|
const size = await ctx.workspace.fileSize('big.log') // probe before whole-reads
|
|
354
|
-
|
|
355
|
-
// (spawn failures reject with their real cause). The guard is
|
|
356
|
-
// side-effect-free — it never spawns a container just to answer:
|
|
357
|
-
if (await ctx.workspace.available()) { /* … */ }
|
|
358
|
-
// Calling before the session's first run spawns the execution handle on
|
|
359
|
-
// demand — same lifecycle seam (and `execution:ready` hook) as agent.run().
|
|
241
|
+
if (await ctx.workspace.available()) { /* … */ } // detached ⇒ calls above REJECT, not no-op
|
|
360
242
|
```
|
|
361
243
|
|
|
362
|
-
`/reload` (built-in command) hot-reloads the registry: re-discover, re-import
|
|
363
|
-
each extension's module graph fresh (on-disk edits apply live), re-run `setup`,
|
|
364
|
-
swap — the outgoing instance's teardowns run and its HUD paint is wiped
|
|
365
|
-
(generation-guarded, so the fresh instance's paint survives the swap ordering).
|
|
366
|
-
Dispose also retires the instance's HUD writer: late status/banner writes from
|
|
367
|
-
teardown closures or leaked timers are inert (`notify` stays live for farewell
|
|
368
|
-
lines). Toggling an extension off in Settings hides its paint and mutes its
|
|
369
|
-
notifications immediately; toggling back on restores them.
|
|
244
|
+
`/reload` (built-in command) hot-reloads the registry: re-discover, re-import each extension's module graph fresh, re-run `setup`, swap — outgoing teardowns run and the outgoing HUD paint is wiped. Toggling an extension off in Settings hides its paint and mutes notifications immediately; toggling back on restores them.
|
|
370
245
|
|
|
371
|
-
`onTrigger` receives
|
|
246
|
+
`onTrigger` receives `{ extensionName, openModal(id), closeModal() }`. Heavy work should hop to its own microtask; the dispatcher is synchronous.
|
|
372
247
|
|
|
373
|
-
The dispatcher iterates **built-ins first, then extensions** —
|
|
248
|
+
The dispatcher iterates **built-ins first, then extensions** — an extension declaring `default: 'ctrl+o'` collides with `openSettings` and never fires (logged at detect time). The framework **reserves `escape` and `ctrl+c`** — extensions can't bind those chords (registration dropped with a warning).
|
|
374
249
|
|
|
375
|
-
Users
|
|
250
|
+
Users rebind any extension keybinding from `~/.zidane/keybindings.json` by its `id`:
|
|
376
251
|
|
|
377
252
|
```jsonc
|
|
378
|
-
// ~/.zidane/keybindings.json
|
|
379
253
|
{
|
|
380
254
|
"openSettings": "ctrl+o",
|
|
381
|
-
"my-ext.openPanel": "ctrl+shift+j"
|
|
255
|
+
"my-ext.openPanel": "ctrl+shift+j"
|
|
382
256
|
}
|
|
383
257
|
```
|
|
384
258
|
|
|
@@ -390,7 +264,7 @@ Extension bindings appear in `Settings → Keybindings` under a trailing **Exten
|
|
|
390
264
|
ctx.gui.registerPanel({ id, label, render })
|
|
391
265
|
```
|
|
392
266
|
|
|
393
|
-
|
|
267
|
+
Calls land in the registry, but GUI panels aren't rendered yet. GUI parity wired today: extension discovery/settings, extension summaries, completion providers, and completion submit hooks (`resolveReferences` / `onSubmit`) through IPC.
|
|
394
268
|
|
|
395
269
|
## How it boots
|
|
396
270
|
|
|
@@ -412,9 +286,9 @@ flowchart TD
|
|
|
412
286
|
|
|
413
287
|
Key invariants:
|
|
414
288
|
|
|
415
|
-
1. **State first.** `runTui` reads `state.json` before discovery so the registry knows the enable allowlist as it builds. The allowlist gates **contributions, not execution**: a disabled-but-trusted extension is still imported and its `setup()` still runs — but only to declare its config schema
|
|
289
|
+
1. **State first.** `runTui` reads `state.json` before discovery so the registry knows the enable allowlist as it builds. The allowlist gates **contributions, not execution**: a disabled-but-trusted extension is still imported and its `setup()` still runs — but only to declare its config schema. It runs with `ctx.disabled === true` and the runtime bridges (`tui.*`, `submitPrompt`, `runtime`, `sessions`, `workspace`) withheld as inert no-ops. Gate side-effecting setup work on `!ctx.disabled`.
|
|
416
290
|
2. **Setup-then-merge.** Each extension's contributions are captured per-extension on `LoadedExtension.contributions`. The host can re-merge any subset via `mergeContributions(active, inactive)` to flip toggles without re-running `setup`.
|
|
417
|
-
3. **Sync `resolveConfig`.** Setup can be async; `resolveConfig` can't. The host pre-builds the registry, then passes it via `ChatOptions.extensionRegistry
|
|
291
|
+
3. **Sync `resolveConfig`.** Setup can be async; `resolveConfig` can't. The host pre-builds the registry, then passes it via `ChatOptions.extensionRegistry`:
|
|
418
292
|
|
|
419
293
|
```ts
|
|
420
294
|
import {
|
|
@@ -431,24 +305,19 @@ Key invariants:
|
|
|
431
305
|
|
|
432
306
|
## Enabling / disabling
|
|
433
307
|
|
|
434
|
-
Each extension shows up in `Settings → Extensions` with a checkbox. Toggling writes
|
|
308
|
+
Each extension shows up in `Settings → Extensions` with a checkbox. Toggling writes `Settings.enabledExtensions` (semantics mirror `enabledSkills` / `enabledMcps`):
|
|
435
309
|
|
|
436
|
-
- `undefined` (fresh install) —
|
|
310
|
+
- `undefined` (fresh install) — every discovered extension runs.
|
|
437
311
|
- `[]` — none run.
|
|
438
312
|
- `[names]` — explicit allowlist.
|
|
439
313
|
|
|
440
|
-
Disabled extensions
|
|
441
|
-
- Still run `setup()` at boot with `ctx.disabled === true` — schema-declaration only. Runtime bridges are withheld (`ctx.tui.*` / `ctx.submitPrompt` / `ctx.runtime` are inert), so an inactive extension can't paint status, inject prompts, or touch the session. Author-level side effects must be gated on `!ctx.disabled`.
|
|
442
|
-
- Land on `registry.inactive` so the toggle UI still lists them, and expose their **config schema** (categories + items) so their settings render before you enable them.
|
|
443
|
-
- Don't contribute tools / hooks / mcps / skills / themes / agents / providers / keybindings / footer hints / modals.
|
|
314
|
+
Disabled extensions still run `setup()` at boot with `ctx.disabled === true` (schema-declaration only, runtime bridges inert), land on `registry.inactive`, and expose their config schema so settings render before you enable them. They don't contribute tools/hooks/mcps/skills/themes/agents/providers/keybindings/footer hints/modals.
|
|
444
315
|
|
|
445
|
-
**Toggle = live filter; `/reload` = full re-apply.** Toggling live-filters tools
|
|
316
|
+
**Toggle = live filter; `/reload` = full re-apply.** Toggling live-filters tools/hooks/mcpServers/skills/completion providers/keybindings/footer hints and hides HUD paint immediately — but `setup()` isn't re-run and agent profiles/providers/themes stay visible in their pickers until `/reload` (or restart) rebuilds the registry against the current allowlist. Picking an extension-contributed profile after toggling its extension off — without reloading — activates a half-functional state: the profile's preset works, but the extension's hooks/tools are suppressed.
|
|
446
317
|
|
|
447
318
|
## Installing from GitHub / npm
|
|
448
319
|
|
|
449
|
-
`Settings → Extensions → ctrl+g` fetches, validates, and installs an extension
|
|
450
|
-
into the roots discovery already scans — then hot-reloads the registry, so
|
|
451
|
-
it's live without a restart. Type a source into the inline input:
|
|
320
|
+
`Settings → Extensions → ctrl+g` fetches, validates, and installs an extension into the roots discovery already scans, then hot-reloads the registry — live, no restart. Type a source into the inline input:
|
|
452
321
|
|
|
453
322
|
```
|
|
454
323
|
owner/repo # GitHub, single-extension repo
|
|
@@ -458,41 +327,22 @@ owner/repo#main:packages/ext # search a subdirectory (monorepos); pasted tr
|
|
|
458
327
|
@scope/pkg@1.2.0 # npm (exact versions or dist-tags; ranges are not resolved)
|
|
459
328
|
```
|
|
460
329
|
|
|
461
|
-
…then pick the scope with ←/→: **global** (`~/.zidane/extensions
|
|
462
|
-
trusted, the default) or **this project** (`<project>/.zidane/extensions/`,
|
|
463
|
-
which still requires project-extension trust to load — the panel warns when
|
|
464
|
-
the project isn't trusted). Progress streams into the panel's status line.
|
|
465
|
-
Bare `a/b` is GitHub (npm slash-names are always `@scope/…`), anything else
|
|
466
|
-
is npm.
|
|
330
|
+
…then pick the scope with ←/→: **global** (`~/.zidane/extensions/`, always trusted) or **this project** (`<project>/.zidane/extensions/`, requires project-extension trust to load). Bare `a/b` is GitHub (npm slash-names are always `@scope/…`); anything else is npm.
|
|
467
331
|
|
|
468
332
|
**Recognized package shapes** (same for both targets):
|
|
469
333
|
|
|
470
334
|
- a root `index.{ts,tsx,mjs,js}` — single-extension repo/package
|
|
471
|
-
- `extensions/<name>/index.*` directories — a collection; append the name to
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
the
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
touches your roots** — a failure installs nothing. This executes the
|
|
483
|
-
extension's top-level code once; your explicit install request is the consent.
|
|
484
|
-
- The target folder is named from the *validated* `definition.name`, so
|
|
485
|
-
discovery's folder-name invariant holds by construction.
|
|
486
|
-
- Dependencies install with the first of bun/npm/pnpm/yarn on PATH, **never
|
|
487
|
-
running lifecycle scripts** (bun's default; the others get `--ignore-scripts`).
|
|
488
|
-
- An existing install of the same name is refused unless you confirm the
|
|
489
|
-
overwrite. A `.install.json` provenance file (raw spec, resolved
|
|
490
|
-
version/ref, exact commit SHA for git installs, scope, selected candidate,
|
|
491
|
-
timestamp) is written — it's what powers updates.
|
|
492
|
-
|
|
493
|
-
**Bundled extensions** — this repo ships installable packages under
|
|
494
|
-
`extensions/`, which double as real-world examples of the pipeline (note the
|
|
495
|
-
subdir syntax — paste either source into the `ctrl+g` input):
|
|
335
|
+
- `extensions/<name>/index.*` directories — a collection; append the name to the source input
|
|
336
|
+
- for monorepos, `:path/to/dir` (GitHub only) re-roots the search
|
|
337
|
+
|
|
338
|
+
**Pipeline guarantees** (`installExtensionFromSpec` — the GUI calls the identical core):
|
|
339
|
+
|
|
340
|
+
- The module is imported and validated (definition shape, apiVersion, folder-safe name, manifest agreement) **in a staging dir before anything touches your roots** — a failure installs nothing. This runs the extension's top-level code once; your explicit install request is the consent.
|
|
341
|
+
- The target folder is named from the *validated* `definition.name`.
|
|
342
|
+
- Dependencies install with the first of bun/npm/pnpm/yarn on PATH, **never running lifecycle scripts**.
|
|
343
|
+
- An existing install of the same name is refused unless you confirm the overwrite. A `.install.json` provenance file (raw spec, resolved version/ref, commit SHA, scope, timestamp) is written — it powers updates.
|
|
344
|
+
|
|
345
|
+
**Bundled extensions** — this repo ships installable packages under `extensions/` as real-world examples (paste either source into the `ctrl+g` input):
|
|
496
346
|
|
|
497
347
|
```
|
|
498
348
|
Tahul/zidane:extensions/git-flow # /commit — agent-written commit messages + one-tap push
|
|
@@ -500,51 +350,31 @@ Tahul/zidane:extensions/headroom # compress large tool outputs via a local He
|
|
|
500
350
|
Tahul/zidane:extensions/pxpipe # image tool outputs in-process + optional pxpipe proxy routing
|
|
501
351
|
```
|
|
502
352
|
|
|
503
|
-
All mirror the packaging conventions to copy for
|
|
504
|
-
your own extensions (`package.json` with `zidane-extension` keyword, entry
|
|
505
|
-
`index.ts`, `zidane` as a peer).
|
|
506
|
-
|
|
507
|
-
**Update / remove** live on the focused row of the same tab:
|
|
508
|
-
|
|
509
|
-
- `ctrl+u` — update: first a cheap no-op probe — `git ls-remote` compares the
|
|
510
|
-
recorded commit SHA (npm: one registry GET re-resolves the version); an
|
|
511
|
-
unchanged remote reports "already up to date" without fetching or reloading
|
|
512
|
-
anything. (The probe compares the remote against the *recorded* install,
|
|
513
|
-
not your local files — to restore a hand-edited install, remove and
|
|
514
|
-
reinstall.) Otherwise it re-resolves the recorded spec (`owner/repo` follows
|
|
515
|
-
the branch, bare `pkg` follows `latest`; pinned refs/versions stay pinned)
|
|
516
|
-
and replaces the install **in place** (same root, even a
|
|
517
|
-
`.agents/extensions` one; an upstream rename removes the superseded
|
|
518
|
-
folder), reporting the commit delta (`abc1234 → def5678`). Hand-authored
|
|
519
|
-
extensions have no provenance — nothing to update from.
|
|
520
|
-
- `ctrl+x` (twice — arm, then confirm; cursor moves disarm) — remove: deletes
|
|
521
|
-
the extension's directory and drops it from the enable allowlist. Works on
|
|
522
|
-
hand-authored extensions too; a containment guard refuses any directory
|
|
523
|
-
that isn't a direct child of a recognized extensions root.
|
|
524
|
-
|
|
525
|
-
Install, update, and remove all finish with the `/reload` hot-swap, and all
|
|
526
|
-
are thin wrappers over `installExtensionFromSpec` /
|
|
527
|
-
`updateInstalledExtension` / `removeInstalledExtension` in the SDK (the GUI
|
|
528
|
-
calls the same functions).
|
|
353
|
+
All mirror the packaging conventions to copy for your own extensions (`package.json` with `zidane-extension` keyword, entry `index.ts`, `zidane` as a peer).
|
|
529
354
|
|
|
530
|
-
|
|
355
|
+
**Update / remove** on the focused row of the same tab:
|
|
356
|
+
|
|
357
|
+
- `ctrl+u` — update: a cheap no-op probe (`git ls-remote` / one npm registry GET) compares the recorded install; unchanged reports "already up to date". Otherwise re-resolves the recorded spec (`owner/repo` follows the branch, bare `pkg` follows `latest`; pinned refs/versions stay pinned) and replaces the install in place, reporting the commit delta. Hand-authored extensions have no provenance to update from.
|
|
358
|
+
- `ctrl+x` (twice — arm, then confirm) — remove: deletes the extension's directory and drops it from the enable allowlist. A containment guard refuses any directory that isn't a direct child of a recognized extensions root.
|
|
531
359
|
|
|
532
|
-
|
|
360
|
+
Install, update, and remove all finish with the `/reload` hot-swap.
|
|
361
|
+
|
|
362
|
+
## Collisions + precedence
|
|
533
363
|
|
|
534
364
|
| Surface | Collision rule |
|
|
535
365
|
|---|---|
|
|
536
366
|
| Tools | Profile tools win, then later extensions (load order). Interaction tools (`ask_user`, `present_plan`) always win. |
|
|
537
|
-
| Themes | Built-in theme ids win. Cross-extension
|
|
367
|
+
| Themes | Built-in theme ids win. Cross-extension: last write wins. |
|
|
538
368
|
| Agent profiles | Host-supplied profiles win on `id`. |
|
|
539
369
|
| Providers | Host-supplied providers (incl. `BUILTIN_PROVIDERS`) win on `key`. |
|
|
540
370
|
| MCP servers | Discovered `mcps.json` wins on `name`. |
|
|
541
371
|
| Keybindings | Built-in actions match first; collisions logged at boot. |
|
|
542
372
|
| Completion providers | First registered trigger wins per character. |
|
|
543
373
|
| Skills | Concatenated catalog; per-name first registration wins. |
|
|
544
|
-
| Runtime transforms / middleware | Applied in extension load order after registry merge; failures
|
|
545
|
-
| Storage/session keys | Namespaced by extension name; unsafe keys (`__proto__`, `prototype`, `constructor`)
|
|
374
|
+
| Runtime transforms / middleware | Applied in extension load order after registry merge; failures logged only under `ZIDANE_DEBUG`. |
|
|
375
|
+
| Storage/session keys | Namespaced by extension name; unsafe keys (`__proto__`, `prototype`, `constructor`) rejected or ignored. |
|
|
546
376
|
|
|
547
|
-
The mental model: **extensions extend, they don't shadow.** The few cases where extensions *could* shadow (other extensions' tools, themes, keybinding ids) resolve by load order with a warning
|
|
377
|
+
The mental model: **extensions extend, they don't shadow.** The few cases where extensions *could* shadow (other extensions' tools, themes, keybinding ids) resolve by load order with a warning.
|
|
548
378
|
|
|
549
379
|
## Hooks: parent vs subagent
|
|
550
380
|
|
|
@@ -553,8 +383,8 @@ The mental model: **extensions extend, they don't shadow.** The few cases where
|
|
|
553
383
|
To observe subagent activity, subscribe to the `child:*` mirror events (`child:tool:before`, `child:tool:after`, `child:stream:text`, …). They fire on the parent's hook bus with `{ childId, depth }` stamped on the context.
|
|
554
384
|
|
|
555
385
|
```ts
|
|
556
|
-
ctx.registerHook('tool:after',
|
|
557
|
-
ctx.registerHook('child:tool:after',
|
|
386
|
+
ctx.registerHook('tool:after', e => log('parent tool', e))
|
|
387
|
+
ctx.registerHook('child:tool:after', e => log('subagent tool', e, 'depth=', e.depth))
|
|
558
388
|
```
|
|
559
389
|
|
|
560
390
|
## Authoring patterns
|
|
@@ -571,10 +401,7 @@ export default defineExtension({
|
|
|
571
401
|
ctx.registerTool('jq_query', {
|
|
572
402
|
name: 'jq_query',
|
|
573
403
|
description: 'Run a jq filter against the given JSON.',
|
|
574
|
-
schema: z.object({
|
|
575
|
-
json: z.string(),
|
|
576
|
-
filter: z.string(),
|
|
577
|
-
}),
|
|
404
|
+
schema: z.object({ json: z.string(), filter: z.string() }),
|
|
578
405
|
async execute({ json, filter }) {
|
|
579
406
|
// ... shell out, call a JS jq, whatever
|
|
580
407
|
return JSON.stringify(result, null, 2)
|
|
@@ -587,28 +414,19 @@ export default defineExtension({
|
|
|
587
414
|
### A theme
|
|
588
415
|
|
|
589
416
|
```ts
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
label: 'Sunset',
|
|
598
|
-
colors: { brand: '#ff6b35', accent: '#f7c59f', /* … */ },
|
|
599
|
-
select: { /* … */ },
|
|
600
|
-
surfaces: { /* … */ },
|
|
601
|
-
syntax: { /* … */ },
|
|
602
|
-
})
|
|
603
|
-
},
|
|
417
|
+
ctx.registerTheme({
|
|
418
|
+
id: 'sunset',
|
|
419
|
+
label: 'Sunset',
|
|
420
|
+
colors: { brand: '#ff6b35', accent: '#f7c59f' /* … */ },
|
|
421
|
+
select: { /* … */ },
|
|
422
|
+
surfaces: { /* … */ },
|
|
423
|
+
syntax: { /* … */ },
|
|
604
424
|
})
|
|
605
425
|
```
|
|
606
426
|
|
|
607
|
-
After restart: `Settings → Theme → Sunset`.
|
|
608
|
-
|
|
609
427
|
### A complete agent profile
|
|
610
428
|
|
|
611
|
-
`registerAgent` adds a profile to the same registry
|
|
429
|
+
`registerAgent` adds a profile to the same registry Build/Plan ride. `shift+tab` (`cycleAgent`) cycles every registered profile in registration order.
|
|
612
430
|
|
|
613
431
|
```ts
|
|
614
432
|
import { defineExtension, definePreset } from 'zidane'
|
|
@@ -620,7 +438,7 @@ export default defineExtension({
|
|
|
620
438
|
ctx.registerAgent({
|
|
621
439
|
id: 'reviewer',
|
|
622
440
|
label: 'Reviewer',
|
|
623
|
-
description: 'Read-only code reviewer
|
|
441
|
+
description: 'Read-only code reviewer.',
|
|
624
442
|
accent: 'model',
|
|
625
443
|
preset: definePreset({
|
|
626
444
|
name: 'reviewer',
|
|
@@ -633,149 +451,96 @@ export default defineExtension({
|
|
|
633
451
|
})
|
|
634
452
|
```
|
|
635
453
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
Notes:
|
|
639
|
-
|
|
640
|
-
- Profile ids must be unique across the merged registry. Built-in ids (`build`, `plan`) shadow extension ids on collision — you can't override the built-ins from an extension.
|
|
641
|
-
- The `preset` field is a full `Preset` (same shape `composePresets` accepts): `tools`, `system`, `behavior`, `hooks`, `mcpServers`, `skills`. Anything you'd put in a host-side `agents` registry works here too.
|
|
642
|
-
- The picker modal (`AgentPickerModal`) is exported from `zidane/tui` for embedders who want an explicit picker UI — not wired by default, since the cycle is enough for most setups.
|
|
454
|
+
Profile ids must be unique; built-in ids (`build`, `plan`) shadow extension ids on collision. `preset` is a full `Preset` — `tools`, `system`, `behavior`, `hooks`, `mcpServers`, `skills`. The picker modal (`AgentPickerModal`) is exported from `zidane/tui` for embedders who want an explicit UI.
|
|
643
455
|
|
|
644
456
|
### An MCP server
|
|
645
457
|
|
|
646
458
|
```ts
|
|
647
|
-
|
|
648
|
-
name: 'linear
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
transport: 'stdio',
|
|
653
|
-
command: 'npx',
|
|
654
|
-
args: ['-y', 'linear-mcp'],
|
|
655
|
-
})
|
|
656
|
-
},
|
|
459
|
+
ctx.registerMcpServer({
|
|
460
|
+
name: 'linear',
|
|
461
|
+
transport: 'stdio',
|
|
462
|
+
command: 'npx',
|
|
463
|
+
args: ['-y', 'linear-mcp'],
|
|
657
464
|
})
|
|
658
465
|
```
|
|
659
466
|
|
|
660
|
-
Same effect as
|
|
661
|
-
|
|
662
|
-
### Observability + logging
|
|
663
|
-
|
|
664
|
-
```ts
|
|
665
|
-
export default defineExtension({
|
|
666
|
-
name: 'audit-log',
|
|
667
|
-
setup(ctx) {
|
|
668
|
-
const file = openAuditLog()
|
|
669
|
-
ctx.registerHook('tool:after', ({ name, callId }) => file.write(`${name}:${callId}\n`))
|
|
670
|
-
ctx.registerHook('child:tool:after', ({ name, callId, depth }) => file.write(`[d${depth}] ${name}:${callId}\n`))
|
|
671
|
-
ctx.registerHook('agent:done', ({ runId }) => file.flush())
|
|
672
|
-
},
|
|
673
|
-
})
|
|
674
|
-
```
|
|
467
|
+
Same effect as an `mcps.json` entry, but carried alongside the extension's docs/version/setup logic.
|
|
675
468
|
|
|
676
469
|
### A custom slash command
|
|
677
470
|
|
|
678
471
|
```ts
|
|
679
|
-
|
|
680
|
-
name: '
|
|
681
|
-
|
|
682
|
-
ctx.
|
|
683
|
-
|
|
684
|
-
trigger: '!',
|
|
685
|
-
label: 'Jokes',
|
|
686
|
-
async suggest(query) {
|
|
687
|
-
const items = await fetchJokes(query)
|
|
688
|
-
return items.map(j => ({
|
|
689
|
-
id: j.id,
|
|
690
|
-
label: j.title,
|
|
691
|
-
description: j.summary,
|
|
692
|
-
insertText: `!${j.title} `,
|
|
693
|
-
data: j,
|
|
694
|
-
}))
|
|
695
|
-
},
|
|
696
|
-
parseReferences(text) {
|
|
697
|
-
// walk text, find every "!title" occurrence, return spans
|
|
698
|
-
return []
|
|
699
|
-
},
|
|
700
|
-
})
|
|
472
|
+
ctx.registerCommand({
|
|
473
|
+
name: 'deploy',
|
|
474
|
+
run: async (c) => {
|
|
475
|
+
const rows = await ctx.artifacts.list({ scope: 'project', kind: 'coolify.deployment' })
|
|
476
|
+
c.emit({ text: rows.map(r => `- [${r.label}](${r.uri})`).join('\n') || '_none_' })
|
|
701
477
|
},
|
|
702
478
|
})
|
|
703
479
|
```
|
|
704
480
|
|
|
705
|
-
`
|
|
481
|
+
`emit` output is display-only (persisted, never sent to the model). A command may also call `ctx.runAgent` for interactive sub-runs, `ctx.generateData` for silent schema-pinned JSON, `ctx.spawnAgents?` for parallel isolated child agents, and `ctx.appendContext?` to persist a model-visible note — the optional surfaces are host-dependent, so guard for bare hosts.
|
|
482
|
+
|
|
483
|
+
### A completion provider
|
|
706
484
|
|
|
707
485
|
```ts
|
|
708
486
|
ctx.registerCompletionProvider({
|
|
709
|
-
id: '
|
|
710
|
-
trigger: '
|
|
711
|
-
label: '
|
|
712
|
-
async suggest(query
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
parseReferences(text) {
|
|
716
|
-
return findTicketRefs(text)
|
|
717
|
-
},
|
|
718
|
-
async resolveReferences(refs, _ctx, signal) {
|
|
719
|
-
return Promise.all(refs.map(ref => hydrateTicketRef(ref, { signal })))
|
|
720
|
-
},
|
|
721
|
-
async onSubmit({ references }) {
|
|
722
|
-
await rememberSubmittedTickets(references)
|
|
487
|
+
id: 'jokes',
|
|
488
|
+
trigger: '!',
|
|
489
|
+
label: 'Jokes',
|
|
490
|
+
async suggest(query) {
|
|
491
|
+
const items = await fetchJokes(query)
|
|
492
|
+
return items.map(j => ({ id: j.id, label: j.title, insertText: `!${j.title} `, data: j }))
|
|
723
493
|
},
|
|
494
|
+
parseReferences(text) { return [] }, // walk text, find every "!title" occurrence
|
|
724
495
|
})
|
|
725
496
|
```
|
|
726
497
|
|
|
498
|
+
`resolveReferences` and `onSubmit` are optional submit hooks the TUI/GUI call before dispatching a prompt.
|
|
499
|
+
|
|
727
500
|
### Runtime transforms + session state
|
|
728
501
|
|
|
729
502
|
```ts
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
ctx.registerProviderMiddleware((provider) => ({
|
|
742
|
-
...provider,
|
|
743
|
-
stream: async (options, callbacks) => {
|
|
744
|
-
await ctx.session.set('lastModel', options.model)
|
|
745
|
-
return provider.stream(options, callbacks)
|
|
746
|
-
},
|
|
747
|
-
}))
|
|
503
|
+
ctx.registerRunTransform(options => ({
|
|
504
|
+
...options,
|
|
505
|
+
behavior: { ...options.behavior, maxCostUsd: Math.min(options.behavior?.maxCostUsd ?? 5, 1) },
|
|
506
|
+
}))
|
|
507
|
+
|
|
508
|
+
ctx.registerProviderMiddleware(provider => ({
|
|
509
|
+
...provider,
|
|
510
|
+
stream: async (options, callbacks) => {
|
|
511
|
+
await ctx.session.set('lastModel', options.model)
|
|
512
|
+
return provider.stream(options, callbacks)
|
|
748
513
|
},
|
|
749
|
-
})
|
|
514
|
+
}))
|
|
750
515
|
```
|
|
751
516
|
|
|
752
|
-
Transforms are synchronous and fail-open
|
|
517
|
+
Transforms are synchronous and fail-open — use them for cheap composition decisions (budget caps, tool catalog shaping, provider wrappers). Put I/O in provider middleware, hooks, or tools.
|
|
518
|
+
|
|
519
|
+
### Session artifacts
|
|
753
520
|
|
|
754
|
-
|
|
521
|
+
An extension gives meaning to an artifact `kind` — a durable, typed pointer to something a session produced (PR, branch, commit, file, deployment). Full model, storage, and surfaces: [ARTIFACTS.md](./ARTIFACTS.md).
|
|
755
522
|
|
|
756
523
|
```ts
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
524
|
+
export default defineExtension({
|
|
525
|
+
name: 'coolify',
|
|
526
|
+
setup(ctx) {
|
|
527
|
+
ctx.registerArtifactKind({
|
|
528
|
+
kind: 'coolify.deployment', // namespace by extension; [\w.:-]+
|
|
529
|
+
label: 'Deployment',
|
|
530
|
+
detect: ({ tool, result, isError }) => {
|
|
531
|
+
if (isError || tool !== 'mcp_Coolify_deploy') return
|
|
532
|
+
const m = /deployment ([\w-]+)/.exec(String(result))
|
|
533
|
+
return m && { uri: `https://coolify.example/deployments/${m[1]}`, label: m[1], meta: { id: m[1], state: 'queued' } }
|
|
534
|
+
},
|
|
535
|
+
render: a => ({ title: a.label, href: a.uri, status: String((a.meta as { state?: string })?.state ?? '') }),
|
|
536
|
+
resolve: async a => ({ meta: { ...(a.meta as object), state: await fetchState(a) } }), // lazy, on view
|
|
537
|
+
})
|
|
765
538
|
|
|
766
|
-
ctx.
|
|
767
|
-
namespace: 'acme.trace',
|
|
768
|
-
contextRows({ entries }) {
|
|
769
|
-
return entries.map(({ value }) => ({
|
|
770
|
-
label: 'Trace',
|
|
771
|
-
value: String(value),
|
|
772
|
-
}))
|
|
539
|
+
ctx.tui.registerArtifactRenderer({ kind: 'coolify.deployment', renderDetail: a => <DeployLogs id={a.uri} /> })
|
|
773
540
|
},
|
|
774
541
|
})
|
|
775
542
|
```
|
|
776
543
|
|
|
777
|
-
Tool-result metadata is namespaced. The registry does not validate `schema` yet; it records the declaration so hosts and future tooling can project or validate metadata consistently.
|
|
778
|
-
|
|
779
544
|
### A keybinding + modal
|
|
780
545
|
|
|
781
546
|
```tsx
|
|
@@ -783,11 +548,7 @@ import { defineExtension } from 'zidane'
|
|
|
783
548
|
import { Modal } from 'zidane/tui'
|
|
784
549
|
|
|
785
550
|
function JokesPanel() {
|
|
786
|
-
return
|
|
787
|
-
<Modal title="jokes">
|
|
788
|
-
<text>haha</text>
|
|
789
|
-
</Modal>
|
|
790
|
-
)
|
|
551
|
+
return <Modal title="jokes"><text>haha</text></Modal>
|
|
791
552
|
}
|
|
792
553
|
|
|
793
554
|
export default defineExtension({
|
|
@@ -806,57 +567,21 @@ export default defineExtension({
|
|
|
806
567
|
})
|
|
807
568
|
```
|
|
808
569
|
|
|
809
|
-
`node` also accepts a **loader thunk** — `() => import('./panel')` — invoked only when the modal first opens
|
|
810
|
-
|
|
811
|
-
```ts
|
|
812
|
-
// index.ts — no UI imports, no JSX (keeps the entry renderer-neutral)
|
|
813
|
-
import { defineExtension } from 'zidane'
|
|
814
|
-
|
|
815
|
-
export default defineExtension({
|
|
816
|
-
name: 'jokes-ui',
|
|
817
|
-
setup(ctx) {
|
|
818
|
-
ctx.tui.registerModal({ id: 'jokes-panel', node: () => import('./panel') })
|
|
819
|
-
ctx.tui.registerKeybinding({
|
|
820
|
-
id: 'jokes-ui.open',
|
|
821
|
-
default: 'ctrl+shift+j',
|
|
822
|
-
label: 'Jokes',
|
|
823
|
-
description: 'Open the jokes panel',
|
|
824
|
-
onTrigger: dispatch => dispatch.openModal('jokes-panel'),
|
|
825
|
-
})
|
|
826
|
-
},
|
|
827
|
-
})
|
|
828
|
-
```
|
|
829
|
-
|
|
830
|
-
```tsx
|
|
831
|
-
// panel.tsx — UI + JSX live here, imported lazily on first open
|
|
832
|
-
import { Modal } from 'zidane/tui'
|
|
833
|
-
|
|
834
|
-
export default (
|
|
835
|
-
<Modal title="jokes">
|
|
836
|
-
<text>haha</text>
|
|
837
|
-
</Modal>
|
|
838
|
-
)
|
|
839
|
-
```
|
|
570
|
+
`node` also accepts a **loader thunk** — `() => import('./panel')` — invoked only when the modal first opens, keeping the panel's UI (and the `react/jsx-runtime` import JSX injects) off the import graph of headless hosts. See [Headless-safe UI](#headless-safe-ui).
|
|
840
571
|
|
|
841
572
|
Notes:
|
|
842
573
|
|
|
843
|
-
- **Wrap your panel in `<Modal>` from `zidane/tui`.** It centers the panel, paints the bordered chrome, and installs the `esc` → close handler.
|
|
844
|
-
- **Fullscreen panels**: pass `fullscreen` (plus `horizontalMargin={0} verticalMargin={0}`
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
<Modal title="dashboard" fullscreen horizontalMargin={0} verticalMargin={0}>
|
|
848
|
-
<scrollbox style={{ flexGrow: 1 }}>{/* … */}</scrollbox>
|
|
849
|
-
</Modal>
|
|
850
|
-
```
|
|
851
|
-
- Set `<Modal disableEscape>` if the panel needs to swallow `esc` itself (e.g. cancel a pending confirmation). The framework's baseline esc handler honors the opt-out automatically.
|
|
852
|
-
- TUI components need `@opentui/react` resolvable at runtime. For the bundled binary, ship the modal as a small renderless registration and rely on `ctx.tui.registerFooterHint` + `onTrigger` to call a shell command instead.
|
|
574
|
+
- **Wrap your panel in `<Modal>` from `zidane/tui`.** It centers the panel, paints the bordered chrome, and installs the `esc` → close handler.
|
|
575
|
+
- **Fullscreen panels**: pass `fullscreen` (plus `horizontalMargin={0} verticalMargin={0}`) and the panel fills the terminal and tracks resizes — the layout the Settings workbench uses. Sizing knobs (`maxWidth`/`minWidth`/`maxHeight`) are ignored under `fullscreen`.
|
|
576
|
+
- Set `<Modal disableEscape>` if the panel needs to swallow `esc` itself.
|
|
577
|
+
- TUI components need `@opentui/react` resolvable at runtime; for a bundled binary, prefer a renderless registration + `ctx.tui.registerFooterHint`/`onTrigger` calling a shell command.
|
|
853
578
|
|
|
854
579
|
## Settings UI
|
|
855
580
|
|
|
856
581
|
Open Settings (`ctrl+o`), navigate to the **Extensions** tab (alongside Skills + MCPs):
|
|
857
582
|
|
|
858
583
|
```
|
|
859
|
-
[ Agent ] [ UI ] [ Keybindings ] [ Authentication ] [ Skills ] [ MCP servers ] [ Extensions
|
|
584
|
+
[ Agent ] [ UI ] [ Keybindings ] [ Authentication ] [ Skills ] [ MCP servers ] [ Extensions 1 / 2 ]
|
|
860
585
|
|
|
861
586
|
search extensions — name, version, description…
|
|
862
587
|
|
|
@@ -864,25 +589,15 @@ Open Settings (`ctrl+o`), navigate to the **Extensions** tab (alongside Skills +
|
|
|
864
589
|
|
|
865
590
|
▶ [✓] hello v0.1.0
|
|
866
591
|
Logs every tool call · [project: ~/proj/.zidane/extensions/hello]
|
|
867
|
-
[✓] jq v0.2.0
|
|
868
|
-
Run jq filters · [user: ~/.zidane/extensions/jq]
|
|
869
592
|
[ ] notes
|
|
870
593
|
Personal note-taking integration · [user: ~/.zidane/extensions/notes]
|
|
871
594
|
|
|
872
595
|
←→ tabs · ↑↓ navigate · ↵ toggle · esc close
|
|
873
596
|
```
|
|
874
597
|
|
|
875
|
-
Failed extensions render at the top as a red preamble warning
|
|
598
|
+
Failed extensions render at the top as a red preamble warning. Disabled-no-error extensions stay in the toggle list. The `[source: path]` suffix shows where each extension lives on disk.
|
|
876
599
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
```
|
|
880
|
-
Extensions
|
|
881
|
-
ctrl+shift+j Jokes
|
|
882
|
-
Open the jokes panel
|
|
883
|
-
```
|
|
884
|
-
|
|
885
|
-
Config items can declare lifecycle metadata:
|
|
600
|
+
Config items declare lifecycle metadata:
|
|
886
601
|
|
|
887
602
|
```ts
|
|
888
603
|
ctx.registerConfigItem('limits', {
|
|
@@ -896,27 +611,21 @@ ctx.registerConfigItem('limits', {
|
|
|
896
611
|
})
|
|
897
612
|
```
|
|
898
613
|
|
|
899
|
-
`scope`
|
|
900
|
-
|
|
901
|
-
Item types: `boolean` (toggle), `number` (input with `min`/`max`/`step`), `string` (input, optional `pattern`/`multiline`), `enum` (cycling choice over static `options`), `model` — an inline searchable picker over the host's credentialed model catalog (the ctrl+m interaction: type to filter, `↵` picks; with `multi: true`, `↵` toggles and `esc` applies) — and `action` (imperative row, below). `model` persists a plain id — or a comma-separated id list when `multi` — and degrades to the free string input on hosts without a catalog, so read it with a tolerant parser and validate ids against `ctx.runtime.models()` yourself.
|
|
614
|
+
`scope` and `onChange` (`'live'` / `'agent-rebuild'` / `'restart'`) are advisory host metadata. Item types: `boolean`, `number` (`min`/`max`/`step`), `string` (`pattern`/`multiline`, or `secret: true` for a masked row + empty-on-open editor), `enum` (cycling over static `options`), `model` (searchable picker over the credentialed model catalog; `multi: true` toggles a comma-separated id list), and `action` (below).
|
|
902
615
|
|
|
903
616
|
### Config actions (`type: 'action'`)
|
|
904
617
|
|
|
905
|
-
|
|
618
|
+
Renders as a **press ↵ to run** row for imperative setup steps (start a service, log in, run a migration):
|
|
906
619
|
|
|
907
620
|
```ts
|
|
908
621
|
ctx.registerConfigItem('headroom', {
|
|
909
622
|
type: 'action',
|
|
910
623
|
key: 'runContainer',
|
|
911
624
|
label: 'Run container',
|
|
912
|
-
description: 'Start the Headroom proxy in Docker and set Base URL to it.',
|
|
913
625
|
run: async () => {
|
|
914
626
|
const baseUrl = await startMyService() // throw on failure — the message lands on the row
|
|
915
627
|
return { message: `proxy running at ${baseUrl}`, config: { baseUrl } }
|
|
916
628
|
},
|
|
917
|
-
// Optional passive probe — no ↵ needed: called when the settings panel
|
|
918
|
-
// surfaces the row (and re-called after any action settles), so the row
|
|
919
|
-
// shows live state. Return undefined to stay quiet (e.g. tool missing).
|
|
920
629
|
status: async () => {
|
|
921
630
|
const url = await findMyService()
|
|
922
631
|
return url ? { state: 'ok', message: `running at ${url}` } : { state: 'off', message: 'not running' }
|
|
@@ -924,55 +633,40 @@ ctx.registerConfigItem('headroom', {
|
|
|
924
633
|
})
|
|
925
634
|
```
|
|
926
635
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
- **
|
|
930
|
-
- **
|
|
931
|
-
-
|
|
932
|
-
- **No value** — action keys hold nothing: `ctx.config.get('runContainer')` is `undefined`, and action items are omitted from the GUI wire schema (`toWireConfigSchema`) because `run` can't cross IPC — today actions are TUI-only.
|
|
933
|
-
- **Status probe** — the optional `status` callback is passive decoration: it must be fast (the host caps each probe at ~3s and drops late results), must not mutate anything, and a throw is treated as "no status". `state` drives the row color: `ok` (accent), `off` (mute), `error` (error).
|
|
934
|
-
|
|
935
|
-
The bundled `headroom` extension uses this for a **Run container** / **Stop container** pair managing a `zidane-headroom` container (`ghcr.io/chopratejas/headroom`) on the host: run reuses/starts/creates the container published on an ephemeral loopback port and returns `config: { baseUrl }` so the proxy URL is re-synced on every run; its `status` probe shows `running at …` / `container stopped` when you open Settings; stop leaves `baseUrl` untouched so a later run picks up where you left off. Run also forwards any provider credentials present in the host environment (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, AWS / Google) into the container via `docker run -e KEY` (value read from the host env, never placed in argv), and warns in the result message when none are found — a proxy without credentials boots and answers `/stats` but fails open on compression.
|
|
636
|
+
- **Success** — resolve `{ message?, config? }`. `config` names this extension's own keys to persist; the host validates each entry against the registered item's schema and drops anything unknown.
|
|
637
|
+
- **Failure** — throw; the message renders in the error color.
|
|
638
|
+
- **Runtime** — the host serializes actions (one in flight per panel), no timeout — bound your own work.
|
|
639
|
+
- **Status probe** — optional, passive, capped at ~3s; a throw means "no status". `state`: `ok` / `off` / `error`.
|
|
640
|
+
- Action keys hold no value (`ctx.config.get('runContainer')` is `undefined`) and are omitted from the GUI wire schema — actions are TUI-only today.
|
|
936
641
|
|
|
937
642
|
## Limitations + gotchas (v1)
|
|
938
643
|
|
|
939
|
-
- **Hot-reload via `/reload`.**
|
|
940
|
-
- **No sandboxing.** Extensions run with the host's full Node access — same trust model as `SKILL.md` shell access.
|
|
941
|
-
- **No cross-extension dependency declarations.**
|
|
942
|
-
- **No compaction lifecycle provider yet.**
|
|
943
|
-
- **Eager modal `node`s load UI everywhere.** A modal registered with
|
|
644
|
+
- **Hot-reload via `/reload`.** Discovery evicts the extension's module graph before importing, so on-disk edits apply live; module-level state resets with it. `teardown` runs on registry disposal (`/reload`, cwd change, process teardown) — **not** on an in-session enable/disable toggle.
|
|
645
|
+
- **No sandboxing.** Extensions run with the host's full Node access — same trust model as `SKILL.md` shell access.
|
|
646
|
+
- **No cross-extension dependency declarations.** Coordination between extensions is on the authors.
|
|
647
|
+
- **No compaction lifecycle provider yet.** `compactConversation` is still host-driven (TUI/GUI), not an extension surface.
|
|
648
|
+
- **Eager modal `node`s load UI everywhere.** A modal registered with `node: <Panel/>` builds its UI at `setup()`, joining the import graph of headless hosts too. Use the lazy `node: () => import('./panel')` form — see [Headless-safe UI](#headless-safe-ui).
|
|
944
649
|
- **Hooks don't bubble to subagents** — see [Hooks: parent vs subagent](#hooks-parent-vs-subagent).
|
|
945
|
-
- **Compiled binary import resolution.** `zidane-tui` binaries can't dynamically resolve `import { defineExtension } from 'zidane'
|
|
946
|
-
- **Setup runs on the boot critical path.** Slow imports
|
|
650
|
+
- **Compiled binary import resolution.** `zidane-tui` binaries can't dynamically resolve `import { defineExtension } from 'zidane'`. Use the zero-import alternative or type-only imports.
|
|
651
|
+
- **Setup runs on the boot critical path.** Slow imports add latency to TUI startup; keep `setup` cheap.
|
|
947
652
|
|
|
948
653
|
## SDK surface
|
|
949
654
|
|
|
950
|
-
For hosts (custom CLIs, GUI shells, scripts)
|
|
655
|
+
For hosts (custom CLIs, GUI shells, scripts) driving extensions without `runTui`:
|
|
951
656
|
|
|
952
657
|
```ts
|
|
953
658
|
import {
|
|
954
|
-
defineExtension,
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
buildExtensionRegistry, // run setup, merge contributions
|
|
958
|
-
mergeContributions, // re-merge a filtered subset (toggle)
|
|
959
|
-
isExtensionDefinition, // structural type guard
|
|
960
|
-
defaultExtensionScanPaths,
|
|
961
|
-
scanExtensionDirectories,
|
|
962
|
-
EMPTY_EXTENSION_REGISTRY,
|
|
659
|
+
defineExtension, discoverExtensions, inlineLoadedExtension,
|
|
660
|
+
buildExtensionRegistry, mergeContributions, isExtensionDefinition,
|
|
661
|
+
defaultExtensionScanPaths, scanExtensionDirectories, EMPTY_EXTENSION_REGISTRY,
|
|
963
662
|
} from 'zidane'
|
|
964
663
|
|
|
965
664
|
import {
|
|
966
|
-
// Live-host wiring (
|
|
967
|
-
createExtensionHud,
|
|
968
|
-
createExtensionPromptBridge, // ctx.submitPrompt backing
|
|
969
|
-
createExtensionHostRuntimeBridge, // ctx.runtime / ctx.sessions / appendNote backing
|
|
665
|
+
// Live-host wiring (TUI uses these internally; GUI shells do the same):
|
|
666
|
+
createExtensionHud, createExtensionPromptBridge, createExtensionHostRuntimeBridge,
|
|
970
667
|
// Install / update / remove pipeline (what the Settings panel calls):
|
|
971
|
-
installExtensionFromSpec,
|
|
972
|
-
|
|
973
|
-
removeInstalledExtension,
|
|
974
|
-
readExtensionInstallProvenance,
|
|
975
|
-
ExtensionInstallError,
|
|
668
|
+
installExtensionFromSpec, updateInstalledExtension, removeInstalledExtension,
|
|
669
|
+
readExtensionInstallProvenance, ExtensionInstallError,
|
|
976
670
|
} from 'zidane'
|
|
977
671
|
|
|
978
672
|
import type {
|
|
@@ -992,18 +686,11 @@ import type {
|
|
|
992
686
|
} from 'zidane'
|
|
993
687
|
```
|
|
994
688
|
|
|
995
|
-
|
|
996
|
-
(same barrel, narrower module graph for hosts that don't want the root).
|
|
997
|
-
|
|
998
|
-
Same exports are available from `zidane/chat` for hosts that pull the chat layer (not the full SDK barrel).
|
|
999
|
-
|
|
1000
|
-
The TUI-specific bits (`ExtensionsSettingsModal`) live in `zidane/tui`.
|
|
689
|
+
Also importable from `zidane/extensions` (same barrel, narrower module graph) and `zidane/chat` (for hosts that pull the chat layer, not the full SDK). The TUI-specific bits (`ExtensionsSettingsModal`) live in `zidane/tui`.
|
|
1001
690
|
|
|
1002
691
|
### Folding extensions into an agent
|
|
1003
692
|
|
|
1004
|
-
`runTui` and the GUI wire the registry in for you. For an embedded harness, build
|
|
1005
|
-
the registry (async — it runs `setup()`), then hand the **result** to `createAgent`
|
|
1006
|
-
(sync) or `runHeadless`:
|
|
693
|
+
`runTui` and the GUI wire the registry in for you. For an embedded harness, build the registry (async — it runs `setup()`), then hand the **result** to `createAgent` (sync) or `runHeadless`:
|
|
1007
694
|
|
|
1008
695
|
```ts
|
|
1009
696
|
import { buildExtensionRegistry, createAgent, inlineLoadedExtension, runHeadless } from 'zidane'
|
|
@@ -1013,72 +700,37 @@ const registry = await buildExtensionRegistry(defs.map(inlineLoadedExtension))
|
|
|
1013
700
|
// Long-lived agent:
|
|
1014
701
|
const agent = createAgent({ provider, execution, session, extensions: registry })
|
|
1015
702
|
|
|
1016
|
-
// One-shot headless run
|
|
703
|
+
// One-shot headless run:
|
|
1017
704
|
await runHeadless({ prompt, provider, preset: myPreset, extensions: registry })
|
|
1018
705
|
```
|
|
1019
706
|
|
|
1020
|
-
`createAgent({ extensions })` folds the registry's tools /
|
|
1021
|
-
skills / system fragments / subagents / runtime transforms / typed metadata into
|
|
1022
|
-
the agent, with your explicit options winning on collision. It is **not** just
|
|
1023
|
-
`composePresets(buildExtensionPreset(registry), preset)`: the fold also rebuilds
|
|
1024
|
-
the `spawn` tool's subagent menu (`withSubagents`), *appends* system fragments
|
|
1025
|
-
rather than replacing `system`, carries context-panel sections and tool-result
|
|
1026
|
-
metadata into `agent.getContextBreakdown()`, and leaves `ctx.config.get` reading
|
|
1027
|
-
live through the registry's `readSettings`. The renderer/picker surfaces a registry
|
|
1028
|
-
carries — `themes`, `agents`, `providers`, `completionProviders`, keybindings, footer
|
|
1029
|
-
hints — are **not** folded into a bare agent; they need a host (TUI/GUI) that consumes
|
|
1030
|
-
them.
|
|
707
|
+
`createAgent({ extensions })` folds the registry's tools/hooks/MCP servers/skills/system fragments/subagents/runtime transforms/typed metadata into the agent, with explicit options winning on collision. It also rebuilds the `spawn` tool's subagent menu, *appends* system fragments rather than replacing `system`, and carries context-panel sections + tool-result metadata into `agent.getContextBreakdown()`. Renderer/picker surfaces (`themes`, `agents`, `providers`, `completionProviders`, keybindings, footer hints) are **not** folded into a bare agent — they need a host (TUI/GUI).
|
|
1031
708
|
|
|
1032
709
|
### Headless-safe UI
|
|
1033
710
|
|
|
1034
|
-
Every **agent-level** contribution
|
|
1035
|
-
fragments, subagents, typed config — is headless-safe: it's plain data /
|
|
1036
|
-
callbacks, so registering it never drags a UI runtime onto a non-TUI host's
|
|
1037
|
-
import graph. TUI keybindings and footer hints are likewise just callbacks +
|
|
1038
|
-
data and are fine.
|
|
1039
|
-
|
|
1040
|
-
The one exception is a modal's **eager** `node`. Because it's a `ReactNode`
|
|
1041
|
-
built during `setup()`, the JSX producing it is evaluated at registration time,
|
|
1042
|
-
so it lands on the import graph of *every* host that loads the extension —
|
|
1043
|
-
headless and embedded included.
|
|
711
|
+
Every **agent-level** contribution (tools, hooks, MCP servers, skills, system fragments, subagents, typed config) is headless-safe: plain data/callbacks, no UI runtime on the import graph. TUI keybindings and footer hints are likewise callbacks + data.
|
|
1044
712
|
|
|
1045
|
-
|
|
713
|
+
The exception is a modal's **eager** `node` — a `ReactNode` built during `setup()`, so its JSX is evaluated at registration time and lands on every host's import graph, headless included. To keep it off non-TUI graphs:
|
|
1046
714
|
|
|
1047
|
-
- Register
|
|
1048
|
-
|
|
1049
|
-
-
|
|
1050
|
-
piece of JSX injects a static `react/jsx-runtime` import that defeats the split.
|
|
1051
|
-
- Put the component + its JSX in the lazily-imported `./panel` module and
|
|
1052
|
-
`export default` the node (zidane unwraps the module's `default`).
|
|
715
|
+
- Register with the lazy form: `node: () => import('./panel')`. The thunk runs only when the modal first opens.
|
|
716
|
+
- Keep the extension's entry module free of UI imports **and** JSX (a single piece of JSX injects a static `react/jsx-runtime` import that defeats the split).
|
|
717
|
+
- Put the component + JSX in the lazily-imported module and `export default` the node.
|
|
1053
718
|
|
|
1054
|
-
This mirrors
|
|
1055
|
-
`src/extensions/**` tree is renderer-neutral (no `react` / `@opentui` at
|
|
1056
|
-
runtime), checked by the `bundle-invariant` test. The lazy `node` form extends
|
|
1057
|
-
the same discipline to *your* extension's entry module.
|
|
719
|
+
This mirrors zidane's own invariant: `src/extensions/**` is renderer-neutral (no `react`/`@opentui` at runtime), checked by the `bundle-invariant` test.
|
|
1058
720
|
|
|
1059
721
|
## Preset vs extensions
|
|
1060
722
|
|
|
1061
|
-
Both layer contributions onto an agent, but
|
|
1062
|
-
extension never shadows anything the host didn't already cede:
|
|
723
|
+
Both layer contributions onto an agent, but answer different questions — an extension never shadows anything the host didn't already cede:
|
|
1063
724
|
|
|
1064
725
|
| | `preset` (+ explicit `createAgent` options) | `extensions` (an `ExtensionRegistry`) |
|
|
1065
726
|
|---|---|---|
|
|
1066
727
|
| Role | The host's authoritative bundle — the agent "profile" | A contributed layer composed *under* the host |
|
|
1067
|
-
| Precedence | **Wins** on every collision | Loses to preset/explicit (mirrors
|
|
728
|
+
| Precedence | **Wins** on every collision | Loses to preset/explicit (mirrors `composePresets(extensionPreset, profile.preset)`) |
|
|
1068
729
|
| Unit | A static object you author | A *built* artifact: ran `setup()`, allowlist-gated, carries inactive/errored entries |
|
|
1069
730
|
| Lifecycle | Fixed at construction | Discovered, trusted, enabled/disabled per user |
|
|
1070
731
|
| Surface | tools, hooks, system, mcpServers, skills, behavior | all of the above **plus** subagents, typed config, themes, agent profiles, providers, completion providers, keybindings, footer hints, storage/session state, runtime transforms, context-panel sections, metadata |
|
|
1071
732
|
|
|
1072
|
-
**Rule of thumb:**
|
|
1073
|
-
|
|
1074
|
-
- **Host-owned and authoritative** (your core tools, your system prompt, your behavior
|
|
1075
|
-
defaults) → `preset` / explicit options.
|
|
1076
|
-
- **Contributed, third-party, user-toggleable, or anything needing the extension-only
|
|
1077
|
-
surfaces** (subagents, typed config, themes, providers, keybindings, storage/session state, runtime transforms) → `extensions`.
|
|
1078
|
-
|
|
1079
|
-
If a tool or hook lives in both, the preset/explicit one wins (hooks from both still
|
|
1080
|
-
fire). The litmus test: *would a user expect to toggle it off in `Settings → Extensions`?*
|
|
1081
|
-
If yes, it's an extension.
|
|
733
|
+
**Rule of thumb:** host-owned and authoritative (core tools, system prompt, behavior defaults) → `preset`/explicit options. Contributed, third-party, user-toggleable, or extension-only surfaces (subagents, typed config, themes, providers, keybindings, storage/session state, runtime transforms) → `extensions`. The litmus test: *would a user expect to toggle it off in `Settings → Extensions`?* If yes, it's an extension.
|
|
1082
734
|
|
|
1083
735
|
## File layout reference
|
|
1084
736
|
|
|
@@ -1094,15 +746,13 @@ If yes, it's an extension.
|
|
|
1094
746
|
~/.zidane/extensions/ # user-global, zidane-specific
|
|
1095
747
|
```
|
|
1096
748
|
|
|
1097
|
-
Each `<name>/` is free to ship whatever it needs — additional files, `package.json` (including optional `package.json#zidane` manifest metadata), assets. The loader reads `extension.json
|
|
749
|
+
Each `<name>/` is free to ship whatever it needs — additional files, `package.json` (including optional `package.json#zidane` manifest metadata), assets. The loader reads `extension.json`/`package.json#zidane` for metadata and only imports `index.{ts,tsx,mjs,js}` for executable code.
|
|
1098
750
|
|
|
1099
751
|
## Quick checklist
|
|
1100
752
|
|
|
1101
|
-
Building your first extension:
|
|
1102
|
-
|
|
1103
753
|
- [ ] Create `.zidane/extensions/<your-name>/index.ts`
|
|
1104
754
|
- [ ] `export default defineExtension({ name, setup })` (or the zero-import object form)
|
|
1105
|
-
- [ ] Optionally add `extension.json` with `apiVersion: 1`, `capabilities`,
|
|
755
|
+
- [ ] Optionally add `extension.json` with `apiVersion: 1`, `capabilities`, `permissions`
|
|
1106
756
|
- [ ] Register what you need via `ctx.*` inside `setup`
|
|
1107
757
|
- [ ] Restart the TUI
|
|
1108
758
|
- [ ] Toggle on/off in `Settings → Extensions`
|