返回 CodeWhale
PLUGIN_AUTHORING.md
根目录 / docs / PLUGIN_AUTHORING.md
1 # Write your first Codewhale plugin
2
3 > 阅读简体中文版:[zh_hans/PLUGIN_AUTHORING.md](zh_hans/PLUGIN_AUTHORING.md)
4
5 Start with a skill: a Markdown instruction file inside a small plugin bundle.
6 The [hello-codewhale example](examples/plugins/hello-codewhale/plugin.json)
7 contains two files, declares no server or hook, and asks for no tool use.
8 This walkthrough takes it from source files to a reviewed, enabled skill.
9
10 ## 1. Create the bundle
11
12 Use the checked-in example, or create this directory outside an installed
13 plugins directory:
14
15 ```text
16 hello-codewhale/
17 ├── plugin.json
18 └── skills/
19 └── hello/
20 └── SKILL.md
21 ```
22
23 `plugin.json`:
24
25 ```json
26 {
27 "$schema": "https://agent-plugins.org/schemas/plugin.json",
28 "name": "hello-codewhale",
29 "version": "0.1.0",
30 "description": "A minimal, explicitly invoked greeting skill."
31 }
32 ```
33
34 `skills/hello/SKILL.md`:
35
36 ```markdown
37 ---
38 name: hello
39 description: Greet the user when they explicitly try the hello-codewhale example.
40 invocation: explicit-only
41 ---
42
43 Respond with one short greeting in the user's language. Include the exact text
44 `hello-codewhale:hello` so they can identify the example they invoked.
45
46 Use only the conversation. Do not call tools, run commands, read or write files,
47 or contact external services.
48 ```
49
50 Codewhale finds `skills/` automatically. `explicit-only` keeps this example out
51 of the model's automatic skill catalogue; you load it by name. See
52 [Skills](SKILLS.md#invocation-and-alias-metadata) for invocation metadata and
53 [Plugin bundles](PLUGIN_BUNDLES.md#manifest) for the authoritative manifest
54 contract. Keep new native bundles in `plugin.json`; no second manifest is
55 needed.
56
57 ## 2. Install, inspect, and trust
58
59 Start Codewhale in the repository root. Enter these commands **inside the
60 Codewhale session**, one at a time:
61
62 ```text
63 /plugin install ./docs/examples/plugins/hello-codewhale
64 /plugin validate hello-codewhale
65 /plugin show hello-codewhale
66 ```
67
68 For your own bundle, replace the install path with its directory. Installation
69 copies it to `~/.codewhale/plugins/hello-codewhale/`, disabled and untrusted.
70 Review the installed source, skill inventory, and permissions. This example
71 should declare only Skills, with no MCP server, hook, or requested network host.
72
73 The install review prints a command containing two full hashes:
74
75 ```text
76 /plugin trust hello-codewhale <full-content-sha256>.<full-capability-sha256>
77 ```
78
79 Run the exact command printed by your review; the angle-bracket text above is
80 a placeholder. If you need a fresh review, use `/plugin trust hello-codewhale`
81 without a token. Trust records the reviewed content and capability hashes and
82 creates a runtime snapshot. It does not enable the plugin.
83
84 ```text
85 /plugin enable hello-codewhale
86 /skills hello-codewhale:
87 /skills inspect
88 ```
89
90 The skill is named `hello-codewhale:hello`: the bundle name qualifies the skill
91 name. `/skills inspect` identifies its reviewed plugin snapshot.
92
93 ## 3. Invoke it and turn it off
94
95 ```text
96 /skill hello-codewhale:hello
97 ```
98
99 Codewhale confirms activation. Then send `Say hello.` as a normal message.
100 The reply should be a short greeting containing `hello-codewhale:hello`.
101 The example contributes instructions only; the reply still uses your selected
102 model and its normal provider connection. The local install, review, and
103 activation steps do not need a model call.
104
105 ```text
106 /plugin disable hello-codewhale
107 ```
108
109 Disabling removes the plugin's contributions while preserving its trust
110 receipt. A subsequent `/skill hello-codewhale:hello` must not activate it.
111 Enable it again when needed, provided its reviewed hashes still match.
112
113 ## 4. Iterate and review changes
114
115 The installed bundle is a copy. Editing the example's original source does
116 not update that copy. To try a changed local source, disable and uninstall the
117 installed example, then install the source directory again:
118
119 ```text
120 /plugin disable hello-codewhale
121 /plugin uninstall hello-codewhale
122 /plugin install ./docs/examples/plugins/hello-codewhale
123 /plugin validate hello-codewhale
124 ```
125
126 Uninstall removes the installed copy; it leaves the original example source
127 alone. Review the new token, trust it, and enable it again. For bundles
128 installed from a remote source, use `/plugin update <name>`; see
129 [Installing plugins](PLUGINS.md#update-and-uninstall).
130
131 When files in a discovered bundle change directly, `/plugin reload` refreshes
132 the registry. Changed content invalidates the old receipt, even if you leave
133 the version unchanged. Reload does not grant trust. Use `/plugin revoke <name>`
134 to remove trust explicitly.
135
136 ## Add only the components you need
137
138 All components use the same bundle review and existing Codewhale runtime:
139
140 | Component | Authoring surface |
141 | --- | --- |
142 | Skills | `skills/<name>/SKILL.md`; [instruction and invocation contract](SKILLS.md). |
143 | MCP | A sibling `mcp.json`; [bundle transport and credential rules](PLUGIN_BUNDLES.md#validation-both-formats). |
144 | Commands | Markdown command files; [command metadata](architecture/command-dispatch.md#user-commands). |
145 | Agent profiles | Fleet TOML profiles; [Fleet authoring](FLEET.md#authoring-agent-profiles-fleet-setup). |
146 | Hooks | `HooksConfig` TOML files; [events and process behavior](HOOKS.md). |
147 | Native mods (experimental) | Reviewed ESM entries contributing tools, commands, pre-execute listeners, prompt sections and owner-local JSON state; [extension contract](EXTENSIONS.md). |
148
149 Declare Commands, Agents, and Hooks paths under
150 `extensions["net.codewhale"]` in `plugin.json`, as specified in
151 [Plugin bundles](PLUGIN_BUNDLES.md#active-and-inactive-component-surfaces).
152 Do not place MCP server fields or arbitrary runtime entrypoints at the manifest
153 root. LSP can be inventoried but has no executable adapter. A `native`
154 extension is inventory-only by default; with the experimental
155 `[features] extension_host` flag on, it names one `.mjs`, `.js` or `.mts` ES module
156 file that the TypeScript extension host runs, and `/plugin validate` rejects
157 any other entry. Its tools always use `Required` approval, never a plugin's
158 read-only hint. Full Access, Bypass, or an exact session grant for the
159 reviewed build can satisfy that gate without a prompt
160 ([design](design/TS_EXTENSION_HOST.md#as-built-phase-1-2026-09-25)).
161 For a tested typed example, lifecycle rules and per-plugin diagnostics, read
162 [Writing an extension tool](EXTENSIONS.md). `.mts` supports Node's erasable
163 types without a separate compiler; syntax needing transformation is not supported.
164
165 ## Write a scoped native mod
166
167 The [mod-extension example](examples/plugins/mod-extension/README.md) is a
168 runnable ESM bundle with no package installation or compiler step. It registers
169 `mod_counter`, `/mod-count`, one prompt section, and a pre-execute listener
170 limited to its own tool. The tool returns a structured JSON counter value;
171 `ctx.storage` keeps that value in the owner directory Rust assigned. Its
172 idempotent disposers remove registrations and wait for queued work. Disable
173 withdraws the prompt section and listener while retaining the stored counter.
174
175 Enable the experimental `extension_host` feature explicitly, install the
176 example directory, validate and inspect its Native capability, then personally
177 review and trust its exact content/capability hashes before enabling it. With
178 the feature off, native code remains inventory-only. Source changes require
179 another review; `/plugin reload` is explicit, not a hot-reload watcher.
180
181 The available author services are `tools`, `commands`, `prompt`, `storage`, `skills`,
182 `logger`, and Cordis lifecycle facilities. `ctx.on('tools/pre-execute', ...)`
183 may abstain, deny, ask, revise object input or annotate context. Rust folds those
184 proposals and repeats planning and admission checks for revised input. `allow`
185 does not approve anything; `next()` abstains. Errors, malformed answers,
186 timeouts and withdrawn owners fail closed. This is a pre-execute proposal
187 contract, with no around-execution middleware or post-result rewriting.
188
189 `ctx.prompt.registerSection({id, text})` proposes bounded, attributed
190 instructions delivered through the existing Engine runtime-message path.
191 `ctx.storage.get/set/delete` handles bounded owner-local JSON, including state
192 across generations. Neither API replaces the system prompt, session store or
193 credentials. Tool and command invocations expose optional frozen `sessionId`,
194 `agentId` and `originTurnId` labels supplied for that call, not runtime handles.
195 The public author SDK is not published; the example uses the documented shims.
196
197 `ctx.skills.registerRoot({path: 'profiles/review-skills'})` contributes child
198 `SKILL.md` packages from the reviewed bundle to the existing Rust skill
199 catalog. Its disposer retires the root; disable, revoke and host exit do the
200 same. Rust checks the Native receipt, file hashes and current registration
201 when discovering or loading a skill, including queued user selections. A
202 process or host restart invalidates a saved Native selection. Bundle-relative
203 paths, parser rules and count/byte limits are documented in
204 [the skill-root contract](EXTENSIONS.md#skill-roots). This API adds instructions;
205 tool permissions remain with the shared engine.
206
207 Custom Ratatui/GPUI widgets, DSH browser UI slots,
208 native `dsh.bundle.patch` execution and DSH's agent runtime are not provided.
209 The static importer described below still converts only its portable subset.
210 Compatible Claude bundles still use the existing declarative component adapters;
211 this Native API does not load Claude's agent loop or automatically adapt Pi's
212 extension API. Port executable mod behavior against the documented host contract.
213 An extension tool can use `exec.core.call` only during its direct model
214 invocation under the shared turn gate. Commands and activation have no core
215 handle. Nested shell and network calls force a user prompt; a mode that cannot
216 open one refuses them. See [the exact core-call contract](EXTENSIONS.md#asking-the-core-to-run-a-tool)
217 for refused tools, cancellation and call limits.
218
219 Plugin trust is **not an OS sandbox**. A local MCP server or hook can launch a
220 process; review its code and authority before enabling it. Skills do not grant
221 permissions: repository instructions, permission rules, sandbox policy, and
222 tool approval still apply. Keep credentials out of bundles and command
223 arguments. Use the reviewed environment references documented in the
224 [bundle validation contract](PLUGIN_BUNDLES.md#validation-both-formats) for MCP;
225 read the separate [hook environment contract](HOOKS.md#the-hook-process-environment)
226 before adding a hook.
227
228 ## Convert an existing plugin
229
230 [`scripts/convert-plugin.py`](../scripts/convert-plugin.py) converts explicitly
231 selected remote MCP declarations, packaged local Node MCP servers, and portable
232 Skills into a native bundle.
233 It requires Python 3.10+ and PyYAML 6+; install those separately if absent.
234 The converter installs no dependencies, scans no ambient configuration or
235 credentials, makes no network requests, and executes no source code.
236
237 ### OpenCode
238
239 Save this plain JSON as `opencode-mcp.json`:
240
241 ```json
242 {
243 "mcp": {
244 "docs": {
245 "type": "remote",
246 "url": "https://example.invalid/mcp",
247 "oauth": false,
248 "enabled": false
249 }
250 }
251 }
252 ```
253
254 From the Codewhale repository root, run this in your shell:
255
256 ```sh
257 python3 scripts/convert-plugin.py --format opencode-v1 \
258 --config ./opencode-mcp.json --name migrated-tools --output ./migrated-opencode
259 ```
260
261 Choose `--format opencode-v2` for the `mcp.servers.<name>` layout, whose server
262 flag is `disabled` instead of `enabled`. Select the format from the data;
263 filenames and upstream branch names do not determine its version. Both formats
264 require explicit `oauth: false` for remote servers. Remote MCP output uses **Streamable HTTP only**;
265 OpenCode's fallback to legacy SSE is not reproduced. For an SSE-only endpoint,
266 author native `mcp.json` with `type: "sse"` and use the same review flow.
267
268 When MCP servers are selected, configurations containing `tools`,
269 `permission`/`permissions`, `agent`/`agents`, legacy `mode`, or `default_agent`
270 are refused. These settings can restrict tool access beyond server enablement.
271 Manually preserve those restrictions in Codewhale before supplying an MCP-only
272 input; simply deleting the settings can widen access.
273
274 JSONC comments and trailing commas are not
275 accepted: provide a plain JSON copy containing the declarations you intend
276 to port.
277
278 ### DeepSeek Harness (DSH)
279
280 Save this static Cordis entry list as `dsh-mcp.yml`:
281
282 ```yaml
283 - name: '@deepseek-ai/dsh-mcp-client'
284 disabled: true
285 config:
286 serverName: docs
287 transport: streamable-http
288 url: https://example.invalid/mcp
289 ```
290
291 ```sh
292 python3 scripts/convert-plugin.py --format dsh \
293 --config ./dsh-mcp.yml --name migrated-dsh --output ./migrated-dsh
294 ```
295
296 The DSH input may also be JSON, but must be the plain entry list, not a full
297 profile or patch composition. Each row must name `@deepseek-ai/dsh-mcp-client`.
298
299 A real DeepSeek Harness bundle package — an npm package whose `package.json`
300 declares `dsh.bundle.patch` — is imported natively by Codewhale, not by this
301 script (`--bundle` is retired):
302
303 ```text
304 /plugin import dsh ./node_modules/@demo/tools-dsh
305 /plugin import dsh approve <package-dir> <content-hash>
306 ```
307
308 The first command converts the package into scratch and shows what converts,
309 what is skipped, the network hosts and local processes the bundle will request,
310 and its content hash; nothing is installed. `approve` installs exactly that
311 converted bundle through the ordinary reviewed installer: it lands disabled and
312 untrusted, and a package edited after review installs nothing. Installing the
313 package directory as a plain `/plugin install <dir>` routes to the same importer.
314 `/plugin update <name>` re-converts the recorded package; changed output
315 replaces the bundle and invalidates its trust receipt. The Runtime API exposes
316 the same review as `POST /v1/apps/plugins/import/dsh/preview`, whose
317 `install_source` and `content_hash` go to `POST /v1/apps/plugins/install`.
318 Those two are the only import surfaces today: the TUI slash command
319 (`/plugin import dsh <dir>` and `approve`) and the Runtime API. There is no
320 `codewhale plugin` CLI subcommand for DSH import. This static import is also
321 not the external-launcher integration `codewhale integrations dsh`, which runs
322 the user's installed `dsh` (see [INTEGRATIONS_DSH.md](INTEGRATIONS_DSH.md)).
323
324 `dsh.bundle.patch` may name one patch file or an ordered list of files. The
325 importer reads only contained, non-linked package files (at most 64 files and
326 1 MiB of patch data combined), then applies `insert` and keyed overrides over
327 one empty profile. As in upstream `applyEntryPatches`, an override replaces a
328 whole field: `config` is **not** deep-merged. This is not a complete profile
329 resolver: other bundles, user overlays and deployment configuration are absent.
330 Plain YAML scalars follow the YAML 1.2 core schema, as DSH's own parser does.
331
332 Disabled groups propagate their state to descendants. Disabled MCP declarations
333 stay disabled. Disabled skills are omitted with an explicit receipt because the
334 native skill format has no disabled state. A conditional/non-boolean `disabled`
335 value on a group or portable row refuses the import rather than assuming it is
336 enabled. Unsupported entry policy/dependency fields, including `inject`,
337 `intercept` and `isolate`, also refuse the import on those rows or their groups.
338 Preserve their activation and authority rules in a manual port.
339
340 The importer never executes plugin code. Foreign runtime plugins and
341 `dsh.client` UI code are not executed or translated.
342 Other unrepresentable components are reported in the bundle's `CONVERSION.md` and
343 structured `CONVERSION.json`, with source package/version, manifest and
344 ordered-layer SHA-256 hashes, converter version, per-row outcomes and required
345 manual ports. Unapplied patch operations also appear in the structured
346 manual-port list, with their source layer and one-based operation index.
347
348 The only lowered `!!js` expressions are `process.execPath` (becomes `node`) and
349 simple quoted/template literals without escapes or interpolation. Environment
350 expressions—including fallbacks—are never resolved against this machine.
351 Packaged Node MCP converts only when its relative entry resolves inside the
352 package (and its declared relative working directory); a server outside the
353 package is skipped, and host paths are never copied. Rows of
354 `@deepseek-ai/dsh-skill-filesystem` contribute their literal `customSkillDirs`
355 children only when those directories live inside the package. Default user and
356 project skill roots, watchers and foreign service dependencies are not imported.
357 Arbitrary DSH TypeScript plugin execution is outside this importer's scope.
358 DSH TypeScript plugin code runs only through the experimental TypeScript
359 extension host (`[features] extension_host`, off by default), which now supports
360 tools, slash commands, scoped pre-execute proposals, additive prompt sections
361 and owner-local storage through an explicitly authored Native entry. It does
362 not execute the imported `dsh.bundle.patch` composition. See [EXTENSIONS.md](EXTENSIONS.md) and
363 [design/TS_EXTENSION_HOST.md](design/TS_EXTENSION_HOST.md).
364
365 ### Local Node MCP servers
366
367 For an already packaged Node MCP server, select its original process working
368 directory explicitly. The converter copies that directory into `mcp/<server>`
369 and sets the native server's working directory to the reviewed copy. Relative
370 entrypoint imports and read-only resources keep the same layout.
371
372 ```json
373 {
374 "mcp": {
375 "localdocs": {
376 "type": "local",
377 "command": ["node", "server.mjs"],
378 "environment": {"API_TOKEN": "{env:LOCALDOCS_TOKEN}"},
379 "enabled": false
380 }
381 }
382 }
383 ```
384
385 ```sh
386 python3 scripts/convert-plugin.py --format opencode-v1 \
387 --config ./local-mcp.json --stdio-root localdocs=./packaged-localdocs \
388 --name local-tools --output ./migrated-local
389 ```
390
391 Repeat `--stdio-root SERVER=DIRECTORY` for every local server in the selected
392 configuration. OpenCode v2 uses `mcp.servers` and `disabled`. Static DSH entries
393 use `transport: stdio`, `command: node`, and `args: [server.mjs]`; DSH `env`
394 must be absent or empty because its literals/expressions are not OpenCode
395 environment references. Optional DSH/v2 `cwd` must be absent, empty, or `.`;
396 the selected root explicitly supplies the original working directory.
397
398 Use `node` plus one relative `.mjs`, `.js`, or `.cjs` entry. Package module
399 type and sibling imports are preserved by the native launch adapter. Compile
400 TypeScript to JavaScript before packaging; the converter does not run a compiler.
401 Package dependencies and read-only resources first, inside the selected root.
402 No package manager, install script, module loader or server runs during
403 conversion. Links/reparse points, hard-linked files, hidden files/directories
404 (including `.gitignore`, `.env*`, `.npmrc` and `node_modules/.bin`), common
405 credential filenames, and private-key containers are refused. Prepare a clean
406 package directory; ignore rules are not used to silently omit files. Inspect
407 every selected file for embedded credentials before conversion. The existing
408 4,096-file / 64 MiB aggregate bundle limit applies.
409
410 The converter rejects shell launchers, Node flags, extra arguments, non-Node
411 interpreters, literal environment values, and loader-changing environment
412 names. Stateful servers that write into their working directory, depend on the
413 live workspace, or import files outside the package need a manual native port.
414 Copying files does not statically verify JavaScript import closure or sandbox
415 arbitrary code. Local MCP processes run with host-user authority; their network
416 and filesystem access are not restricted by the remote endpoint host list.
417 The same native install, capability review, hash-bound trust, and enable steps
418 are required before Codewhale launches the server. This adds a packaged Node
419 MCP subset; it does not execute DSH/Cordis plugin modules.
420
421 ### Review the result
422
423 Both examples preserve disabled servers and use a placeholder endpoint. Replace
424 the endpoint and change the source's enablement flag before reconverting when
425 you are ready to connect. The output directory must be new, with an existing
426 parent. Existing output is refused; rejected input leaves no output bundle.
427
428 Add `--skill ./my-skill` for an explicitly selected directory containing
429 `SKILL.md`, or `--skill ./my-skill.md` for a single file; repeat the option for
430 more skills. `--config` is optional for a skills-only conversion. Skills require
431 `name` and `description` frontmatter. `disable-model-invocation: true` becomes
432 native `invocation: explicit-only`. Informational `license`, `compatibility`,
433 and `metadata` fields are retained in `SOURCE_SKILL_METADATA.json` companion
434 data. Companion files from selected skill directories are copied as data;
435 review them and the instructions before loading the skill.
436
437 Only exact OpenCode header references such as `{env:MCP_TOKEN}` become native
438 `env_headers`; the converter never reads the variable's value. Literal headers,
439 DSH header expressions, and URL file/environment substitution are refused.
440 Configured timeouts must be whole seconds expressed in milliseconds, from
441 `1000` through `3600000`. Omitted timeouts use Codewhale's defaults.
442
443 Executable foreign plugins and hooks, other stdio launchers, automatic OAuth,
444 configuration JavaScript,
445 YAML aliases/tags, `__jsExpr`, and unsupported skill runtime fields (including
446 `user-invocable: false`) require a manual port. Conversion does not reproduce
447 another client's runtime or bypass Codewhale's credential and sandbox rules.
448
449 Read the generated `CONVERSION.md`, `CONVERSION.json` for bundles, `plugin.json`, `mcp.json` when present, and
450 all selected skill and MCP source files. Then use `/plugin install ./migrated-opencode` (or the
451 DSH output path), `/plugin validate <name>`, and the same hash-bound trust and
452 enable flow above. Conversion alone proves neither connectivity nor runtime
453 compatibility; the output is not installed, trusted, or enabled.
454
455 Source audit, 2026-09-08: OpenCode's [v1 MCP documentation](https://github.com/anomalyco/opencode/blob/d6855b6b47a8433462ac6aeeba882ccf734cb7f1/packages/web/src/content/docs/mcp-servers.mdx)
456 and [v2 MCP schema](https://github.com/anomalyco/opencode/blob/d6855b6b47a8433462ac6aeeba882ccf734cb7f1/packages/core/src/config/mcp.ts)
457 at `d6855b6b47`, and DSH's [MCP client reference](https://github.com/deepseek-ai/deepseek-harness/blob/c389f96bf3a9b6807cb71ed6bdad5849be0df6d8/packages/mcp/mcp-client/README.md)
458 at `c389f96bf3`. Bundle patch semantics were rechecked against DSH
459 [`00102833df`](https://github.com/deepseek-ai/deepseek-harness/tree/00102833dfaee1da9f48a3a8eae9d34005a75218)
460 (`0.1.7-alpha.2`); `scripts/fixtures/dsh-web-app` retains its real five-file package
461 as pinned test data, not as an importable native plugin. The Rust tests in
462 `crates/tui/src/plugins/install/dsh_tests.rs` load that package through the native
463 importer and assert that no row is promoted to a converted component; they run in
464 the workspace `Test` job. The CI `Plugin conversion` job runs only
465 `scripts/test_convert_plugin.py` (the OpenCode and static DSH MCP-entry converter)
466 with Python/PyYAML and synthetic Node fixtures, and the script's `--bundle` path
467 refuses and points to `/plugin import dsh`. Upstream supports more than this
468 deliberately bounded converter.
469
470 ## Community context
471
472 This guide responds to [giancarlocp's request for plugin authoring guidance
473 and OpenCode conversion in discussion #5827](https://github.com/codewhale-hq/Codewhale/discussions/5827).
474 The Chinese companion follows the documentation work requested by
475 [SparkofSpike in issue #5482](https://github.com/codewhale-hq/Codewhale/issues/5482).
476
476 lines MARKDOWN