From 0cdd711abfc4423af1947bc357e69db0115a69f9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 24 Aug 2026 18:09:04 -0400 Subject: [PATCH] docs: expand plugin guides --- packages/core/src/plugin/skill/opencode.md | 14 +- packages/www/AGENTS.md | 1 + packages/www/src/docs/content/build/index.mdx | 8 +- .../www/src/docs/content/build/plugins.mdx | 494 ------- .../src/docs/content/build/plugins/cli.mdx | 454 +++++++ .../src/docs/content/build/plugins/effect.mdx | 1182 +++++++++++++++++ .../docs/content/build/plugins/overview.mdx | 1029 ++++++++++++++ packages/www/src/docs/content/build/sdk.mdx | 2 +- packages/www/src/docs/content/cli/plugins.mdx | 25 +- packages/www/src/docs/content/config.mdx | 2 +- packages/www/src/docs/content/index.mdx | 5 +- packages/www/src/docs/content/migrate-v1.mdx | 2 +- packages/www/src/docs/content/plugins.mdx | 107 ++ packages/www/src/docs/lib/navigation.ts | 15 +- 14 files changed, 2823 insertions(+), 517 deletions(-) delete mode 100644 packages/www/src/docs/content/build/plugins.mdx create mode 100644 packages/www/src/docs/content/build/plugins/cli.mdx create mode 100644 packages/www/src/docs/content/build/plugins/effect.mdx create mode 100644 packages/www/src/docs/content/build/plugins/overview.mdx create mode 100644 packages/www/src/docs/content/plugins.mdx diff --git a/packages/core/src/plugin/skill/opencode.md b/packages/core/src/plugin/skill/opencode.md index d8d7de2459a..a200a9e75b1 100644 --- a/packages/core/src/plugin/skill/opencode.md +++ b/packages/core/src/plugin/skill/opencode.md @@ -138,16 +138,20 @@ V2 config uses more ergonomic shapes, but conversion is optional. When the user requests conversion, inspect the complete configuration, preserve behavior and unrelated settings, and apply only the relevant migrations from the guide. For plugin migrations, fetch and follow both the migration guide and the full -[plugins guide](https://opencode.ai/v2/docs/build/plugins). If non-API V1 +[plugins guide](https://opencode.ai/v2/docs/build/plugins/overview). If non-API V1 functionality fails in V2, use the `report` skill to file it as a compatibility bug. -## [Plugins](https://opencode.ai/v2/docs/build/plugins) +## [Plugins](https://opencode.ai/v2/docs/build/plugins/overview) For questions about creating, configuring, loading, publishing, or migrating -plugins, fetch the full [plugins guide](https://opencode.ai/v2/docs/build/plugins) -before answering. This includes questions about the Effect plugin API, hooks, -transforms, tools, plugin context capabilities, and package entrypoints. +plugins, fetch the full [plugins guide](https://opencode.ai/v2/docs/build/plugins/overview) +before answering. For Effect-native plugins, also fetch the +[Effect plugin guide](https://opencode.ai/v2/docs/build/plugins/effect). For +terminal UI extensions, fetch the +[CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli). These guides +cover hooks, transforms, tools, plugin context capabilities, and package +entrypoints. ## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service) diff --git a/packages/www/AGENTS.md b/packages/www/AGENTS.md index c566e1b6550..6e2f764f005 100644 --- a/packages/www/AGENTS.md +++ b/packages/www/AGENTS.md @@ -11,6 +11,7 @@ ## Local development - Run `bun dev` from this package and use the local URL printed by Astro. +- Do not run `bun typecheck`, `bun run build`, or another Astro process while the dev server is running. They share the Vite dependency cache and can break the active dev server. Leave validation to the user when the dev server is active. ## Validation diff --git a/packages/www/src/docs/content/build/index.mdx b/packages/www/src/docs/content/build/index.mdx index f48974bd6c1..2df5d6c933c 100644 --- a/packages/www/src/docs/content/build/index.mdx +++ b/packages/www/src/docs/content/build/index.mdx @@ -1,9 +1,9 @@ --- -title: "Build" +title: "Intro" --- - + Build plugins that add tools, integrations, commands, agents, and custom behavior while keeping the rest of OpenCode intact. @@ -16,7 +16,3 @@ title: "Build" around it. - - - The plugin API, client, and SDK are still being finalized during beta and may change before OpenCode 2.0 is stable. - diff --git a/packages/www/src/docs/content/build/plugins.mdx b/packages/www/src/docs/content/build/plugins.mdx deleted file mode 100644 index ebcaee1f2a7..00000000000 --- a/packages/www/src/docs/content/build/plugins.mdx +++ /dev/null @@ -1,494 +0,0 @@ ---- -title: "Plugins" ---- - -Plugins extend OpenCode in-process. They can transform agents, models, commands, -integrations, references, skills, and tools; intercept model requests and tool -execution; and call a subset of the V2 client. - - - The V2 plugin API is beta. Entrypoints, hooks, draft shapes, and configuration may change before the stable release. - Use the `/v2` exports described on this page. - - -## Load plugins - -Plugins can be loaded from npm packages, explicit local paths, or config -directories. Each module must have one default export containing a unique -plugin `id` and a `setup` function. - -### Configuration - -Add ordered entries to the `plugins` field in `opencode.json(c)`: - -```jsonc title="opencode.jsonc" -{ - "$schema": "https://opencode.ai/config.json", - "plugins": [ - "opencode-acme-plugin@1.2.0", - "@acme/opencode-plugin", - "./plugins/local.ts", - { - "package": "./plugins/reviewer.ts", - "options": { - "agent": "reviewer", - "strict": true, - }, - }, - ], -} -``` - -A string is either a package specifier or a local path. Local paths must start -with `./` or `../` and resolve relative to the configuration file containing -the entry. Absolute paths and `file://` URLs are also supported. Both scoped -packages and versioned package specifiers are supported. - -Use the object form to pass JSON configuration to the plugin. OpenCode passes -`options` unchanged as `ctx.options`; omitted options become an empty object. -The plugin owns validation and defaults for its options. - -See [Config](/config#locations) for configuration locations and precedence. -Entries from all applicable files are processed from lowest to highest -precedence rather than replacing the entire array. - -### Local discovery - -OpenCode automatically scans this directory in every discovered OpenCode config -directory: - -```text -.opencode/plugins/ -``` - -The equivalent global directory is `~/.config/opencode/plugins/`. Direct `.ts` -and `.js` children are loaded. An immediate child directory is also loaded as a -package when OpenCode can resolve a string `exports`, `module`, or `main` -entrypoint, or an `index.ts` or `index.js` file. - -A `plugins/` directory beside a project-root `opencode.json` is not discovered -automatically. Put it under `.opencode/`, or add its file explicitly with a -relative config entry. - -### Enable and disable - -A string beginning with `-` disables plugins by their exported `id`. `*` -matches every ID, and a suffix of `.*` matches an ID prefix. Directives are -applied in order: - -```jsonc title="opencode.jsonc" -{ - "plugins": ["./plugins/reviewer.ts", "-acme.reviewer", "-opencode.provider.*", "opencode.provider.openai"], -} -``` - -Package specifiers and local paths locate plugin modules; they are not disable -selectors. Use the `id` from the plugin's default export to disable it. A later -ID entry re-enables a loaded or built-in plugin. Explicit config directives run -after local auto-discovery, so they can disable discovered plugins by ID. - -User plugins are activated in configured order between OpenCode's internal -plugin phases. Hooks run sequentially in registration order, and later hooks -observe earlier mutations. Do not depend on the internal phase ordering while -the API is beta. - -### Installation and dependencies - -OpenCode installs bare package entries and their production dependencies into -an isolated cache. Package installation does not run lifecycle scripts. -Published packages should expose their plugin entrypoint and include every -runtime import in `dependencies`. - -Install a package plugin globally with the CLI: - -```sh -opencode2 plugin add opencode-acme-plugin@1.2.0 -``` - -This installs and inspects the package before changing configuration. Packages -with a server entrypoint are added to global `opencode.json(c)`. Packages that -only expose `./tui` are added to global `cli.json` instead. - -The command accepts npm registry package names with an optional version, -dist-tag, or semver range. Configure local paths directly instead; Git, tarball, -and npm alias targets are not accepted by `plugin add`. - -List configured and active plugins, or remove a package from both global server -and TUI configuration: - -```sh -opencode2 plugin list -opencode2 plugin list --builtin -opencode2 plugin remove opencode-acme-plugin@1.2.0 -``` - -Built-in server plugins are hidden from the default list. Removing a plugin -keeps its package cache available for later reuse. - -Local files and local package directories are imported directly. OpenCode does -**not** install their dependencies. Install dependencies in a `package.json` -visible from the plugin file, for example: - -```sh -cd .opencode -bun add @opencode-ai/plugin@beta -``` - -Match the plugin package version to the OpenCode release you target. - -Configuration and discovered plugin files under watched config directories are -reloaded when they change. Reloading replaces the active plugin generation and -releases its scoped registrations. Restart OpenCode after changing an npm -package version or a local dependency when no watched file changed. - -## Create a plugin - -Export the result of `Plugin.define` as the module default: - -```ts title=".opencode/plugins/reviewer.ts" -import { Plugin } from "@opencode-ai/plugin" - -export default Plugin.define({ - id: "acme.reviewer", - setup: async (ctx) => { - const description = - typeof ctx.options.description === "string" ? ctx.options.description : "Reviews code for regressions" - - await ctx.agent.transform((agents) => { - agents.update("reviewer", (agent) => { - agent.description = description - agent.mode = "subagent" - }) - }) - }, -}) -``` - -`setup` runs each time the plugin is activated. Register long-lived behavior -during setup; do not wait there on an infinite event stream. It may return a -synchronous or asynchronous cleanup function. OpenCode awaits that cleanup -when the plugin is disabled, reloaded, or shut down: - -```ts -setup: async (ctx) => { - const controller = new AbortController() - const task = synchronize(ctx, controller.signal) - - return async () => { - controller.abort() - await task - } -} -``` - -Hook registrations are released automatically with the same plugin scope. Use -the returned cleanup for resources the plugin owns, such as timers, watchers, -connections, and background tasks. - -### Context - -The plugin context is essentially an [OpenCode server client](/build/client). -Its read and action methods use the same inputs and responses as the client. It -adds plugin-only methods for transforms, runtime hooks, reloads, registrations, -and plugin options. - -| Capability | Available operations | -| ---------------------- | -------------------------------------------------------------------------------------------- | -| `ctx.agent` | `list`, `get`, `transform`, `reload` | -| `ctx.catalog.provider` | `list`, `get` | -| `ctx.catalog.model` | `list`, `get`, `default` | -| `ctx.catalog` | `transform`, `reload` | -| `ctx.command` | `list`, `transform`, `reload` | -| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution | -| `ctx.plugin` | `list` currently active plugin IDs | -| `ctx.reference` | `list`, `transform`, `reload` | -| `ctx.session` | `create`, `get`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` | -| `ctx.skill` | `list`, `transform`, `reload` | -| `ctx.tool` | `transform` and `hook` | -| `ctx.aisdk` | `hook` | -| `ctx.event` | `subscribe` to the current public server event stream | -| `ctx.options` | Readonly options from the matching config object | - -### Transform hooks - -Transform hooks let a plugin modify how OpenCode is configured. Use them to add -or remove definitions, override settings, choose defaults, and provide tools or -other sources. - -| Transform | Draft operations | -| ----------------------- | ------------------------------------------------------------------------------------------------------- | -| `agent.transform` | `list`, `get`, `default`, `update`, `remove` | -| `catalog.transform` | Provider `list`, `get`, `update`, `remove`; model `get`, `update`, `remove`; default model `get`, `set` | -| `command.transform` | `list`, `get`, `update`, `remove` | -| `integration.transform` | Integration `list`, `get`, `update`, `remove`; method `list`, `update`, `remove` | -| `reference.transform` | `add`, `remove`, `list` | -| `skill.transform` | `source`, `list` | -| `tool.transform` | `add` | - -Here's an example that keeps models synced from a remote source: - -```js title=".opencode/plugins/remote-models.js" -import { Plugin } from "@opencode-ai/plugin" - -export default Plugin.define({ - id: "acme.remote-models", - setup: async (ctx) => { - let models = [] - - await ctx.catalog.transform((catalog) => { - for (const model of models) { - catalog.model.update(model.providerID, model.id, (draft) => Object.assign(draft, model)) - } - }) - - const refresh = async () => { - const response = await fetch("https://example.com/opencode/models.json", { - signal: AbortSignal.timeout(10_000), - }) - if (!response.ok) return - models = await response.json() - await ctx.catalog.reload() - } - - await refresh() - const timer = setInterval(() => void refresh().catch(console.error), 60_000) - return () => clearInterval(timer) - }, -}) -``` - -`ctx.catalog.reload()` replays every catalog transform to derive the new -catalog. Each plugin's logic remains composed with the others, so a later -plugin can still modify models added by an earlier one. The catalog updates -without restarting OpenCode. - -### Runtime hooks - -Runtime hooks intercept live operations: - -| Hook | Mutable fields | -| --------------------------------------------- | ------------------------------------------------------------------------------ | -| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` | -| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | -| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | -| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch | -| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds | -| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | -| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure | - -HTTP hooks can modify requests and responses. They apply to native models; AI -SDK models do not currently pass through these hooks. Request and response -bodies are one-shot streams. Use `clone()` when you intentionally need a -separate reader, but be aware that its slower branch may buffer data. To inspect -or modify chunks while preserving streaming, replace the body with one piped -through a `TransformStream`. - -```ts -await ctx.session.hook("http.request", (event) => { - event.request.headers.set("x-session-id", event.sessionID) -}) - -await ctx.session.hook("http.response", (event) => { - event.response = new Response(event.response.body, { - status: event.response.status, - headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" }, - }) -}) -``` - -For example, remove a tool from selected model requests and normalize another -tool's input: - -```ts title=".opencode/plugins/guards.ts" -import { Plugin } from "@opencode-ai/plugin" - -export default Plugin.define({ - id: "acme.guards", - setup: async (ctx) => { - await ctx.session.hook("context", (event) => { - delete event.tools.write - }) - - await ctx.tool.hook("execute.before", (event) => { - if (event.tool !== "lookup" || typeof event.input !== "object" || event.input === null) return - event.input = { ...event.input, source: "plugin" } - }) - }, -}) -``` - -A hook failure fails the operation it intercepts. Keep runtime hooks fast and -handle expected errors inside the callback. - -## Examples - -### Add a tool - -Register a structural tool definition with a name and registration options. -Define its input with JSON Schema and use an async executor: - -```js title=".opencode/plugins/greeting.js" -import { Plugin } from "@opencode-ai/plugin" - -export default Plugin.define({ - id: "acme.greeting", - setup: async (ctx) => { - await ctx.tool.transform((tools) => { - tools.add("greeting", { - description: "Create a greeting", - input: { - type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], - additionalProperties: false, - }, - output: { - type: "object", - properties: { greeting: { type: "string" } }, - required: ["greeting"], - additionalProperties: false, - }, - execute: async ({ name }) => { - const text = `Hello, ${name}!` - return { - output: { greeting: text }, - content: text, - } - }, - }) - }) - }, -}) -``` - -Unsupported characters in tool names are normalized to underscores. Namespace -segments must begin with a letter, contain at most 64 letters, digits, -underscores, or hyphens, and are joined with dots. Pass the optional third -argument to `tools.add` to configure the registration with -`{ namespace, codemode }`: - -- `namespace` prefixes and groups the exposed tool name. -- `codemode` defaults to `true` and makes the tool available through the - `execute` CodeMode tool. Set `codemode: false` to expose it directly to the - provider. - -The executor receives a second context argument containing `id`, `sessionID`, -`agent`, `messageID`, and `progress`. A tool with `output` -must return `output`; Effect and Standard Schema codecs validate it, while raw -JSON Schema definitions enforce JSON compatibility only. A tool -without `output` returns model-visible `content` instead. - -### Add a command - -```js title=".opencode/plugins/review-command.js" -import { Plugin } from "@opencode-ai/plugin" - -export default Plugin.define({ - id: "acme.review-command", - setup: async (ctx) => { - await ctx.command.transform((commands) => { - commands.update("review", (command) => { - command.description = "Review the current changes" - command.template = "Review the current changes for correctness and missing tests." - }) - }) - }, -}) -``` - -### Set the default model - -```js title=".opencode/plugins/default-model.js" -import { Plugin } from "@opencode-ai/plugin" - -export default Plugin.define({ - id: "acme.default-model", - setup: async (ctx) => { - await ctx.catalog.transform((catalog) => { - catalog.model.default.set("anthropic", "claude-sonnet-4-5") - }) - }, -}) -``` - -## Publish a package - -A package plugin uses the same default export as a local plugin. A minimal -manifest is: - -```json title="package.json" -{ - "name": "opencode-acme-plugin", - "version": "1.0.0", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./tui": "./src/tui.tsx" - }, - "dependencies": { - "@opencode-ai/plugin": "beta" - } -} -``` - -Packages with a TUI entrypoint should set `tui: true` on their server plugin -definition. A locally connected TUI loads the package's `./tui` export from the -existing OpenCode package cache. A TUI connected to a remote server skips it -when that package is not installed locally. - -Use versions compatible with the OpenCode release you target and test the -installed package, not only a workspace-linked copy. Because the plugin API is -beta, publish compatible plugin updates when V2 entrypoints or contracts -change. - -## Verify loading - -List active plugin IDs through the V2 API: - -```sh -opencode2 api get /api/plugin -``` - -If a plugin is absent, check the server log described in -[Troubleshooting](/troubleshooting#read-logs). Invalid modules and setup failures are -logged; one failing package does not prevent unrelated valid packages from -being resolved. - -## Effect - -OpenCode provides a first-class Effect API for plugins through the -`@opencode-ai/plugin/effect` entrypoint. Install `effect` alongside the -plugin package and export an `effect` function instead of `setup`: - -```sh -bun add @opencode-ai/plugin@beta effect -``` - -```ts title=".opencode/plugins/reviewer-effect.ts" -import { Plugin } from "@opencode-ai/plugin/effect" -import { Effect } from "effect" - -export default Plugin.define({ - id: "acme.reviewer-effect", - effect: (ctx) => - Effect.gen(function* () { - yield* ctx.agent.transform((agents) => { - agents.update("reviewer", (agent) => { - agent.description = "Reviews code for regressions" - agent.mode = "subagent" - }) - }) - }), -}) -``` - -Context operations return Effects. The plugin effect is scoped, so finalizers, -fibers, and registrations are released when the plugin reloads or unloads. -OpenCode does not expose its private Core services to the plugin; use the -capabilities on `ctx`. - -Typed tools can use `Schema` from `effect`. Effect and Promise plugins use the -same `tools.add(name, tool, options?)` registration shape. Effect executors -return an Effect and may fail with the typed tool failure channel. diff --git a/packages/www/src/docs/content/build/plugins/cli.mdx b/packages/www/src/docs/content/build/plugins/cli.mdx new file mode 100644 index 00000000000..f148e95dde3 --- /dev/null +++ b/packages/www/src/docs/content/build/plugins/cli.mdx @@ -0,0 +1,454 @@ +--- +title: "CLI" +--- + +CLI plugins extend the terminal with commands, routes, slots, Markdown renderers, notifications, and local state. + +```ts title="src/tui.ts" +import { Plugin } from "@opencode-ai/plugin/tui" + +export default Plugin.define({ + id: "acme.cli", + setup(context) { + context.ui.toast.show({ message: "CLI plugin loaded", variant: "success" }) + }, +}) +``` + +## Context + +`setup` receives configuration, app metadata, the current location, the OpenCode client, cached data, theme tokens, the +OpenTUI renderer, and the UI APIs documented below. + +```ts +setup(context) { + const compact = context.options.compact === true + const location = context.location ?? context.data.location.default() + const version = context.app.version + const channel = context.app.channel + const client = context.client + const renderer = context.renderer + const theme = context.theme +} +``` + +Return a cleanup function for resources owned by the plugin. + +```ts +setup(context) { + const stop = context.data.on("session.execution.succeeded", () => {}) + return () => stop() +} +``` + +## Client + +`context.client` is the generated OpenCode client and can call the connected server, including a remote server. + +```ts +const response = await context.client.plugin.list({ + location: context.location ?? context.data.location.default(), +}) +const plugins = response.data +``` + +## Events + +Use `data.on` for one typed event or `data.listen` for every server event; both return an unsubscribe function. + +```ts +const stopPermission = context.data.on("permission.asked", (event) => { + context.ui.toast.show({ message: `Permission ${event.data.id}` }) +}) +const stopAll = context.data.listen(({ details }) => console.log(details.type)) + +return () => { + stopPermission() + stopAll() +} +``` + +## Sessions + +Session data exposes list, lookup, hierarchy, cost, status, synchronization, and invalidation. + +```ts +const sessions = context.data.session.list() +const session = context.data.session.get(sessionID) +const rootID = context.data.session.root(sessionID) +const familyIDs = context.data.session.family(sessionID) +const cost = context.data.session.cost(sessionID) +const status = context.data.session.status(sessionID) +await context.data.session.sync(sessionID) +context.data.session.invalidate(sessionID) +``` + +Pending inbox items and messages have list, lookup, sync, and invalidate APIs. + +```ts +await context.data.session.pending.sync(sessionID) +const pending = context.data.session.pending.list(sessionID) +context.data.session.pending.invalidate(sessionID) + +await context.data.session.message.sync(sessionID) +const messages = context.data.session.message.list(sessionID) +const message = context.data.session.message.get(sessionID, messageID) +context.data.session.message.invalidate(sessionID) +``` + +Permission requests can be read and refreshed for a session. + +```ts +await context.data.session.permission.sync(sessionID) +const requests = context.data.session.permission.list(sessionID) ?? [] +context.data.session.permission.invalidate(sessionID) +``` + +Forms can be listed, refreshed, replied to, or cancelled at a location. + +```ts +import type { FormCancelInput, FormReplyInput } from "@opencode-ai/client" + +async function handleForm(reply: FormReplyInput, cancel: FormCancelInput) { + const location = context.location + await context.data.session.form.sync(sessionID, location) + const forms = context.data.session.form.list(sessionID, location) ?? [] + await context.data.session.form.reply(reply, location) + await context.data.session.form.cancel(cancel, location) + context.data.session.form.invalidate(sessionID, location) +} +``` + +## Projects and shells + +Projects and saved permissions support list, lookup, sync, and invalidate operations. + +```ts +await context.data.project.sync() +const projects = context.data.project.list() +const project = context.data.project.get(projectID) +context.data.project.invalidate() + +await context.data.project.permission.sync(projectID) +const saved = context.data.project.permission.list(projectID) ?? [] +context.data.project.permission.invalidate(projectID) +``` + +Shell data supports location-scoped list, lookup, sync, and invalidate operations. + +```ts +await context.data.shell.sync(context.location) +const shells = context.data.shell.list(context.location) +const shell = context.data.shell.get(shellID) +context.data.shell.invalidate(context.location) +``` + +## Location data + +Location state exposes the default location and refresh controls. + +```ts +const location = context.data.location.default() +await context.data.location.sync(location) +context.data.location.invalidate(location) +``` + +Version-control state exposes repository information at a location. + +```ts +await context.data.location.vcs.sync(context.location) +const vcs = context.data.location.vcs.info(context.location) +const branch = vcs?.branch.current +context.data.location.vcs.invalidate(context.location) +``` + +Agents, commands, integrations, models, providers, references, skills, and MCP data share `list`, `sync`, and +`invalidate` methods. + +```ts +const location = context.location +await Promise.all([ + context.data.location.agent.sync(location), + context.data.location.command.sync(location), + context.data.location.integration.sync(location), + context.data.location.model.sync(location), + context.data.location.provider.sync(location), + context.data.location.reference.sync(location), + context.data.location.skill.sync(location), + context.data.location.mcp.server.sync(location), + context.data.location.mcp.resource.sync(location), +]) + +const agents = context.data.location.agent.list(location) ?? [] +const commands = context.data.location.command.list(location) ?? [] +const integrations = context.data.location.integration.list(location) ?? [] +const models = context.data.location.model.list(location) ?? [] +const providers = context.data.location.provider.list(location) ?? [] +const references = context.data.location.reference.list(location) ?? [] +const skills = context.data.location.skill.list(location) ?? [] +const servers = context.data.location.mcp.server.list(location) ?? [] +const resources = context.data.location.mcp.resource.list(location) ?? [] + +context.data.location.model.invalidate(location) +``` + +## Attention + +Attention requests can show a system notification, play a configured sound, or do both based on terminal focus. + +```ts +const result = await context.attention.notify({ + title: "OpenCode", + message: "Session done", + notification: { when: "blurred" }, + sound: { name: "done", volume: 0.5, when: "always" }, +}) +console.log(result.ok, result.notification, result.sound, result.skipped) +``` + +## Theme and renderer + +Use semantic theme tokens with OpenTUI elements and pass `context.renderer` to renderer-specific helpers. + +```tsx +const Status = () => Ready +const renderer = context.renderer +``` + +## Solid components + +Use `usePlugin` to access the current context inside JSX rendered by a route, dialog, or slot. + +```tsx +import { usePlugin } from "@opencode-ai/plugin/tui" + +function Status() { + const context = usePlugin() + return {context.app.version} +} +``` + +## Markdown + +Register a fenced-code renderer by language; the returned function unregisters it. + +```ts +const unregister = context.markdown.registerCodeBlockRenderer( + "acme", + (_token, render) => render.defaultRender(), +) +return unregister +``` + +## Commands and keymaps + +Register palette, slash, and keyboard commands in a reactive keymap layer. + +```ts +context.keymap.layer(() => ({ + mode: "global", + priority: 10, + commands: [ + { + id: "acme.status", + title: "Show Acme status", + group: "Acme", + bind: "ctrl+g", + palette: true, + slash: { name: "acme", aliases: ["status"], arguments: true }, + enabled: () => true, + suggested: true, + run: async (input) => context.ui.toast.show({ message: input ?? "Ready" }), + }, + ], + bindings: ["acme.status"], +})) +``` + +A layer may target one OpenTUI renderable and can return `false` from a command to continue keyboard dispatch. + +```ts +context.keymap.layer(() => ({ + target: () => panel, + commands: [{ bind: "escape", run: (_input, event) => (event ? false : undefined) }], +})) +``` + +Dispatch commands, inspect shortcuts and command state, or push a temporary input mode. + +```ts +context.keymap.dispatch("acme.status", "verbose") +const shortcuts = context.keymap.shortcuts("acme.status") +const commands = context.keymap.commands() +const pending = context.keymap.pending() +const active = context.keymap.active() +const currentMode = context.keymap.mode.current() +const popMode = context.keymap.mode.push("acme-search") +popMode() +``` + +## Storage + +Durable storage persists JSON across restarts and synchronizes across TUI instances. + +```ts +const [settings, updateSettings] = context.storage.store("settings", { + initial: { compact: false }, +}) +await updateSettings((draft) => { + draft.compact = true +}) +``` + +Memory storage survives plugin reloads but is discarded when the TUI exits. + +```ts +const [state, updateState] = context.storage.memory("state", { + initial: { count: 0 }, +}) +updateState((draft) => { + draft.count++ +}) +``` + +## Dialogs and toasts + +Use promise-based dialogs for alerts, confirmations, text input, and selection. + +```ts +await context.ui.dialog.alert({ title: "Acme", message: "Ready" }) +const confirmed = await context.ui.dialog.confirm({ + title: "Continue?", + message: "Run the Acme action?", + label: { confirm: "Run", cancel: "Cancel" }, +}) +const name = await context.ui.dialog.prompt({ title: "Name", placeholder: "release" }) +const mode = await context.ui.dialog.select({ + title: "Mode", + current: "safe", + options: [ + { title: "Safe", value: "safe", description: "Ask before changes" }, + { title: "Fast", value: "fast", disabled: false, category: "Advanced" }, + ], +}) +``` + +Custom JSX dialogs can set their size and close themselves. + +```tsx +context.ui.dialog.set({ size: "large", centered: true }) +context.ui.dialog.show(() => Acme, () => console.log("closed")) +context.ui.dialog.clear() +``` + +Toasts support title, message, variant, and duration. + +```ts +context.ui.toast.show({ + title: "Acme", + message: "Saved", + variant: "success", + duration: 3000, +}) +``` + +## Routes and tabs + +Register a JSX route, inspect the current route, and navigate to home, a session, or the plugin page. + +```tsx +const unregister = context.ui.router.register({ + name: "dashboard", + render: ({ data }) => {String(data?.title ?? "Acme")}, +}) +const current = context.ui.router.current() +context.ui.router.navigate({ type: "plugin", name: "dashboard", data: { title: "Status" } }) +context.ui.router.navigate({ type: "session", sessionID }) +context.ui.router.navigate({ type: "home" }) +return unregister +``` + +Tabs can be listed, opened, focused, and closed when session tabs are enabled. + +```ts +if (context.ui.tabs.enabled()) { + context.ui.tabs.open(sessionID) + const tabs = context.ui.tabs.list() + context.ui.tabs.focus(sessionID) + context.ui.tabs.close(sessionID) + context.ui.tabs.close() +} +``` + +## Slots + +Slots insert or replace JSX at `app`, `home.footer`, `prompt.footer`, `prompt.footer.status`, `prompt.footer.file`, +`session.composer.top`, `sidebar.content`, or `sidebar.footer`. + +```tsx +return context.ui.slot({ + append: "sidebar.content", + render: ({ sessionID }) => {context.data.session.get(sessionID)?.title}, +}) +``` + +Use `prepend`, `append`, `before`, `after`, or `replace` for placement. + +```tsx +context.ui.slot({ prepend: "home.footer", render: () => Before footer content }) +context.ui.slot({ append: "home.footer", render: () => After footer content }) +context.ui.slot({ before: "home.footer", render: () => Before footer slot }) +context.ui.slot({ after: "home.footer", render: () => After footer slot }) +context.ui.slot({ replace: "home.footer", render: () => New footer }) +``` + +## Formatting + +Format filesystem paths for display, including home-directory abbreviation. + +```ts +const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project") +``` + +## Publish and load + +Expose the CLI plugin through `./tui`; add OpenTUI peers when the plugin renders JSX. + +```json title="package.json" +{ + "name": "opencode-acme-plugin", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./tui": "./src/tui.tsx" + }, + "dependencies": { + "@opencode-ai/plugin": "beta" + }, + "peerDependencies": { + "@opentui/core": ">=0.5.8", + "@opentui/solid": ">=0.5.8", + "solid-js": ">=1.9.0" + } +} +``` + +Set `tui: true` on the [main plugin](/build/plugins/overview) for automatic loading. + +```ts title="src/index.ts" +import { Plugin } from "@opencode-ai/plugin" + +export default Plugin.define({ + id: "acme.server", + tui: true, + setup() {}, +}) +``` + +Configure a CLI-only package in [`cli.json`](/cli/plugins) so it remains active against remote servers. + +```json title="cli.json" +{ + "plugins": ["opencode-acme-plugin"] +} +``` diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx new file mode 100644 index 00000000000..51aaa298104 --- /dev/null +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -0,0 +1,1182 @@ +--- +title: "Effect" +--- + +`@opencode-ai/plugin/effect` is the Effect-native version of the OpenCode plugin API. Its context operations return +Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`. Install `effect` with the +plugin package. + +```sh +bun add @opencode-ai/plugin@beta effect +``` + +Export an Effect plugin from `.opencode/plugins/` to load it automatically. + +```ts title=".opencode/plugins/concise.ts" +import { Plugin } from "@opencode-ai/plugin/effect" +import { Effect } from "effect" + +export default Plugin.define({ + id: "example", + effect: (ctx) => + Effect.gen(function* () { + const storage = ctx.storage + yield* storage.set("loaded", true) + }), +}) +``` + +Published packages and files outside `.opencode/plugins/` use the same `plugins` configuration as other server +plugins. + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "plugins": [ + "opencode-acme-effect-plugin", + "opencode-acme-effect-plugin@1.2.0", + "@acme/opencode-effect-plugin", + "./plugins/local-effect.ts", + { + "package": "@acme/opencode-effect-plugin", + "options": { "agent": "reviewer", "strict": true } + } + ] +} +``` + +See [Configure plugins](/plugins) for enablement, package resolution, and configuration precedence. + +```ts title="plugins/local-effect.ts" +import { Plugin } from "@opencode-ai/plugin/effect" +import { Effect } from "effect" + +export default Plugin.define({ + id: "local-effect", + effect: (ctx) => + Effect.gen(function* () { + yield* Effect.logInfo("Effect plugin loaded", { version: ctx.app.version }) + }), +}) +``` + +## Lifecycle + +The `effect` runs when the plugin loads. Its scope closes when the plugin reloads or unloads, so registrations, scoped +fibers, and finalizers are released together. + +```ts +import { Plugin } from "@opencode-ai/plugin/effect" +import { Effect } from "effect" + +export default Plugin.define({ + id: "lifecycle", + effect: (ctx) => + Effect.gen(function* () { + yield* Effect.logInfo("loaded", { version: ctx.app.version }) + yield* Effect.addFinalizer(() => Effect.logInfo("unloaded")) + }), +}) +``` + +Use scoped fibers for background work. The plugin scope interrupts the fiber during cleanup. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const storage = ctx.storage + yield* Effect.repeat( + storage.set("heartbeat", { time: Date.now() }), + { schedule: Schedule.spaced("1 minute") }, + ).pipe(Effect.forkScoped) + }), +``` + +### Context + +The context exposes the Effect client for the connected OpenCode server plus plugin-only transforms, hooks, storage, +reloads, and options. It does not expose OpenCode's private Core services. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const plugins = ctx.plugin + const active = yield* plugins.list().pipe(Effect.orDie) + yield* Effect.logInfo("active plugins", { count: active.data.length }) + }), +``` + +The plugin definition and complete context are declared as follows. + +```ts +interface Context { + readonly app: App + readonly options: PluginOptions + readonly agent: AgentDomain + readonly catalog: CatalogDomain + readonly command: CommandDomain + readonly event: EventDomain + readonly integration: IntegrationDomain + readonly mcp: MCPDomain + readonly plugin: PluginApi + readonly reference: ReferenceDomain + readonly session: SessionDomain + readonly shell: ShellDomain + readonly skill: SkillDomain + readonly storage: StorageDomain + readonly tool: ToolDomain + readonly websearch: WebSearchDomain +} + +interface Plugin { + readonly id: string + readonly tui?: boolean + readonly effect: (context: Context) => Effect.Effect +} +``` + +### Options + +Pass options with the object form in `opencode.json(c)`. + +```jsonc title="opencode.jsonc" +{ + "plugins": [ + { + "package": "./plugins/company-effect.ts", + "options": { "strict": true } + } + ] +} +``` + +Read options from `ctx.options`. Narrow unknown values before use. + +```ts title="plugins/company-effect.ts" +import { Plugin } from "@opencode-ai/plugin/effect" +import { Effect } from "effect" + +export default Plugin.define({ + id: "company", + effect: (ctx) => + Effect.gen(function* () { + const strict = ctx.options.strict === true + yield* Effect.logInfo("company plugin configured", { strict }) + }), +}) +``` + +## Transforms + +Transforms synchronously edit a mutable draft. OpenCode applies transforms in plugin order, so later transforms see +earlier changes. Yielding the registration keeps it in the plugin scope. + +```ts title="plugins/models-effect.ts" +import { Plugin } from "@opencode-ai/plugin/effect" +import { Effect } from "effect" + +export default Plugin.define({ + id: "company.models", + effect: (ctx) => + Effect.gen(function* () { + const catalog = ctx.catalog + yield* catalog.transform((draft) => { + draft.model.update("acme", "reasoner", (model) => { + model.name = "Acme Reasoner" + model.cost = [{ input: 2, output: 12, cache: { read: 0.2, write: 2 } }] + }) + }) + }), +}) +``` + +A later transform can enforce policy across the composed catalog. + +```ts title="plugins/model-budget-effect.ts" +effect: (ctx) => + Effect.gen(function* () { + const catalog = ctx.catalog + yield* catalog.transform((draft) => { + for (const provider of draft.provider.list()) { + for (const model of provider.models.values()) { + if (model.cost.some((tier) => tier.output > 20)) { + draft.model.remove(model.providerID, model.id) + } + } + } + }) + }), +``` + +Call `reload` when external state used by a transform changes. Reload replays every transform in order. + +```ts title="plugins/models-effect.ts" +effect: (ctx) => + Effect.gen(function* () { + const catalog = ctx.catalog + const state = { models: yield* loadFromSource() } + + yield* catalog.transform((draft) => { + for (const item of state.models) { + draft.model.update(item.providerID, item.id, (model) => { + model.name = item.name + }) + } + }) + + yield* Effect.repeat( + Effect.gen(function* () { + state.models = yield* loadFromSource() + yield* catalog.reload() + }), + { schedule: Schedule.spaced("1 minute") }, + ).pipe(Effect.forkScoped) + }), +``` + +## API + +### Agent + +Read all agents or fetch one by ID. Client responses include the resolved location and schema data. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const agent = ctx.agent + const agents = yield* agent.list().pipe(Effect.orDie) + const build = yield* agent.get({ agentID: Agent.ID.make("build") }).pipe(Effect.orDie) + yield* Effect.logInfo("agents", { count: agents.data.length, build: build.data.name }) + }), +``` + +Transform or reload agents. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const agent = ctx.agent + yield* agent.transform((draft) => { + draft.default("build") + draft.update("build", (item) => (item.description = "Builds features and fixes bugs")) + draft.remove("legacy") + }) + yield* agent.reload() + }), +``` + +Schema: [`Agent.Info`](/api#schema-Agent.Info). + +```ts +interface AgentDraft { + list(): readonly Types.DeepMutable[] + get(id: string): Types.DeepMutable | undefined + default(id: string | undefined): void + update(id: string, update: (agent: Types.DeepMutable) => void): void + remove(id: string): void +} + +interface AgentDomain extends AgentApi { + readonly transform: Transform + readonly reload: () => Effect.Effect +} +``` + +### Catalog + +Read providers, models, and the default model. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const catalog = ctx.catalog + const providers = yield* catalog.provider.list().pipe(Effect.orDie) + const anthropic = yield* catalog.provider.get({ providerID: Provider.ID.make("anthropic") }).pipe(Effect.orDie) + const models = yield* catalog.model.list().pipe(Effect.orDie) + const selected = yield* catalog.model.default().pipe(Effect.orDie) + yield* Effect.logInfo("catalog", { + providers: providers.data.length, + models: models.data.length, + anthropic: anthropic.data.name, + selected: selected.data?.name, + }) + }), +``` + +Transform providers and models, then reload after source data changes. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const catalog = ctx.catalog + yield* catalog.transform((draft) => { + draft.provider.update("anthropic", (provider) => (provider.name = "Anthropic")) + draft.model.update("anthropic", "claude-sonnet-4-5", (model) => (model.name = "Claude Sonnet 4.5")) + draft.model.default.set("anthropic", "claude-sonnet-4-5") + draft.model.remove("anthropic", "legacy-model") + draft.provider.remove("legacy-provider") + }) + yield* catalog.reload() + }), +``` + +Schemas: [`Provider.Info`](/api#schema-Provider.Info), [`Model.Info`](/api#schema-Model.Info). + +```ts +interface CatalogProviderRecord { + readonly provider: Types.DeepMutable + readonly models: ReadonlyMap> +} + +interface CatalogDraft { + readonly provider: { + list(): readonly CatalogProviderRecord[] + get(providerID: string): CatalogProviderRecord | undefined + update(providerID: string, update: (provider: Types.DeepMutable) => void): void + remove(providerID: string): void + } + readonly model: { + get(providerID: string, modelID: string): Types.DeepMutable | undefined + update(providerID: string, modelID: string, update: (model: Types.DeepMutable) => void): void + remove(providerID: string, modelID: string): void + readonly default: { + get(): { providerID: string; modelID: string } | undefined + set(providerID: string, modelID: string): void + } + } +} + +interface CatalogDomain extends CatalogApi { + readonly transform: Transform + readonly reload: () => Effect.Effect +} +``` + +### Commands + +Read commands available at the current location. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const command = ctx.command + const commands = yield* command.list().pipe(Effect.orDie) + yield* Effect.logInfo("commands", { names: commands.data.map((item) => item.name) }) + }), +``` + +The current command transform is add-only. An executor receives the session, prompt attachments, and delivery mode. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const command = ctx.command + const session = ctx.session + yield* command.transform((draft) => { + draft.add({ + name: "security-review", + description: "Review changes for security issues", + execute: (input) => + session.prompt({ + ...input.prompt, + sessionID: input.sessionID, + text: `Review these changes for security issues.\n\n${input.prompt.text}`, + delivery: input.delivery, + }).pipe(Effect.asVoid), + }) + }) + yield* command.reload() + }), +``` + +Schemas: [`Command.Info`](/api#schema-Command.Info), +[`Session.Inbox.Delivery`](/api#schema-Session.Inbox.Delivery). + +```ts +interface CommandInvocation { + readonly sessionID: Session.ID + readonly prompt: PromptInput.Prompt + readonly delivery: SessionInbox.Delivery +} + +interface CommandDefinition { + readonly name: string + readonly description?: string + readonly execute: (input: CommandInvocation) => Effect.Effect +} + +interface CommandDraft { + add(definition: CommandDefinition): void +} + +interface CommandDomain extends Pick, "list"> { + readonly transform: Transform + readonly reload: () => Effect.Effect +} +``` + +### Integrations + +Read integrations and resolve the active credential when one exists. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const integration = ctx.integration + const integrations = yield* integration.list().pipe(Effect.orDie) + const github = yield* integration.get({ integrationID: Integration.ID.make("github") }).pipe(Effect.orDie) + const connection = yield* integration.connection.active("github") + const credential = connection ? yield* integration.connection.resolve(connection).pipe(Effect.orDie) : undefined + yield* Effect.logInfo("integration", { + count: integrations.data.length, + github: github.data.name, + credential: credential?.type, + }) + }), +``` + +Connect with a key or drive an OAuth attempt through the connected API. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const integration = ctx.integration + const token = yield* Config.redacted("GITHUB_TOKEN").pipe(Effect.orDie) + yield* integration.connect + .key({ integrationID: Integration.ID.make("github"), key: Redacted.value(token) }) + .pipe(Effect.orDie) + + const attempt = yield* integration.oauth + .connect({ + integrationID: Integration.ID.make("acme"), + methodID: Integration.MethodID.make("oauth"), + }) + .pipe(Effect.orDie) + const status = yield* integration.oauth + .status({ + integrationID: Integration.ID.make("acme"), + attemptID: attempt.data.attemptID, + }) + .pipe(Effect.orDie) + yield* Effect.logInfo("OAuth status", { status: status.data }) + }), +``` + +Transform integrations and authentication methods. OAuth method callbacks are Effects and may acquire scoped +resources. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const integration = ctx.integration + yield* integration.transform((draft) => { + draft.update("acme", (item) => (item.name = "Acme")) + draft.method.update({ + integrationID: "acme", + method: { id: "device", type: "oauth", label: "Sign in with Acme" }, + authorize: () => + Effect.succeed({ + mode: "code", + url: "https://acme.example/device", + instructions: "Enter the displayed code", + callback: (code) => exchangeCode(code), + }), + }) + }) + yield* integration.reload() + }), +``` + +Schemas: [`Integration.Info`](/api#schema-Integration.Info), [`Integration.Method`](/api#schema-Integration.Method), +[`Connection.Info`](/api#schema-Connection.Info), [`Form.Answer`](/api#schema-Form.Answer). + +```ts +type IntegrationOAuthAuthorization = { + readonly url: string + readonly instructions: string + readonly expiresAt?: number +} & ( + | { readonly mode: "auto"; readonly callback: Effect.Effect } + | { readonly mode: "code"; readonly callback: (code: string) => Effect.Effect } +) + +type IntegrationOAuthMethodRegistration = { + readonly integrationID: string + readonly method: IntegrationOAuthMethod + readonly authorize: (answer: Form.Answer) => Effect.Effect + readonly refresh?: (credential: Credential.OAuth) => Effect.Effect + readonly label?: (credential: Credential.OAuth) => string | undefined +} + +interface IntegrationDraft { + list(): readonly IntegrationRef[] + get(id: string): IntegrationRef | undefined + update(id: string, update: (integration: IntegrationRef) => void): void + remove(id: string): void + readonly method: { + list(integrationID: string): readonly IntegrationMethod[] + update(input: IntegrationMethodRegistration): void + remove(integrationID: string, method: IntegrationMethod): void + } +} + +interface IntegrationDomain extends Omit, "wellknown"> { + readonly transform: Transform + readonly reload: () => Effect.Effect + readonly connection: { + readonly active: (integrationID: string) => Effect.Effect + readonly resolve: (connection: ConnectionInfo) => Effect.Effect + } +} +``` + +### MCP + +List servers and change connection state. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const mcp = ctx.mcp + const servers = yield* mcp.list().pipe(Effect.orDie) + yield* mcp.connect({ server: "docs" }).pipe(Effect.orDie) + yield* mcp.disconnect({ server: "docs" }).pipe(Effect.orDie) + yield* Effect.logInfo("MCP servers", { count: servers.data.length }) + }), +``` + +Add servers through the API, or transform and reload their configuration. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const mcp = ctx.mcp + yield* mcp + .add({ server: "docs", config: { type: "remote", url: "https://mcp.example.com" } }) + .pipe(Effect.orDie) + yield* mcp.transform((draft) => { + draft.update("docs", (server) => (server.disabled = false)) + draft.remove("legacy") + }) + yield* mcp.reload() + }), +``` + +Schemas: [`Mcp.Server`](/api#schema-Mcp.Server), [`Mcp.LocalConfigEncoded`](/api#schema-Mcp.LocalConfigEncoded), +[`Mcp.RemoteConfigEncoded`](/api#schema-Mcp.RemoteConfigEncoded). + +```ts +interface MCPDraft { + list(): readonly [string, Types.DeepMutable][] + get(name: string): Types.DeepMutable | undefined + set(name: string, config: Mcp.ServerConfig): void + update(name: string, update: (config: Types.DeepMutable) => void): void + remove(name: string): void +} + +interface MCPDomain extends Omit, "resource"> { + readonly transform: Transform + readonly reload: () => Effect.Effect +} +``` + +### Plugins + +List active, failed, and resolved plugins at the current location. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const plugin = ctx.plugin + const plugins = yield* plugin.list().pipe(Effect.orDie) + yield* Effect.forEach(plugins.data, (item) => Effect.logInfo("plugin", item), { discard: true }) + }), +``` + +Filter the schema values in an Effect pipeline when only active plugins matter. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const plugin = ctx.plugin + const active = yield* plugin.list().pipe( + Effect.orDie, + Effect.map((result) => result.data.filter((item) => item.status === "active")), + ) + yield* Effect.logInfo("active plugin count", { count: active.length }) + }), +``` + +Schemas: [`Plugin.Info`](/api#schema-Plugin.Info), [`Plugin.Source`](/api#schema-Plugin.Source). + +```ts +interface PluginApi { + readonly list: PluginListOperation +} + +interface Context { + readonly plugin: PluginApi +} +``` + +### References + +Read references available at the current location. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const reference = ctx.reference + const references = yield* reference.list().pipe(Effect.orDie) + yield* Effect.logInfo("references", { count: references.data.length }) + }), +``` + +Add or remove local and Git references, then reload after external state changes. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const reference = ctx.reference + yield* reference.transform((draft) => { + draft.add("handbook", { type: "local", path: "/workspace/docs/handbook" }) + draft.add("standards", { type: "git", repository: "https://github.com/acme/standards", branch: "main" }) + draft.remove("legacy") + }) + yield* reference.reload() + }), +``` + +Schemas: [`Reference.Info`](/api#schema-Reference.Info), [`Reference.LocalSource`](/api#schema-Reference.LocalSource), +[`Reference.GitSource`](/api#schema-Reference.GitSource). + +```ts +interface ReferenceDraft { + add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void + remove(name: string): void + list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] +} + +interface ReferenceDomain extends ReferenceApi { + readonly transform: Transform + readonly reload: () => Effect.Effect +} +``` + +### Sessions + +Create or read a session, then select the agent and model used by later work. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const session = ctx.session + const created = yield* session.create({ title: "Review" }).pipe(Effect.orDie) + const current = yield* session.get({ sessionID: created.id }).pipe(Effect.orDie) + yield* session.switchAgent({ sessionID: current.id, agent: Agent.ID.make("build") }).pipe(Effect.orDie) + yield* session + .switchModel({ + sessionID: current.id, + model: { providerID: Provider.ID.make("anthropic"), id: Model.ID.make("claude-sonnet-4-5") }, + }) + .pipe(Effect.orDie) + }), +``` + +Send prompts, transient generation requests, commands, or synthetic messages. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const session = ctx.session + const created = yield* session.create({ title: "Automation" }).pipe(Effect.orDie) + yield* session.prompt({ sessionID: created.id, text: "Review the current changes" }).pipe(Effect.orDie) + const summary = yield* session + .generate({ sessionID: created.id, prompt: "Summarize this project" }) + .pipe(Effect.orDie) + yield* session.command({ sessionID: created.id, command: "review", arguments: "--staged" }).pipe(Effect.orDie) + yield* session + .synthetic({ sessionID: created.id, text: `Summary generated: ${summary.text}`, resume: false }) + .pipe(Effect.orDie) + }), +``` + +Schemas: [`Session.Info`](/api#schema-Session.Info), [`Model.Ref`](/api#schema-Model.Ref), +[`Session.Inbox.User`](/api#schema-Session.Inbox.User), +[`Session.Inbox.Synthetic`](/api#schema-Session.Inbox.Synthetic). + +```ts +type SessionDomain = Pick< + SessionApi, + | "create" + | "get" + | "switchAgent" + | "switchModel" + | "prompt" + | "generate" + | "command" + | "synthetic" + | "interrupt" + | "rename" + | "wait" +> +``` + +### Skills + +Read skills at the current location. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const skill = ctx.skill + const skills = yield* skill.list() + yield* Effect.logInfo("skills", { ids: skills.data.map((item) => item.id) }) + }), +``` + +Transform and reload skills. Use the re-exported Effect schema constructors for branded values. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const skill = ctx.skill + yield* skill.transform((draft) => { + draft.add(Skill.Info.make({ + id: Skill.ID.make("review"), + name: Skill.Name.make("Review"), + description: "Review the current changes", + location: "/workspace/.opencode/skills/review.md", + content: "Review the current changes for correctness and missing tests.", + })) + draft.update("review", (item) => (item.autoinvoke = true)) + draft.remove("legacy") + }) + yield* skill.reload() + }), +``` + +Schema: [`Skill.Info`](/api#schema-Skill.Info). + +```ts +interface SkillDraft { + list(): readonly Types.DeepMutable[] + add(skill: Skill.Info): void + update(id: string, update: (skill: Types.DeepMutable) => void): void + remove(id: string): void +} + +interface SkillDomain extends SkillApi { + readonly transform: Transform + readonly reload: () => Effect.Effect +} +``` + +### Storage + +Store, read, and remove durable JSON values scoped to the plugin ID. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const storage = ctx.storage + yield* storage.set("settings", { strict: true }) + const settings = yield* storage.get("settings") + yield* Effect.logInfo("settings", { settings }) + yield* storage.remove("settings") + }), +``` + +Scan keys by prefix with cursor pagination. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const storage = ctx.storage + const first = yield* storage.scan({ prefix: "cache/", limit: 100 }) + const second = first.next + ? yield* storage.scan({ prefix: "cache/", after: first.next, limit: 100 }) + : { entries: [] } + yield* Effect.logInfo("cache entries", { count: first.entries.length + second.entries.length }) + }), +``` + +Storage accepts Effect's JSON type and returns Effects directly. + +```ts +interface StorageDomain { + readonly get: (key: string) => Effect.Effect + readonly set: (key: string, value: Schema.Json) => Effect.Effect + readonly remove: (key: string) => Effect.Effect + readonly scan: (options: StorageScanOptions) => Effect.Effect +} +``` + +### Tools + +Register typed tools with Effect `Schema`. The executor receives decoded input and returns an Effect containing typed +output, display content, or metadata. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const tool = ctx.tool + yield* tool.transform((draft) => { + draft.add({ + name: "greeting", + description: "Create a greeting", + input: Schema.Struct({ name: Schema.String }), + output: Schema.Struct({ greeting: Schema.String }), + options: { namespace: "acme", codemode: true }, + execute: ({ name }, context) => + Effect.gen(function* () { + yield* context.progress({ status: "greeting" }) + return { output: { greeting: `Hello ${name}!` } } + }), + }) + }) + }), +``` + +Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent), +[`Tool.FileContent`](/api#schema-Tool.FileContent). + +```ts +interface ToolDraft { + add, Output extends Tool.ValueSchema | undefined>( + tool: Tool.Info, + ): void +} + +interface ToolDomain { + readonly transform: Transform +} +``` + +### Websearch + +List providers or run a query through the selected provider. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const websearch = ctx.websearch + const providers = yield* websearch.providers().pipe(Effect.orDie) + const results = yield* websearch + .query({ query: "OpenCode plugins", providerID: WebSearch.ID.make("internal") }) + .pipe(Effect.orDie) + yield* Effect.logInfo("websearch", { providers: providers.data.length, results: results.data.results.length }) + }), +``` + +Register an Effect executor and select the default provider. Set the default to `false` to disable websearch. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const websearch = ctx.websearch + yield* websearch.transform((draft) => { + draft.add({ + id: "internal", + name: "Internal search", + execute: ({ query }) => searchInternal(query), + }) + draft.default.set("internal") + }) + yield* websearch.reload() + }), +``` + +Schemas: [`WebSearch.Provider`](/api#schema-WebSearch.Provider), +[`WebSearch.Result`](/api#schema-WebSearch.Result). + +```ts +interface WebSearchDefinition { + readonly id: string + readonly name: string + readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect +} + +interface WebSearchDraft { + add(definition: WebSearchDefinition): void + readonly default: { + get(): string | false | undefined + set(selection: string | false): void + } +} + +interface WebSearchDomain extends WebsearchApi { + readonly transform: Transform + readonly reload: () => Effect.Effect +} +``` + +### Events + +The public server event subscription is an Effect `Stream`. Fork its consumer in the plugin scope for automatic +interruption. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const event = ctx.event + yield* event.subscribe().pipe( + Stream.tap((item) => Effect.logDebug("OpenCode event", { type: item.type })), + Stream.runDrain, + Effect.forkScoped, + ) + }), +``` + +Use Stream operators to select and process event types. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const event = ctx.event + yield* event.subscribe().pipe( + Stream.filter((item) => item.type === "config.updated"), + Stream.runForEach(() => Effect.logInfo("configuration changed")), + Effect.forkScoped, + ) + }), +``` + +Schema: [`V2EventEncoded`](/api#schema-V2EventEncoded). + +```ts +type EventSubscribeOutput = OpenCodeEvent +type EventSubscribeOperation = () => Stream.Stream + +interface EventApi { + readonly subscribe: EventSubscribeOperation +} + +interface EventDomain extends Pick, "subscribe"> {} +``` + +## Hooks + +Hooks intercept live operations. Multiple plugins can register the same hook; OpenCode runs them in plugin order so +later hooks see earlier changes. Registrations remain active for the plugin scope. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const session = ctx.session + yield* session.hook("context", () => Effect.void) + }), +``` + +### Sessions + +Modify assembled system instructions, messages, or tools immediately before model dispatch. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const session = ctx.session + yield* session.hook("context", (event) => + Effect.sync(() => { + event.system.push({ text: "Keep the review focused on correctness." }) + delete event.tools.write + }), + ) + }), +``` + +Modify model request settings and optionally scope the hook to one provider. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const session = ctx.session + yield* session.hook( + "model.request", + (event) => Effect.sync(() => (event.headers["x-plugin"] = "review")), + { providerID: "anthropic" }, + ) + }), +``` + +Modify native provider requests or responses. Their bodies are one-shot streams; clone or replace a body before reading +it. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const session = ctx.session + yield* session.hook("http.request", (event) => + Effect.sync(() => event.request.headers.set("x-session-id", event.sessionID)), + ) + yield* session.hook("http.response", (event) => + Effect.sync(() => { + event.response = new Response(event.response.body, { + status: event.response.status, + headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "review" }, + }) + }), + ) + }), +``` + +#### Reference + +```ts +interface SessionHooks { + readonly context: SessionContext + readonly "model.request": SessionModelRequest + readonly "http.request": SessionHttpRequest + readonly "http.response": SessionHttpResponse +} + +interface SessionHookDomain { + readonly hook: ModelHooks +} +``` + +### Shell + +Modify shell commands, working directories, timeouts, executables, or environment variables before execution. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const shell = ctx.shell + yield* shell.hook("create.before", (event) => + Effect.sync(() => { + if (event.command === "npm") event.command = "bun" + event.timeout = Math.min(event.timeout, 60_000) + event.env.COMPANY_ENV = "development" + }), + ) + }), +``` + +#### Reference + +```ts +interface ShellCreateBefore { + command: string + cwd: string + timeout: number + shell: string + env: Record +} + +interface ShellHooks { + readonly "create.before": ShellCreateBefore +} + +interface ShellHookDomain { + readonly hook: Hooks +} +``` + +### Tools + +Before hooks may replace input or fail with `Tool.Error`. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const tool = ctx.tool + yield* tool.hook("execute.before", (event) => + event.tool === "write" + ? Effect.fail(new Tool.Error({ message: "Writes are disabled" })) + : Effect.logDebug("tool input", { tool: event.tool, input: event.input }), + ) + }), +``` + +After hooks may inspect or replace successful results and failures. + +```ts +effect: (ctx) => + Effect.gen(function* () { + const tool = ctx.tool + yield* tool.hook("execute.after", (event) => { + if (event.status === "error") return Effect.logWarning("tool failed", { message: event.error.message }) + return Effect.sync(() => { + event.result = { ...event.result, metadata: { observed: true } } + }) + }) + }), +``` + +#### Reference + +```ts +interface ToolHooks { + readonly "execute.before": ToolExecuteBefore + readonly "execute.after": ToolExecuteAfter +} + +interface ToolFailures extends Record { + readonly "execute.before": Tool.Error + readonly "execute.after": never +} + +interface ToolHookDomain { + readonly hook: Hooks +} +``` + +The shared registration types show which operations require the plugin scope. + +```ts +interface Registration { + readonly dispose: Effect.Effect +} + +type Hooks = Record> = ( + name: Name, + callback: (input: Spec[Name]) => Effect.Effect, +) => Effect.Effect + +type Transform = (callback: (input: Input) => void) => Effect.Effect +``` + +## Publish + +A package plugin uses the same default export as a local Effect plugin. Export the Effect implementation from the main +entrypoint and declare both runtime dependencies. + +```json title="package.json" +{ + "name": "opencode-acme-effect-plugin", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@opencode-ai/plugin": "beta", + "effect": "4.0.0-rc.111" + } +} +``` + +The package entrypoint exports `Plugin.define` with an `effect` function. + +```ts title="src/index.ts" +import { Plugin } from "@opencode-ai/plugin/effect" +import { Effect } from "effect" + +export default Plugin.define({ + id: "acme.published", + effect: (ctx) => + Effect.gen(function* () { + const storage = ctx.storage + yield* storage.set("installed", true) + }), +}) +``` + +Use versions compatible with the OpenCode release you target and test the installed package rather than only a +workspace-linked copy. + +```sh +bun pm pack +bun add ./opencode-acme-effect-plugin-1.0.0.tgz +``` diff --git a/packages/www/src/docs/content/build/plugins/overview.mdx b/packages/www/src/docs/content/build/plugins/overview.mdx new file mode 100644 index 00000000000..e8f1db403f9 --- /dev/null +++ b/packages/www/src/docs/content/build/plugins/overview.mdx @@ -0,0 +1,1029 @@ +--- +title: "Overview" +--- + +Plugins can modify OpenCode's behavior and add new features. To change the terminal UI, build a [CLI +plugin](/build/plugins/cli). + +```ts title=".opencode/plugins/example.ts" +import { Plugin } from "@opencode-ai/plugin" + +export default Plugin.define({ + id: "example", + async setup(ctx) { + await ctx.storage.set("loaded", true) + }, +}) +``` + +Plugins under `.opencode/plugins/` are loaded automatically, like the local example above. To load published packages +or files from other locations, add them to `plugins` in `opencode.json(c)`: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "plugins": [ + "opencode-acme-plugin", + "opencode-acme-plugin@1.2.0", + "@acme/opencode-plugin", + "./plugins/local.ts", + "../shared/plugin.ts", + "/absolute/path/plugin.ts", + "file:///home/me/plugins/local.ts", + { + "package": "@acme/opencode-plugin", + "options": { + "agent": "reviewer", + "strict": true, + }, + }, + ], +} +``` + +See [Configure plugins](/plugins) for more loading and configuration options. + +## Lifecycle + +`setup` runs when the plugin loads. It may return a cleanup function that runs when the plugin unloads. + +```ts +import { Plugin } from "@opencode-ai/plugin" + +export default Plugin.define({ + id: "example", + setup(ctx) { + console.log(`loaded in OpenCode ${ctx.app.version}`) + return () => console.log("unloaded") + }, +}) +``` + +### Context + +The plugin context is essentially an [OpenCode server client](/build/client). +Its read and action methods use the same inputs and responses as the client. It +adds plugin-only methods for transforms, runtime hooks, reloads, registrations, +and plugin options. + +```ts +setup(ctx) { + console.log(ctx.app.version) +} +``` + +### Options + +Pass plugin options with the object form in `opencode.json(c)`. + +```jsonc title="opencode.jsonc" +{ + "plugins": [ + { + "package": "./plugins/company.ts", + "options": { + "strict": true + } + } + ] +} +``` + +Read those values from `ctx.options` during `setup`. + +```ts title="plugins/company.ts" +import { Plugin } from "@opencode-ai/plugin" + +export default Plugin.define({ + id: "company", + setup(ctx) { + const strict = ctx.options.strict === true + }, +}) +``` + +## Transforms + +Transforms are a central pattern in the plugin API and modify how OpenCode works. Plugins register +transforms and each builds on the changes made before it. + +Say we have a plugin that adds one model to the catalog. + +```ts title="plugins/models.ts" +import { Plugin } from "@opencode-ai/plugin" + +export default Plugin.define({ + id: "company.models", + async setup(ctx) { + await ctx.catalog.transform((catalog) => { + catalog.model.update("acme", "reasoner", (model) => { + model.name = "Acme Reasoner" + model.cost = [{ input: 2, output: 12, cache: { read: 0.2, write: 2 } }] + }) + }) + }, +}) +``` + +A later plugin can enforce a maximum output price across every model, including models added by earlier plugins. + +```ts title="plugins/model-budget.ts" +import { Plugin } from "@opencode-ai/plugin" + +export default Plugin.define({ + id: "company.model-budget", + async setup(ctx) { + await ctx.catalog.transform((catalog) => { + for (const provider of catalog.provider.list()) { + for (const model of provider.models.values()) { + if (model.cost.some((tier) => tier.output > 20)) { + catalog.model.remove(model.providerID, model.id) + } + } + } + }) + }, +}) +``` + +Now say the first plugin dynamically fetches can fetch its model list from a +dynamic source. It can call `reload` when that list changes. + +```ts title="plugins/models.ts" +import { Plugin } from "@opencode-ai/plugin" + +export default Plugin.define({ + id: "company.models", + async setup(ctx) { + let models = await loadFromSource() + + await ctx.catalog.transform((catalog) => { + for (const item of models) { + catalog.model.update(item.providerID, item.id, (model) => { + model.name = item.name + model.cost = [{ input: item.input, output: item.output, cache: { read: 0, write: 0 } }] + }) + } + }) + + const refresh = async () => { + models = await loadFromSource() + await ctx.catalog.reload() + } + + const timer = setInterval(() => void refresh().catch(console.error), 60_000) + return () => clearInterval(timer) + }, +}) +``` + +`reload` replays every catalog transform in order, so the output-price policy still filters the refreshed models. + +## API + +### Agent + +Read all agents or fetch one by ID. + +```ts +const agents = await ctx.agent.list() +const build = await ctx.agent.get({ agentID: "build" }) +``` + +Register a transform to inspect, update, remove, or select the default agent. + +```ts +await ctx.agent.transform((draft) => { + const agents = draft.list() + const build = draft.get("build") + + draft.default("build") + draft.update("build", (agent) => { + agent.description = "Builds features and fixes bugs" + }) + draft.remove("legacy") +}) +``` + +Reload agents after external state used by a transform changes. + +```ts +await ctx.agent.reload() +``` + +#### Reference + +Schema: [`Agent.Info`](/api#schema-Agent.Info) + +```ts +interface AgentContext { + list(input?: AgentListInput, requestOptions?: RequestOptions): Promise + get(input: AgentGetInput, requestOptions?: RequestOptions): Promise + transform(callback: (draft: AgentDraft) => void): Promise + reload(): Promise +} + +interface AgentDraft { + list(): readonly AgentInfo[] + get(id: string): AgentInfo | undefined + default(id: string | undefined): void + update(id: string, update: (agent: AgentInfo) => void): void + remove(id: string): void +} + +interface Registration { + dispose(): Promise +} +``` + +### Catalog + +Read the available providers, models, and default model. + +```ts +const providers = await ctx.catalog.provider.list() +const provider = await ctx.catalog.provider.get({ providerID: "anthropic" }) +const models = await ctx.catalog.model.list() +const defaults = await ctx.catalog.model.default() +``` + +Register a transform to inspect, update, remove, or select providers and models. + +```ts +await ctx.catalog.transform((catalog) => { + const providers = catalog.provider.list() + const anthropic = catalog.provider.get("anthropic") + const sonnet = catalog.model.get("anthropic", "claude-sonnet-4-5") + + catalog.provider.update("anthropic", (provider) => { + provider.name = "Anthropic" + }) + catalog.model.update("anthropic", "claude-sonnet-4-5", (model) => { + model.name = "Claude Sonnet 4.5" + }) + catalog.model.default.set("anthropic", "claude-sonnet-4-5") + catalog.model.remove("anthropic", "legacy-model") + catalog.provider.remove("legacy-provider") +}) +``` + +Reload the catalog after external state used by a transform changes. + +```ts +await ctx.catalog.reload() +``` + +#### Reference + +Schemas: [`Provider.Info`](/api#schema-Provider.Info), [`Model.Info`](/api#schema-Model.Info) + +```ts +interface CatalogContext { + provider: { + list(input?: ProviderListInput, requestOptions?: RequestOptions): Promise + get(input: ProviderGetInput, requestOptions?: RequestOptions): Promise + } + model: { + list(input?: ModelListInput, requestOptions?: RequestOptions): Promise + default(input?: ModelDefaultInput, requestOptions?: RequestOptions): Promise + } + transform(callback: (draft: CatalogDraft) => void): Promise + reload(): Promise +} + +interface CatalogDraft { + provider: { + list(): readonly CatalogProviderRecord[] + get(providerID: string): CatalogProviderRecord | undefined + update(providerID: string, update: (provider: ProviderInfo) => void): void + remove(providerID: string): void + } + model: { + get(providerID: string, modelID: string): ModelInfo | undefined + update(providerID: string, modelID: string, update: (model: ModelInfo) => void): void + remove(providerID: string, modelID: string): void + default: { + get(): { providerID: string; modelID: string } | undefined + set(providerID: string, modelID: string): void + } + } +} + +interface CatalogProviderRecord { + provider: ProviderInfo + models: ReadonlyMap +} +``` + +### Commands + +Read the commands available at a location. + +```ts +const commands = await ctx.command.list() +``` + +Register commands with a transform. The executor receives the session, prompt attachments, and requested delivery mode. + +```ts +await ctx.command.transform((draft) => { + draft.add({ + name: "security-review", + description: "Review changes for security issues", + execute: async ({ sessionID, prompt, delivery }) => { + await ctx.session.prompt({ + ...prompt, + sessionID, + text: `Review these changes for security issues.\n\n${prompt.text}`, + delivery, + }) + }, + }) +}) +``` + +Reload commands after external state used by a transform changes. + +```ts +await ctx.command.reload() +``` + +#### Reference + +Schemas: [`Command.Info`](/api#schema-Command.Info), +[`Session.Inbox.Delivery`](/api#schema-Session.Inbox.Delivery) + +```ts +interface CommandContext { + list(input?: CommandListInput, requestOptions?: RequestOptions): Promise + transform(callback: (draft: CommandDraft) => void): Promise + reload(): Promise +} + +interface CommandDraft { + add(definition: CommandDefinition): void +} + +interface CommandDefinition { + name: string + description?: string + execute(input: CommandInvocation): Promise +} + +interface CommandInvocation { + sessionID: string + prompt: PromptInput + delivery: "steer" | "queue" +} +``` + +### Integrations + +Read integrations and inspect active credentials. + +```ts +const integrations = await ctx.integration.list() +const github = await ctx.integration.get({ integrationID: "github" }) +const connection = await ctx.integration.connection.active("github") +const credential = connection ? await ctx.integration.connection.resolve(connection) : undefined +``` + +Connect integrations with an API key. + +```ts +await ctx.integration.connect.key({ + integrationID: "github", + key: process.env.GITHUB_TOKEN!, +}) +``` + +Start, inspect, complete, or cancel an OAuth connection attempt. + +```ts +const attempt = await ctx.integration.oauth.connect({ integrationID: "github", methodID: "oauth" }) +const status = await ctx.integration.oauth.status({ integrationID: "github", attemptID: attempt.data.attemptID }) +await ctx.integration.oauth.complete({ integrationID: "github", attemptID: attempt.data.attemptID, code }) +await ctx.integration.oauth.cancel({ integrationID: "github", attemptID: attempt.data.attemptID }) +``` + +Command-based connections expose the same start, status, and cancel flow. + +```ts +const attempt = await ctx.integration.command.connect({ integrationID: "acme", methodID: "cli" }) +const status = await ctx.integration.command.status({ integrationID: "acme", attemptID: attempt.data.attemptID }) +await ctx.integration.command.cancel({ integrationID: "acme", attemptID: attempt.data.attemptID }) +``` + +Register a transform to inspect integrations and manage their authentication methods. + +```ts +await ctx.integration.transform((draft) => { + const integrations = draft.list() + const acme = draft.get("acme") + + draft.update("acme", (integration) => { + integration.name = "Acme" + }) + draft.method.update({ + integrationID: "acme", + method: { id: "cli", type: "command", label: "Sign in with Acme CLI", command: ["acme", "login"] }, + }) + + const methods = draft.method.list("acme") + const legacy = methods.find((method) => method.type === "command" && method.id === "legacy") + if (legacy) draft.method.remove("acme", legacy) + draft.remove("legacy") +}) +``` + +Reload integrations after external state used by a transform changes. + +```ts +await ctx.integration.reload() +``` + +#### Reference + +Schemas: [`Integration.Info`](/api#schema-Integration.Info), [`Integration.Method`](/api#schema-Integration.Method), +[`Connection.Info`](/api#schema-Connection.Info), [`Form.Answer`](/api#schema-Form.Answer) + +```ts +interface IntegrationContext { + list(input?: IntegrationListInput, requestOptions?: RequestOptions): Promise + get(input: IntegrationGetInput, requestOptions?: RequestOptions): Promise + connect: { + key(input: IntegrationConnectKeyInput, requestOptions?: RequestOptions): Promise + } + oauth: { + connect(input: IntegrationOauthConnectInput, requestOptions?: RequestOptions): Promise + status(input: IntegrationOauthStatusInput, requestOptions?: RequestOptions): Promise + complete(input: IntegrationOauthCompleteInput, requestOptions?: RequestOptions): Promise + cancel(input: IntegrationOauthCancelInput, requestOptions?: RequestOptions): Promise + } + command: { + connect(input: IntegrationCommandConnectInput, requestOptions?: RequestOptions): Promise + status(input: IntegrationCommandStatusInput, requestOptions?: RequestOptions): Promise + cancel(input: IntegrationCommandCancelInput, requestOptions?: RequestOptions): Promise + } + transform(callback: (draft: IntegrationDraft) => void): Promise + reload(): Promise + connection: { + active(integrationID: string): Promise + resolve(connection: ConnectionInfo): Promise + } +} + +interface IntegrationDraft { + list(): readonly IntegrationRef[] + get(id: string): IntegrationRef | undefined + update(id: string, update: (integration: IntegrationRef) => void): void + remove(id: string): void + method: { + list(integrationID: string): readonly IntegrationMethod[] + update(input: IntegrationMethodRegistration): void + remove(integrationID: string, method: IntegrationMethod): void + } +} +``` + +### MCP + +List MCP servers or change their connection state. + +```ts +const servers = await ctx.mcp.list() +await ctx.mcp.connect({ server: "docs" }) +await ctx.mcp.disconnect({ server: "docs" }) +``` + +Add or remove a server through the connected API. + +```ts +await ctx.mcp.add({ + server: "docs", + config: { type: "remote", url: "https://mcp.example.com" }, +}) +await ctx.mcp.remove({ server: "legacy" }) +``` + +Register a transform to inspect, set, update, or remove server configuration. + +```ts +await ctx.mcp.transform((draft) => { + const servers = draft.list() + const docs = draft.get("docs") + draft.set("docs", { type: "remote", url: "https://mcp.example.com" }) + draft.update("docs", (server) => { + server.disabled = false + }) + draft.remove("legacy") +}) +``` + +Reload MCP configuration after external state used by a transform changes. + +```ts +await ctx.mcp.reload() +``` + +#### Reference + +Schemas: [`Mcp.Server`](/api#schema-Mcp.Server), [`Mcp.LocalConfigEncoded`](/api#schema-Mcp.LocalConfigEncoded), +[`Mcp.RemoteConfigEncoded`](/api#schema-Mcp.RemoteConfigEncoded) + +```ts +interface MCPContext { + list(input?: McpListInput, requestOptions?: RequestOptions): Promise + add(input: McpAddInput, requestOptions?: RequestOptions): Promise + remove(input: McpRemoveInput, requestOptions?: RequestOptions): Promise + connect(input: McpConnectInput, requestOptions?: RequestOptions): Promise + disconnect(input: McpDisconnectInput, requestOptions?: RequestOptions): Promise + transform(callback: (draft: MCPDraft) => void): Promise + reload(): Promise +} + +interface MCPDraft { + list(): readonly (readonly [string, McpServerConfig])[] + get(name: string): McpServerConfig | undefined + set(name: string, config: McpServerConfig): void + update(name: string, update: (config: McpServerConfig) => void): void + remove(name: string): void +} +``` + +### Plugins + +List the plugins currently active for a location. + +```ts +const plugins = await ctx.plugin.list() +``` + +#### Reference + +Schemas: [`Plugin.Info`](/api#schema-Plugin.Info), [`Plugin.Source`](/api#schema-Plugin.Source) + +```ts +interface PluginContext { + list(input?: PluginListInput, requestOptions?: RequestOptions): Promise +} +``` + +### References + +Read the references available at a location. + +```ts +const references = await ctx.reference.list() +``` + +Register a transform to inspect, add, or remove local and Git references. + +```ts +await ctx.reference.transform((draft) => { + const references = draft.list() + draft.add("handbook", { type: "local", path: "/workspace/docs/handbook" }) + draft.add("standards", { type: "git", repository: "https://github.com/acme/standards", branch: "main" }) + draft.remove("legacy") +}) +``` + +Reload references after external state used by a transform changes. + +```ts +await ctx.reference.reload() +``` + +#### Reference + +Schemas: [`Reference.Info`](/api#schema-Reference.Info), [`Reference.LocalSource`](/api#schema-Reference.LocalSource), +[`Reference.GitSource`](/api#schema-Reference.GitSource) + +```ts +interface ReferenceContext { + list(input?: ReferenceListInput, requestOptions?: RequestOptions): Promise + transform(callback: (draft: ReferenceDraft) => void): Promise + reload(): Promise +} + +interface ReferenceDraft { + list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] + add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void + remove(name: string): void +} +``` + +### Sessions + +Create or read a session. + +```ts +const created = await ctx.session.create({ title: "Review" }) +const session = await ctx.session.get({ sessionID }) +``` + +Change the agent or model used by subsequent requests. + +```ts +await ctx.session.switchAgent({ sessionID, agent: "build" }) +await ctx.session.switchModel({ sessionID, model: { providerID: "anthropic", id: "claude-sonnet-4-5" } }) +``` + +Send user prompts, transient generation requests, commands, or synthetic messages. + +```ts +const prompt = await ctx.session.prompt({ sessionID, text: "Review the current changes" }) +const generated = await ctx.session.generate({ sessionID, prompt: "Summarize this project" }) +const command = await ctx.session.command({ sessionID, command: "review", arguments: "--staged" }) +const synthetic = await ctx.session.synthetic({ sessionID, text: "Deployment completed" }) +``` + +Rename, interrupt, or wait for a session. + +```ts +await ctx.session.rename({ sessionID, title: "Review" }) +await ctx.session.interrupt({ sessionID, continue: false }) +await ctx.session.wait({ sessionID }) +``` + +#### Reference + +Schemas: [`Session.Info`](/api#schema-Session.Info), [`Model.Ref`](/api#schema-Model.Ref), +[`Session.Inbox.User`](/api#schema-Session.Inbox.User), [`Session.Inbox.Synthetic`](/api#schema-Session.Inbox.Synthetic) + +```ts +interface SessionContext { + create(input?: SessionCreateInput, requestOptions?: RequestOptions): Promise + get(input: SessionGetInput, requestOptions?: RequestOptions): Promise + switchAgent(input: SessionSwitchAgentInput, requestOptions?: RequestOptions): Promise + switchModel(input: SessionSwitchModelInput, requestOptions?: RequestOptions): Promise + prompt(input: SessionPromptInput, requestOptions?: RequestOptions): Promise + generate(input: SessionGenerateInput, requestOptions?: RequestOptions): Promise<{ text: string }> + command(input: SessionCommandInput, requestOptions?: RequestOptions): Promise + synthetic(input: SessionSyntheticInput, requestOptions?: RequestOptions): Promise + interrupt(input: SessionInterruptInput, requestOptions?: RequestOptions): Promise + rename(input: SessionRenameInput, requestOptions?: RequestOptions): Promise + wait(input: SessionWaitInput, requestOptions?: RequestOptions): Promise +} +``` + +### Skills + +Read the skills available at a location. + +```ts +const skills = await ctx.skill.list() +``` + +Register a transform to inspect, add, update, or remove skills. + +```ts +await ctx.skill.transform((draft) => { + const skills = draft.list() + draft.add({ + id: "review", + name: "Review", + description: "Review the current changes", + location: "/workspace/.opencode/skills/review.md", + content: "Review the current changes for correctness and missing tests.", + }) + draft.update("review", (skill) => { + skill.autoinvoke = true + }) + draft.remove("legacy") +}) +``` + +Reload skills after external state used by a transform changes. + +```ts +await ctx.skill.reload() +``` + +#### Reference + +Schema: [`Skill.Info`](/api#schema-Skill.Info) + +```ts +interface SkillContext { + list(input?: SkillListInput, requestOptions?: RequestOptions): Promise + transform(callback: (draft: SkillDraft) => void): Promise + reload(): Promise +} + +interface SkillDraft { + list(): readonly SkillInfo[] + add(skill: SkillInfo): void + update(id: string, update: (skill: SkillInfo) => void): void + remove(id: string): void +} +``` + +### Storage + +Store, read, or remove durable JSON values scoped to the plugin. + +```ts +await ctx.storage.set("settings", { strict: true }) +const settings = await ctx.storage.get("settings") +await ctx.storage.remove("settings") +``` + +Scan keys by prefix with optional cursor pagination. + +```ts +const page = await ctx.storage.scan({ prefix: "cache/", limit: 100 }) +const next = page.next +``` + +#### Reference + +```ts +interface StorageContext { + get(key: string): Promise + set(key: string, value: Json): Promise + remove(key: string): Promise + scan(options: StorageScanOptions): Promise +} + +interface StorageScanOptions { + prefix: string + after?: string + limit?: number +} + +interface StorageScanResult { + entries: readonly { key: string; value: Json }[] + next?: string +} +``` + +### Tools + +Register tools with a transform. + +```ts +await ctx.tool.transform((draft) => { + draft.add({ + name: "greeting", + description: "Create a greeting", + input: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }, + options: { namespace: "acme", codemode: true }, + execute: async (input, tool) => { + await tool.progress({ status: "greeting" }) + return { content: `Hello ${(input as { name: string }).name}!` } + }, + }) +}) +``` + +#### Reference + +Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent), +[`Tool.FileContent`](/api#schema-Tool.FileContent) + +```ts +interface ToolContext { + transform(callback: (draft: ToolDraft) => void): Promise +} + +interface ToolDraft { + add(tool: ToolInfo): void +} +``` + +### Websearch + +List websearch providers or run a query through the selected provider. + +```ts +const providers = await ctx.websearch.providers() +const results = await ctx.websearch.query({ query: "OpenCode plugins", providerID: "internal" }) +``` + +Register a provider and select the default provider with a transform. + +```ts +await ctx.websearch.transform((draft) => { + draft.add({ + id: "internal", + name: "Internal search", + execute: async ({ query }, { signal }) => { + const response = await fetch(`https://search.example.com?q=${encodeURIComponent(query)}`, { signal }) + const result = await response.json() + return [{ url: result.url, title: result.title, content: result.content, time: {} }] + }, + }) + draft.default.set("internal") +}) +``` + +Disable websearch by selecting `false`, or reload providers after external state changes. + +```ts +await ctx.websearch.transform((draft) => draft.default.set(false)) +await ctx.websearch.reload() +``` + +#### Reference + +Schemas: [`WebSearch.Provider`](/api#schema-WebSearch.Provider), [`WebSearch.Result`](/api#schema-WebSearch.Result) + +```ts +interface WebSearchContext { + providers(input?: WebsearchProvidersInput, requestOptions?: RequestOptions): Promise + query(input: WebsearchQueryInput, requestOptions?: RequestOptions): Promise + transform(callback: (draft: WebSearchDraft) => void): Promise + reload(): Promise +} + +interface WebSearchDraft { + add(provider: WebSearchDefinition): void + default: { + get(): string | false | undefined + set(providerID: string | false): void + } +} +``` + +### Events + +Subscribe to the connected server's public event stream. Abort the stream during plugin cleanup. + +```ts +const controller = new AbortController() +void (async () => { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + console.log(event.type) + } +})() +return () => controller.abort() +``` + +#### Reference + +Schema: [`V2EventEncoded`](/api#schema-V2EventEncoded) + +```ts +interface EventContext { + subscribe(requestOptions?: RequestOptions): AsyncIterable +} +``` + +## Hooks + +Hooks intercept live operations. Multiple plugins can register the same hook; OpenCode runs them in plugin order so +later hooks see earlier changes. + +```ts +const registration = await ctx.session.hook("context", () => {}) +await registration.dispose() +``` + +### Sessions + +Modify assembled system instructions, messages, or tools immediately before model dispatch. + +```ts +await ctx.session.hook("context", (event) => { + event.system.push("Keep the review focused on correctness.") + delete event.tools.write +}) +``` + +Modify model request settings and optionally scope the hook to one provider. + +```ts +await ctx.session.hook("model.request", (event) => { + event.headers["x-plugin"] = "review" +}, { providerID: "anthropic" }) +``` + +Modify native provider requests or responses. Their bodies are one-shot streams; clone or replace a body before reading +it. + +```ts +await ctx.session.hook("http.request", (event) => { + event.request.headers.set("x-session-id", event.sessionID) +}) + +await ctx.session.hook("http.response", (event) => { + event.response = new Response(event.response.body, { + status: event.response.status, + headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "review" }, + }) +}) +``` + +#### Reference + +```ts +interface SessionHooks { + context: SessionContextHook + "model.request": SessionModelRequestHook + "http.request": SessionHttpRequestHook + "http.response": SessionHttpResponseHook +} + +interface SessionHookContext { + hook( + name: Name, + callback: (event: SessionHooks[Name]) => Promise | void, + options?: { providerID?: string }, + ): Promise +} +``` + +### Shell + +Modify shell commands, working directories, timeouts, executables, or environment variables before execution. + +```ts +await ctx.shell.hook("create.before", (event) => { + event.timeout = Math.min(event.timeout, 60_000) + event.env.COMPANY_ENV = "development" +}) +``` + +#### Reference + +```ts +interface ShellHookContext { + hook( + name: "create.before", + callback: (event: ShellCreateBefore) => Promise | void, + ): Promise +} + +interface ShellCreateBefore { + command: string + cwd: string + timeout: number + shell: string + env: Record +} +``` + +### Tools + +Inspect or replace tool input before execution. + +```ts +await ctx.tool.hook("execute.before", (event) => { + if (event.tool === "read") console.log(event.input) +}) +``` + +Inspect successful results or failures after execution. + +```ts +await ctx.tool.hook("execute.after", (event) => { + if (event.status === "completed") event.result = { ...event.result, metadata: { observed: true } } + if (event.status === "error") console.error(event.error.message) +}) +``` + +#### Reference + +```ts +interface ToolHooks { + "execute.before": ToolExecuteBefore + "execute.after": ToolExecuteCompleted | ToolExecuteFailed +} + +interface ToolHookContext { + hook( + name: Name, + callback: (event: ToolHooks[Name]) => Promise | void, + ): Promise +} +``` + +## Publish + +A package plugin uses the same default export as a local plugin. A minimal +manifest is: + +```json title="package.json" +{ + "name": "opencode-acme-plugin", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@opencode-ai/plugin": "beta" + } +} +``` + +Use versions compatible with the OpenCode release you target and test the +installed package, not only a workspace-linked copy. Because the plugin API is +beta, publish compatible plugin updates when V2 entrypoints or contracts +change. diff --git a/packages/www/src/docs/content/build/sdk.mdx b/packages/www/src/docs/content/build/sdk.mdx index 8d4f605057b..8c02b6e6d2a 100644 --- a/packages/www/src/docs/content/build/sdk.mdx +++ b/packages/www/src/docs/content/build/sdk.mdx @@ -70,7 +70,7 @@ await using opencode = await OpenCode.create({ plugins: [plugin] }) Call `await opencode.plugin(plugin)` to register another plugin after startup. -See the [Plugins guide](/build/plugins) for the plugin context and available +See the [Plugins guide](/build/plugins/overview) for the plugin context and available hooks. ## Workerd diff --git a/packages/www/src/docs/content/cli/plugins.mdx b/packages/www/src/docs/content/cli/plugins.mdx index 4fd223236d4..2a7f165db21 100644 --- a/packages/www/src/docs/content/cli/plugins.mdx +++ b/packages/www/src/docs/content/cli/plugins.mdx @@ -2,11 +2,25 @@ title: "Plugins" --- -Add plugins to `cli.json`: +Plugins configured in `opencode.json(c)` that expose a TUI component are loaded automatically by the CLI. To learn how +to build plugins, see [Building plugins](/build/plugins/overview). You do not need to add the same package to `cli.json`. The CLI +gets the active plugin list from the connected OpenCode server, so this also works when the server is remote. + +Use `cli.json` for CLI-only plugins. These plugins run locally in the terminal and remain active when the CLI connects +to a remote server: ```json title="cli.json" { - "plugins": ["opencode.example", "./plugins/status.ts"] + "plugins": [ + "opencode.example", + "opencode.example@1.0.0", + "@example/opencode-tui", + "@example/opencode-tui@1.0.0", + "./plugins/status.ts", + "../plugins/status.ts", + "/home/user/plugins/status.ts", + "file:///home/user/plugins/status.ts" + ] } ``` @@ -33,7 +47,10 @@ Pass plugin options with the object form: } ``` -`package` accepts a package name, an absolute path, a `file://` URL, or a path relative to `cli.json`. - OpenCode also discovers JavaScript and TypeScript plugins from `plugins/tui` under the global config directory and project `.opencode` directories. + +```text title="Plugin discovery paths" +/plugins/tui/status.ts +/.opencode/plugins/tui/status.ts +``` diff --git a/packages/www/src/docs/content/config.mdx b/packages/www/src/docs/content/config.mdx index b049ec10221..e9ff7875331 100644 --- a/packages/www/src/docs/content/config.mdx +++ b/packages/www/src/docs/content/config.mdx @@ -434,7 +434,7 @@ accepts options. } ``` -See the [plugins guide](/build/plugins) for plugin development and configuration. +See the [plugins guide](/plugins) for plugin loading and configuration. ### Providers diff --git a/packages/www/src/docs/content/index.mdx b/packages/www/src/docs/content/index.mdx index d29de40485b..bd71ba2f577 100644 --- a/packages/www/src/docs/content/index.mdx +++ b/packages/www/src/docs/content/index.mdx @@ -44,5 +44,6 @@ plan that grants you access to the best open source models. ## Customize -Make OpenCode your own by editing the [OpenCode config](/config), [connecting MCP servers](/mcp-servers), or [creating -commands](/commands). For terminal interface themes and keybindings, see [CLI configuration](/cli/config). +Make OpenCode your own by editing the [OpenCode config](/config), [loading plugins](/plugins), [connecting MCP +servers](/mcp-servers), or [creating commands](/commands). For terminal interface themes and keybindings, see [CLI +configuration](/cli/config). diff --git a/packages/www/src/docs/content/migrate-v1.mdx b/packages/www/src/docs/content/migrate-v1.mdx index 7c98a732774..3ebfe9d7c51 100644 --- a/packages/www/src/docs/content/migrate-v1.mdx +++ b/packages/www/src/docs/content/migrate-v1.mdx @@ -544,7 +544,7 @@ plugin API is still being finalized during beta, and detailed plugin migration g ready. Once the V2 plugin API is finalized, OpenCode should be able to migrate the majority of V1 plugins while keeping related -local modules and dependencies together. See the current beta [Plugins guide](/build/plugins). +local modules and dependencies together. See the current beta [Plugins guide](/build/plugins/overview). ## Server API and clients diff --git a/packages/www/src/docs/content/plugins.mdx b/packages/www/src/docs/content/plugins.mdx new file mode 100644 index 00000000000..76c5d7e8692 --- /dev/null +++ b/packages/www/src/docs/content/plugins.mdx @@ -0,0 +1,107 @@ +--- +title: "Plugins" +--- + +Load published packages, versioned packages, scoped packages, local files, or configured plugins from `opencode.json(c)`. + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "plugins": [ + "opencode-acme-plugin", + "opencode-acme-plugin@1.2.0", + "@acme/opencode-plugin", + "./plugins/local.ts", + "../shared/plugin.ts", + "/absolute/path/plugin.ts", + "file:///home/me/plugins/local.ts", + { + "package": "@acme/opencode-plugin", + "options": { + "agent": "reviewer", + "strict": true, + }, + }, + ], +} +``` + +Relative paths resolve from the config file containing the entry. Plugin arrays from applicable config files are applied +from lowest to highest precedence instead of replacing one another. + +```text +~/.config/opencode/opencode.jsonc +./opencode.jsonc +./.opencode/opencode.jsonc +``` + +OpenCode also loads direct `.ts` and `.js` files and immediate plugin package directories from every discovered +`.opencode/plugins/` directory. + +```text +.opencode/ +└── plugins/ + ├── concise.ts + ├── reviewer.js + └── acme-package/ +``` + +Global plugins use the same discovery layout under the OpenCode config directory. + +```text +~/.config/opencode/plugins/ +``` + +A `plugins/` directory beside a project-root `opencode.json(c)` is not discovered automatically; configure its files +explicitly or move it under `.opencode/`. + +```jsonc title="opencode.jsonc" +{ + "plugins": ["./plugins/local.ts"] +} +``` + +Plugin entries are processed in order. Prefix an ID or wildcard with `-` to disable it, use `*` for every plugin, and +use `.*` to match an ID prefix. A later ID re-enables a plugin. + +```jsonc title="opencode.jsonc" +{ + "plugins": ["*", "-opencode.provider.*", "opencode.provider.openai", "-acme.reviewer"] +} +``` + +Install, inspect, list, or remove global package plugins with the CLI. + +```sh +opencode2 plugin add opencode-acme-plugin@1.2.0 +opencode2 plugin list +opencode2 plugin list --builtin +opencode2 plugin remove opencode-acme-plugin@1.2.0 +``` + +Package installation accepts npm names with versions, tags, or ranges. Configure local paths directly instead of using +Git, tarball, or npm alias targets with `plugin add`. + +```sh +opencode2 plugin add @acme/opencode-plugin@beta +``` + +Changes under watched config directories reload automatically. Restart OpenCode after changing an installed package +version or an unwatched dependency. + +```sh +touch .opencode/plugins/concise.ts +opencode2 service restart +``` + +CLI-only plugins are configured separately and remain active when connected to a remote server. + +```json title="cli.json" +{ + "plugins": ["opencode-acme-cli"] +} +``` + + + Create plugins that add tools, hooks, integrations, commands, agents, and other behavior. + diff --git a/packages/www/src/docs/lib/navigation.ts b/packages/www/src/docs/lib/navigation.ts index 77d936561b0..669402b34f6 100644 --- a/packages/www/src/docs/lib/navigation.ts +++ b/packages/www/src/docs/lib/navigation.ts @@ -36,6 +36,7 @@ export const docsSections: DocsSection[] = [ { title: "Skills", slug: "skills" }, { title: "Themes", slug: "themes" }, { title: "Commands", slug: "commands" }, + { title: "Plugins", slug: "plugins" }, { title: "Providers", slug: "providers" }, { title: "Snapshots", slug: "snapshots" }, { title: "Compaction", slug: "compaction" }, @@ -63,7 +64,6 @@ export const docsSections: DocsSection[] = [ landingSlug: "cli", groups: [ { - title: "Intro", items: [ { title: "Intro", slug: "cli" }, { title: "Config", slug: "cli/config" }, @@ -87,10 +87,19 @@ export const docsSections: DocsSection[] = [ title: "Build", landingSlug: "build", groups: [ + { + items: [{ title: "Build", slug: "build" }], + }, + { + title: "Plugins", + items: [ + { title: "Overview", slug: "build/plugins/overview" }, + { title: "Effect", slug: "build/plugins/effect" }, + { title: "CLI", slug: "build/plugins/cli" }, + ], + }, { items: [ - { title: "Build", slug: "build" }, - { title: "Plugins", slug: "build/plugins" }, { title: "Client", slug: "build/client" }, { title: "SDK", slug: "build/sdk" }, ],