From 27027777d20837dcd38484f21680e59f866909f2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 12 Sep 2026 01:11:52 -0400 Subject: [PATCH] docs: expand V1 plugin migration guide --- .../src/docs/content/build/plugins/index.mdx | 2 + .../docs/content/build/plugins/migrate-v1.mdx | 356 ++++++++++++++++++ services/www/src/docs/content/migrate-v1.mdx | 28 +- services/www/src/docs/lib/navigation.ts | 1 + 4 files changed, 377 insertions(+), 10 deletions(-) create mode 100644 services/www/src/docs/content/build/plugins/migrate-v1.mdx diff --git a/services/www/src/docs/content/build/plugins/index.mdx b/services/www/src/docs/content/build/plugins/index.mdx index 04a6c91bebe..89302e0548a 100644 --- a/services/www/src/docs/content/build/plugins/index.mdx +++ b/services/www/src/docs/content/build/plugins/index.mdx @@ -5,6 +5,8 @@ title: "Overview" Plugins can modify OpenCode's behavior and add new features. To change the terminal UI, build a [CLI plugin](/build/plugins/cli). +Migrating an existing OpenCode 1 plugin? Follow the [V1 plugin migration guide](/build/plugins/migrate-v1). + ```ts title=".opencode/plugins/example/index.ts" import { Plugin } from "@opencode/plugin" diff --git a/services/www/src/docs/content/build/plugins/migrate-v1.mdx b/services/www/src/docs/content/build/plugins/migrate-v1.mdx new file mode 100644 index 00000000000..b8268b2ca7c --- /dev/null +++ b/services/www/src/docs/content/build/plugins/migrate-v1.mdx @@ -0,0 +1,356 @@ +--- +title: "Migrate plugins from V1" +--- + +Plugin configuration can be normalized automatically, but plugin implementation code must be ported. V1 plugin functions +return one object containing tools and hooks. A V2 plugin default-exports a definition with an `id` and `setup(ctx)`; +`setup` registers hooks, transforms, tools, and subscriptions through the context. + + + V1 plugin implementations do not run in V2. Moving a file or renaming its config entry is not enough. + + +## Migrate plugin configuration + +Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object: + +```jsonc +// V1 +{ + "plugin": [ + "opencode-example-plugin", + ["./plugin/local.ts", { "enabled": true }] + ] +} + +// V2 +{ + "plugins": [ + "opencode-example-plugin", + { + "package": "./plugin/local.ts", + "options": { "enabled": true } + } + ] +} +``` + +V2 discovers local plugins from both `.opencode/plugin/` and `.opencode/plugins/`; use `.opencode/plugins/` for V2 files. +Keep supporting modules beside the plugin when moving it. Local plugins and package plugins use the same implementation +API. + +## Replace the entrypoint + +Change the package import, replace the exported plugin function with `Plugin.define`, and move initialization into +`setup`. + +```ts title="V1" +import type { Plugin } from "@opencode-ai/plugin" + +export const ExamplePlugin: Plugin = async ({ directory, project, client }) => { + await client.app.log({ + body: { service: "example", level: "info", message: `Loaded for ${project.id}` }, + }) + + return { + // V1 tools and hooks + } +} +``` + +```ts title="V2" +import { Plugin } from "@opencode/plugin" + +export default Plugin.define({ + id: "example", + async setup(ctx) { + await ctx.storage.set("last-project", ctx.location.project.id) + console.log(`Loaded for ${ctx.location.directory}`) + }, +}) +``` + +Every V2 plugin needs a stable `id`. Plugin storage is scoped by this ID, and the ID also identifies the plugin in status +and diagnostics. Published plugins should depend on a version of `@opencode/plugin` compatible with the OpenCode release +they target. + +## Migrate the context + +The V2 context is both an OpenCode client and the plugin extension API. Most operations are grouped by domain instead of +living on a separate `client` property. + +| V1 | V2 | +| --------------------------------------- | ------------------------------------------------------------------- | +| `directory` | `ctx.location.directory` | +| `project` | `ctx.location.project` | +| `client` | Domain methods such as `ctx.session`, `ctx.permission`, `ctx.agent` | +| plugin options argument | `ctx.options` | +| returned `dispose()` hook | cleanup function returned by `setup` | +| returned `event` hook | `ctx.event.subscribe()` | +| returned config, model, or tool changes | a domain `transform(...)` | +| `$` Bun shell helper | import and manage the process API your plugin uses | + +`ctx.location` describes the location where this plugin instance loaded. It is not the location of every session or event +the plugin may observe. Read the session or event data when that distinction matters. + +The context exposes many more domains than the table lists, including catalogs, commands, integrations, MCP servers, +references, skills, storage, VCS, worktrees, and web search. See the [complete plugin context](/build/plugins#api). + +## Register hooks in `setup` + +V1 returns hooks by string key. V2 registers each hook on the domain that owns the operation. The callback receives one +mutable event instead of separate `input` and `output` objects. + +```ts title="V1" +import type { Plugin } from "@opencode-ai/plugin" + +export const GuardPlugin: Plugin = async () => ({ + "tool.execute.before": async (input, output) => { + if (input.tool === "read" && output.args.filePath.includes(".env")) { + throw new Error("Do not read .env files") + } + }, + "shell.env": async (_input, output) => { + output.env.COMPANY_ENV = "development" + }, +}) +``` + +```ts title="V2" +import { Plugin } from "@opencode/plugin" + +export default Plugin.define({ + id: "company.guard", + async setup(ctx) { + await ctx.tool.hook("execute.before", (event) => { + const input = event.input as { filePath?: string } + if (event.tool === "read" && input.filePath?.includes(".env")) { + throw new Error("Do not read .env files") + } + }) + + await ctx.shell.hook("create.before", (event) => { + event.env.COMPANY_ENV = "development" + }) + }, +}) +``` + +Registrations stay active for the plugin's lifetime and are disposed when it unloads. Retain the returned registration +only when the plugin needs to remove that particular hook or transform earlier with `registration.dispose()`. + +Use this table to find the closest V2 API for each V1 hook: + +| V1 extension point | V2 API | +| -------------------------------------- | ------------------------------------------------------------ | +| `event` | `ctx.event.subscribe()` | +| `dispose` | cleanup function returned by `setup` | +| `config` | transforms on the affected domains | +| `tool` map | `ctx.tool.transform(...)` | +| `auth` | `ctx.integration.transform(...)` and integration APIs | +| `provider` | `ctx.catalog.transform(...)` | +| `chat.message` | `ctx.session.hook("prompt", ...)` | +| `chat.params` | `ctx.session.hook("context", ...)` | +| `chat.headers` | `ctx.session.hook("model.request", ...)` or `"http.request"` | +| `permission.ask` | `ctx.permission.hook("evaluate", ...)` | +| `command.execute.before` | command transforms or the prompt hook, depending on intent | +| `tool.execute.before` | `ctx.tool.hook("execute.before", ...)` | +| `tool.execute.after` | `ctx.tool.hook("execute.after", ...)` | +| `shell.env` | `ctx.shell.hook("create.before", ...)` | +| `tool.definition` | `ctx.tool.transform(...)` | +| `experimental.chat.system.transform` | `ctx.session.hook("context", ...)` and edit `event.system` | +| `experimental.chat.messages.transform` | `ctx.session.hook("context", ...)` and edit `event.messages` | +| `experimental.session.compacting` | `ctx.session.hook("compaction", ...)` | + +These are migration destinations, not always exact renames. In particular, the V2 `prompt` hook runs before durable +prompt admission, while `context` runs immediately before an agent model request. Register `context`, `compaction`, +`generate`, and `title` separately if a transformation must apply to every model request kind. + +There is no direct V2 hook corresponding to every experimental V1 hook. Re-evaluate plugins that use +`experimental.compaction.autocontinue`, `experimental.provider.small_model`, or `experimental.text.complete` against the +V2 session, catalog, and event APIs rather than preserving an internal lifecycle assumption. + +`command.execute.before` also has no one-to-one global hook. Register a command with `ctx.command.transform` when the +plugin owns that command. Use the prompt hook when the real intent is to modify prompts submitted by commands and other +prompt sources. + +## Migrate request hooks + +V2 separates prompt admission, model-visible context, semantic model options, and native HTTP. Choose the narrowest +layer your plugin needs. + +```ts title="V1" +export const RequestPlugin = async () => ({ + "chat.params": async (_input, output) => { + output.temperature = 0.2 + output.maxOutputTokens = 8_000 + }, + "chat.headers": async (_input, output) => { + output.headers["x-plugin"] = "review" + }, + "experimental.chat.system.transform": async (_input, output) => { + output.system.push("Focus on correctness.") + }, +}) +``` + +```ts title="V2" +import { Plugin } from "@opencode/plugin" + +export default Plugin.define({ + id: "review-request", + async setup(ctx) { + await ctx.session.hook("context", (event) => { + event.system.push({ type: "text", text: "Focus on correctness." }) + event.options.temperature = 0.2 + event.options.maxTokens = 8_000 + }) + + await ctx.session.hook("model.request", (event) => { + event.headers["x-plugin"] = "review" + }) + }, +}) +``` + +Use semantic provider options in `event.options`. Use `model.request` for model request headers and raw request settings, +and use `http.request` or `http.response` only when the plugin needs the native provider HTTP exchange. Provider-specific +hooks can pass `{ providerID: "..." }` as the third argument to `ctx.session.hook`. + +## Migrate custom tools + +V1 returns a `tool` map built with the old `tool()` helper. V2 registers tool definitions through a synchronous +`ctx.tool.transform` editor. Tool schemas use JSON Schema, and execution returns structured content. + +```ts title="V1" +import { tool } from "@opencode-ai/plugin" + +export const ToolsPlugin = async () => ({ + tool: { + greeting: tool({ + description: "Create a greeting", + args: { name: tool.schema.string() }, + async execute(args) { + return `Hello ${args.name}!` + }, + }), + }, +}) +``` + +```ts title="V2" +import { Plugin } from "@opencode/plugin" + +export default Plugin.define({ + id: "greeting", + async setup(ctx) { + await ctx.tool.transform((editor) => { + editor.add({ + name: "greeting", + description: "Create a greeting", + input: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }, + async execute(input) { + return { content: `Hello ${(input as { name: string }).name}!` } + }, + }) + }) + }, +}) +``` + +Transforms are replayable state edits. Keep their callback synchronous, cheap, and free of one-time side effects. Load +external data before registration, capture it in the callback, and call the domain's `reload()` method when that data +changes. A later plugin transform sees earlier changes and may update, replace, or remove them. + +The same transform pattern replaces V1 `config` and `provider` hooks, but V2 does not expose one mutable global config +object. Register the change with the domain that owns it: `ctx.agent`, `ctx.catalog`, `ctx.command`, `ctx.integration`, +`ctx.mcp`, `ctx.reference`, `ctx.skill`, `ctx.tool`, `ctx.vcs`, `ctx.websearch`, or `ctx.worktree`. Authentication methods +belong to integrations; provider and model metadata belong to the catalog. + +## Migrate events and cleanup + +V1's `event` callback becomes an async subscription to the public server event stream. Abort it from the cleanup function +returned by `setup`. + +```ts title="V2" +import { Plugin } from "@opencode/plugin" + +export default Plugin.define({ + id: "notifications", + setup(ctx) { + const controller = new AbortController() + + void (async () => { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type === "session.idle") console.log("Session completed") + } + })() + + return () => controller.abort() + }, +}) +``` + +Use the cleanup function for timers, subprocesses, sockets, and other resources the plugin owns. Hook and transform +registrations are scoped to the plugin and are cleaned up automatically. + +## Migrate options and persistent state + +Read options from `ctx.options`. Use `ctx.storage` instead of inventing a global file when state belongs to a plugin; its +JSON values are durable and scoped to the plugin. + +```ts +const strict = ctx.options.strict === true +await ctx.storage.set("settings", { strict }) +const settings = await ctx.storage.get("settings") +``` + +## Support V1 and V2 from one package + +A package can temporarily expose both implementations from one default export. V1 calls `server()` and V2 calls +`setup()`. The APIs remain separate; this shape does not translate hooks automatically. + +```ts title="src/index.ts" +import { Plugin } from "@opencode/plugin" + +export default { + ...Plugin.define({ + id: "example", + async setup(ctx) { + await ctx.tool.hook("execute.before", () => { + console.log("A tool is about to run") + }) + }, + }), + async server() { + return { + "tool.execute.before": async () => { + console.log("A tool is about to run") + }, + } + }, +} +``` + +V1 object entrypoints are supported in OpenCode `1.18.29` and newer. If you support older V1 releases, use separate +package versions or entrypoints and test the oldest release you claim to support. Remove the V1 implementation after your +support window ends. + +## Verify a ported plugin + +For each plugin: + +1. Confirm its ID and source appear in the active plugin list. +2. Exercise every registered hook, transform, tool, command, and event subscription. +3. Verify cleanup by reloading or removing the plugin. +4. Test plugin options and persisted state with a clean project. +5. For a published package, test the installed package rather than only a workspace-linked copy. + +See the [Plugins guide](/build/plugins) for the full Promise API, [Effect plugins](/build/plugins/effect) for the Effect +entrypoint, [plugin RPC](/build/plugins/rpc) for custom methods and events, and [CLI plugins](/build/plugins/cli) for TUI +extensions. diff --git a/services/www/src/docs/content/migrate-v1.mdx b/services/www/src/docs/content/migrate-v1.mdx index da5c8c432ae..511cd0e0877 100644 --- a/services/www/src/docs/content/migrate-v1.mdx +++ b/services/www/src/docs/content/migrate-v1.mdx @@ -9,7 +9,7 @@ package-managed V1 installation before installing V2; the V2 curl installer repl V2 has three intentional breaking changes: -- [Plugins](#plugins) use a new plugin API. +- [Plugins](/build/plugins/migrate-v1) use a new plugin API. - The [server API and clients](#server-api-and-clients) have new contracts. - [Terminal client configuration](/cli/config) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated). @@ -31,6 +31,17 @@ compatibility bug rather than an expected migration requirement. Install the V2 terminal client with the [terminal startup guide](/cli). +You do not need to rewrite everything before starting V2. A practical migration is: + +1. Keep your existing configuration and file-based definitions. +2. Start V2 and verify models, credentials, agents, permissions, and MCP servers. +3. Port plugins, because V1 plugin implementations do not run in V2. +4. Port integrations that call the server API. +5. Convert configuration to the native V2 shape when you are ready. This step is optional. + +Keep a copy of your V1 setup while validating the migration. V1 and V2 use the same configuration locations, so do not +point V1 at files after converting them to native V2-only shapes. + ## Configuration This section covers both JSON/JSONC configuration and file-based definitions under `.opencode/`. @@ -522,10 +533,7 @@ Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object ```jsonc // V1 { - "plugin": [ - "opencode-example-plugin", - ["./plugin/local.ts", { "enabled": true }] - ] + "plugin": ["opencode-example-plugin", ["./plugin/local.ts", { "enabled": true }]] } // V2 @@ -541,13 +549,13 @@ Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object ``` V2 discovers local plugins from both `.opencode/plugin/` and `.opencode/plugins/`; use `.opencode/plugins/` for V2 files. -Moving a file between these directories does not migrate its implementation. -V1 plugins will not work in V2. + + V1 plugin implementations do not run in V2. Moving a file or renaming its config entry is not enough. + -The config entry can be translated automatically, but plugin implementation code must be ported to the released V2 API. -Use the [Plugins guide](/build/plugins) to replace V1 hooks and entrypoints. Related local modules and dependencies can -remain with the plugin while you port its implementation. +Port entrypoints, hooks, tools, events, and package exports with the dedicated +[plugin migration guide](/build/plugins/migrate-v1). ## Server API and clients diff --git a/services/www/src/docs/lib/navigation.ts b/services/www/src/docs/lib/navigation.ts index 0b238030a89..a4e8bc9451f 100644 --- a/services/www/src/docs/lib/navigation.ts +++ b/services/www/src/docs/lib/navigation.ts @@ -95,6 +95,7 @@ export const docsSections: DocsSection[] = [ { title: "Overview", slug: "build/plugins" }, { title: "RPC", slug: "build/plugins/rpc" }, { title: "CLI", slug: "build/plugins/cli" }, + { title: "Migrate from V1", slug: "build/plugins/migrate-v1" }, ], }, {