Version OpenCode skills and plugins
Repository sits at the OpenCode config root rather than inside skills/ or
plugins/. OpenCode scans exactly those two subdirectories, and a .git
directory inside either one is picked up as a plugin and fails to load:
loading plugin id=.../plugins/.git entrypoint=.../plugins/.git/index
failed to load plugin ... AggregateError: 2 errors building .../plugins/.git/index
The watcher.ignore config option does not help: it only suppresses filesystem
update events, not plugin discovery, and the "-" disable prefix only applies to
plugins listed in opencode.json(c), not auto-discovered ones.
.gitignore admits only skills/ and plugins/ (plus itself). Personal config
(opencode.jsonc, cli.json, service.json) and node_modules stay local.
skills/opencode-plugins V2-only plugin authoring reference and 7 templates,
transcribed from the V2 docs with the URL on every file
plugins/searxng-search searxng_search tool over a self-hosted SearXNG JSON API
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
# TUI / CLI Plugins
|
||||
|
||||
**Docs: <https://opencode.ai/v2/docs/build/plugins/cli/>** — fetch this page
|
||||
before writing a TUI plugin. This file is a summary of it.
|
||||
**Loading: <https://opencode.ai/v2/docs/cli/plugins>**
|
||||
**Template: `../templates/tui-plugin.tsx`**
|
||||
|
||||
`@opencode/plugin/tui` extends the **terminal frontend** with commands, routes,
|
||||
slots, Markdown renderers, notifications, and local state. A TUI plugin is a
|
||||
separate export from a server plugin — one package can have both.
|
||||
|
||||
```ts title="src/tui.ts"
|
||||
import { Plugin } from "@opencode/plugin/tui"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.cli",
|
||||
setup(context) {
|
||||
context.ui.toast.show({ message: "CLI plugin loaded", variant: "success" })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Import `@opencode/plugin/tui` directly. OpenCode resolves it at runtime, so a
|
||||
local plugin needs no absolute path to an OpenCode checkout. `setup` may return
|
||||
a cleanup function.
|
||||
|
||||
## Context
|
||||
|
||||
`setup` receives configuration, app metadata, the current location, the client,
|
||||
cached data, theme tokens, the OpenTUI renderer, and the UI APIs.
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
`context.client` is the generated client and works against a remote server.
|
||||
|
||||
```ts
|
||||
const response = await context.client.plugin.list({
|
||||
location: context.location ?? context.data.location.default(),
|
||||
})
|
||||
const plugins = response.data
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
`data.on` handles one typed event, `data.listen` handles 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()
|
||||
}
|
||||
```
|
||||
|
||||
## Cached data
|
||||
|
||||
Reads are served from local cached data; the page also exposes `sync` and
|
||||
invalidation ("synchronization, and invalidation" for sessions, "refresh
|
||||
controls" for location). Do not assume `invalidate` empties the cache or that a
|
||||
`list()` immediately after it returns an empty array — the page does not say
|
||||
that.
|
||||
|
||||
Most collections expose `list`, `sync`, and `invalidate`, but the exact set varies
|
||||
and **`get` is not universal**. Documented `get` / lookup: `session.get`,
|
||||
`session.message.get`, and the "lookup" mentioned for pending items; `project.get`
|
||||
and the lookup for saved project permissions; and `shell.get`. The
|
||||
location-scoped catalogs (agents, commands, integrations, models, providers,
|
||||
references, skills, MCP servers and resources) are documented with `list`,
|
||||
`sync`, and `invalidate` only. Other exceptions: `session.root` / `family` /
|
||||
`cost` / `status` are separate, `location.default()` has no `list`/`get`, and
|
||||
version control uses `vcs.info(...)`. Check the name in the page's example.
|
||||
|
||||
```ts
|
||||
// sessions: list, get, root, family, cost, status, sync, invalidate
|
||||
const rootID = context.data.session.root(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
|
||||
await context.data.session.pending.sync(sessionID)
|
||||
const pending = context.data.session.pending.list(sessionID)
|
||||
await context.data.session.message.sync(sessionID)
|
||||
const messages = context.data.session.message.list(sessionID)
|
||||
|
||||
// permissions, and forms at a location
|
||||
await context.data.session.permission.sync(sessionID)
|
||||
const requests = context.data.session.permission.list(sessionID) ?? []
|
||||
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)
|
||||
|
||||
// projects, shells, location, and version control
|
||||
await context.data.project.sync()
|
||||
await context.data.shell.sync(context.location)
|
||||
await context.data.location.vcs.sync(context.location)
|
||||
const branch = context.data.location.vcs.info(context.location)?.branch.current
|
||||
```
|
||||
|
||||
Location-scoped catalogs — agents, commands, integrations, models, providers,
|
||||
references, skills, MCP servers, MCP resources — all take a `location` and
|
||||
return `?? []` when empty.
|
||||
|
||||
## Attention
|
||||
|
||||
System notification, sound, or 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" },
|
||||
})
|
||||
// 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 = () => <text fg={context.theme.text.base}>Ready</text>
|
||||
```
|
||||
|
||||
Inside JSX rendered by a route, dialog, or slot, get the context with
|
||||
`usePlugin`:
|
||||
|
||||
```tsx
|
||||
import { usePlugin } from "@opencode/plugin/tui"
|
||||
|
||||
function Status() {
|
||||
const context = usePlugin()
|
||||
return <text fg={context.theme.text.base}>{context.app.version}</text>
|
||||
}
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
`store` persists JSON across restarts and syncs across TUI instances. `memory`
|
||||
survives plugin reloads but dies with the TUI.
|
||||
|
||||
```ts
|
||||
const [settings, updateSettings] = context.storage.store("settings", {
|
||||
initial: { compact: false },
|
||||
})
|
||||
await updateSettings((draft) => {
|
||||
draft.compact = true
|
||||
})
|
||||
|
||||
const [state, updateState] = context.storage.memory("state", { initial: { count: 0 } })
|
||||
```
|
||||
|
||||
## Commands and keymaps
|
||||
|
||||
```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"],
|
||||
}))
|
||||
```
|
||||
|
||||
Call `layer` from `setup` — including after an `await` — or from a component.
|
||||
The **layer factory must be pure**. OpenCode evaluates it once without
|
||||
dependency tracking to validate the initial command shape, then reactively.
|
||||
|
||||
A layer is removed at the first of: the plugin is disabled/reloaded/fails
|
||||
setup, or the component that created it is disposed. A layer may target one
|
||||
renderable and return `false` from a command to continue keyboard dispatch.
|
||||
|
||||
```ts
|
||||
context.keymap.layer(() => ({
|
||||
target: () => panel,
|
||||
commands: [{ bind: "escape", run: (_input, event) => (event ? false : undefined) }],
|
||||
}))
|
||||
```
|
||||
|
||||
Initial shape errors — a palette command with no ID — throw from `layer()`.
|
||||
Binding-parse errors are handled by OpenTUI; errors from later reactive
|
||||
updates follow Solid's rules.
|
||||
|
||||
Dispatch and inspection:
|
||||
|
||||
```ts
|
||||
context.keymap.dispatch("acme.status", "verbose")
|
||||
context.keymap.shortcuts("acme.status")
|
||||
context.keymap.commands()
|
||||
context.keymap.pending()
|
||||
context.keymap.active()
|
||||
const popMode = context.keymap.mode.push("acme-search")
|
||||
popMode()
|
||||
```
|
||||
|
||||
## Dialogs and toasts
|
||||
|
||||
```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", category: "Advanced" },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
Custom `search` owns matching and order. It runs for **every** query including
|
||||
the empty one, and receives only enabled options.
|
||||
|
||||
```ts
|
||||
const project = await context.ui.dialog.select({
|
||||
title: "Projects",
|
||||
options: [
|
||||
{ title: "Alpha", value: "alpha", category: "Recent" },
|
||||
{ title: "Beta", value: "beta", category: "Pinned" },
|
||||
],
|
||||
search: (query, options) =>
|
||||
options
|
||||
.filter((o) => o.title.toLowerCase().includes(query.toLowerCase()))
|
||||
.toSorted((l, r) => r.title.localeCompare(l.title)),
|
||||
})
|
||||
```
|
||||
|
||||
Select **actions** appear in the footer and run from their keyboard binding, a
|
||||
click, or Tab focus followed by Enter. By default they receive the selected value
|
||||
and are **unavailable without a selection** — use `selection: "none"` for actions
|
||||
that need none. They do not close the dialog or settle the promise.
|
||||
|
||||
```ts
|
||||
await context.ui.dialog.select({
|
||||
title: "Projects",
|
||||
options: [{ title: "Alpha", value: "alpha" }],
|
||||
actions: [
|
||||
{ bind: "ctrl+o", title: "Inspect", onTrigger: (value) => context.ui.toast.show({ message: value }) },
|
||||
{ bind: "ctrl+a", title: "Close", side: "right", selection: "none", onTrigger: () => context.ui.dialog.clear() },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
- An action does not close the dialog or settle the promise.
|
||||
- Enter or a click resolves the selected value. Escape, `clear()`, or another
|
||||
dialog resolves `undefined`.
|
||||
- **Do not bind `ctrl+n` or `ctrl+p`.** The dialog reserves them for navigation
|
||||
and its own bindings win.
|
||||
|
||||
Custom JSX dialogs set their own size and close themselves:
|
||||
|
||||
```tsx
|
||||
context.ui.dialog.set({ size: "large", centered: true })
|
||||
context.ui.dialog.show(
|
||||
() => (
|
||||
<box>
|
||||
<text>Acme</text>
|
||||
</box>
|
||||
),
|
||||
() => console.log("closed"),
|
||||
)
|
||||
context.ui.dialog.clear()
|
||||
```
|
||||
|
||||
Toasts take `title`, `message`, `variant`, and `duration`.
|
||||
|
||||
## Markdown
|
||||
|
||||
```ts
|
||||
const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender())
|
||||
return unregister
|
||||
```
|
||||
|
||||
## Routes and tabs
|
||||
|
||||
```tsx
|
||||
const unregister = context.ui.router.register({
|
||||
name: "dashboard",
|
||||
render: ({ data }) => <text>{String(data?.title ?? "Acme")}</text>,
|
||||
})
|
||||
context.ui.router.navigate({ type: "plugin", name: "dashboard", data: { title: "Status" } })
|
||||
context.ui.router.navigate({ type: "session", sessionID })
|
||||
context.ui.router.navigate({ type: "home" })
|
||||
```
|
||||
|
||||
```ts
|
||||
if (context.ui.tabs.enabled()) {
|
||||
context.ui.tabs.open(backgroundSessionID) // leaves focus unchanged
|
||||
context.ui.tabs.focus(sessionID) // opens if needed
|
||||
const tabs = context.ui.tabs.list()
|
||||
context.ui.tabs.move(backgroundSessionID, tabs.length - 1)
|
||||
context.ui.tabs.close(sessionID)
|
||||
}
|
||||
```
|
||||
|
||||
## Model
|
||||
|
||||
```ts
|
||||
const selected = context.ui.model.current()
|
||||
const variants = context.ui.model.variant.list()
|
||||
context.ui.model.variant.set("high")
|
||||
context.ui.model.variant.set(undefined) // back to the model default
|
||||
```
|
||||
|
||||
`current()` and `variant.list()` are reactive when read in a Solid computation.
|
||||
`variant.set` returns `false` when no model is selected or the variant does not
|
||||
exist. A common use is replacing the built-in variant cycle:
|
||||
|
||||
```ts
|
||||
context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
id: "acme.variant.cycle",
|
||||
title: "Cycle variants",
|
||||
bind: "tab",
|
||||
run() {
|
||||
const variants = context.ui.model.variant.list()
|
||||
if (variants.length === 0) return
|
||||
const index = variants.indexOf(context.ui.model.current()?.variant ?? "")
|
||||
context.ui.model.variant.set(variants[(index + 1) % variants.length])
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
```
|
||||
|
||||
## Slots
|
||||
|
||||
Insert or replace JSX at `app`, `home.footer`, `home.footer.status`,
|
||||
`prompt.footer`, `prompt.footer.status`, `prompt.footer.file`,
|
||||
`session.composer.top`, `sidebar.content`, or `sidebar.footer`.
|
||||
|
||||
Placements: `prepend`, `append`, `before`, `after`, `replace`.
|
||||
|
||||
```tsx
|
||||
return context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
render: ({ sessionID }) => <text>{context.data.session.get(sessionID)?.title}</text>,
|
||||
})
|
||||
|
||||
context.ui.slot({ append: "home.footer.status", render: () => <text>SYNCED</text> })
|
||||
```
|
||||
|
||||
Use `home.footer.status` to add to the built-in footer row **without replacing
|
||||
it**; status contributions appear after health indicators and before the version.
|
||||
|
||||
### Session panels
|
||||
|
||||
Register a contribution to `session.panel`, then open it from a command. The
|
||||
host owns sizing, focus, and full-screen presentation; the plugin owns content.
|
||||
|
||||
```tsx
|
||||
import { Show } from "solid-js"
|
||||
|
||||
context.ui.slot({
|
||||
append: "session.panel",
|
||||
render: (panel) => (
|
||||
<Show when={panel.name === "acme.review"}>
|
||||
<ReviewPanel panel={panel} />
|
||||
</Show>
|
||||
),
|
||||
})
|
||||
|
||||
context.ui.slot({
|
||||
append: "app",
|
||||
render: () => {
|
||||
context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "acme.review",
|
||||
title: "Open review",
|
||||
slash: { name: "review" },
|
||||
run: () => context.ui.panel.open("acme.review"),
|
||||
},
|
||||
],
|
||||
}))
|
||||
return null
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Opening outside a session returns `false`.
|
||||
- This is an ordinary slot: all five placements and the existing replacement
|
||||
ordering rules apply. `append` is what lets independently selectable
|
||||
contributions coexist; `replace` still takes over the slot.
|
||||
- Names are **shared selection values, not registered claims**. Use a
|
||||
plugin-prefixed name. Opening a name with no matching renderer leaves the slot
|
||||
empty.
|
||||
- Changing presentation preserves mounted contributions. Closing the panel
|
||||
disposes them; disabling a plugin removes its contributions through normal
|
||||
slot cleanup.
|
||||
- Its keyboard layers and input modes are active only while the panel owns input.
|
||||
- The slot receives reactive `name`, `sessionID`, `width`, `presentation`, and
|
||||
`focused`, plus `focus`, `close`, and `toggleFullscreen`. The host keeps narrow
|
||||
terminals full-screen, so `toggleFullscreen` does nothing until there is room.
|
||||
|
||||
```tsx
|
||||
import type { PanelInput } from "@opencode/plugin/tui/context"
|
||||
import { usePlugin } from "@opencode/plugin/tui"
|
||||
|
||||
function ReviewPanel(props: { panel: PanelInput }) {
|
||||
const context = usePlugin()
|
||||
context.keymap.layer(() => ({
|
||||
commands: [{ id: "acme.review.fullscreen", bind: "f", run: props.panel.toggleFullscreen }],
|
||||
}))
|
||||
return <text>Reviewing {props.panel.sessionID}</text>
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
context.ui.panel.open("acme.review", { presentation: "fullscreen" })
|
||||
const current = context.ui.panel.current()
|
||||
context.ui.panel.close()
|
||||
```
|
||||
|
||||
## Formatting
|
||||
|
||||
```ts
|
||||
const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project")
|
||||
```
|
||||
|
||||
Abbreviates the home directory.
|
||||
|
||||
## Publish and load
|
||||
|
||||
Expose the TUI plugin through `./tui` and add OpenTUI peers when rendering JSX.
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "opencode-acme-plugin",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tui": "./src/tui.tsx"
|
||||
},
|
||||
"dependencies": { "@opencode/plugin": "latest" },
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.5.8",
|
||||
"@opentui/solid": ">=0.5.8",
|
||||
"solid-js": ">=1.9.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exporting `./tui` beside the main plugin makes the CLI load it automatically.
|
||||
The docs say 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` only for **CLI-only** plugins that run locally in the terminal and
|
||||
must stay active against a remote server:
|
||||
|
||||
```json title="cli.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/v2/cli.json",
|
||||
"plugins": [
|
||||
"opencode.example",
|
||||
"opencode.example@1.0.0",
|
||||
"@example/opencode-tui",
|
||||
"@example/opencode-tui@1.0.0",
|
||||
"./plugins/status",
|
||||
"../plugins/status",
|
||||
"/home/user/plugins/status",
|
||||
"file:///home/user/plugins/status"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Entries are processed in order. Prefix an ID or wildcard with `-` to disable
|
||||
matching plugins. The `cli/plugins` page shows `["*", "-opencode.notifications",
|
||||
"-team.*"]` as its example; the explicit meaning of `*` and `.*` is spelled out
|
||||
only on the `plugins` config page, so treat the wildcard semantics here as
|
||||
inferred from that example.
|
||||
|
||||
```json title="cli.json"
|
||||
{ "plugins": ["*", "-opencode.notifications", "-team.*"] }
|
||||
```
|
||||
|
||||
Options use the object form:
|
||||
|
||||
```json title="cli.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/v2/cli.json",
|
||||
"plugins": [{ "package": "@example/opencode-tui", "options": { "compact": true } }]
|
||||
}
|
||||
```
|
||||
|
||||
`cli.json` lives at `~/.config/opencode/cli.json`, or
|
||||
`$XDG_CONFIG_HOME/opencode/cli.json`. **There is no project-local CLI settings
|
||||
file** — a `cli.json` next to a project `opencode.json(c)` is not read.
|
||||
(Source: <https://opencode.ai/v2/docs/cli/config>)
|
||||
|
||||
Server plugins belong in `opencode.json(c)`.
|
||||
|
||||
OpenCode also discovers plugins under the global config directory and project
|
||||
`.opencode` directories, each using the published-package layout with server and
|
||||
TUI entrypoints together:
|
||||
|
||||
```text title="Plugin discovery paths"
|
||||
<global-config>/plugins/status/index.ts
|
||||
<global-config>/plugins/status/tui.ts
|
||||
<project>/.opencode/plugins/status/index.ts
|
||||
<project>/.opencode/plugins/status/tui.ts
|
||||
```
|
||||
@@ -0,0 +1,403 @@
|
||||
# Effect Plugins
|
||||
|
||||
**Docs: <https://opencode.ai/v2/docs/build/plugins/effect/>** — fetch this page
|
||||
before writing an Effect plugin. This file is a summary of it.
|
||||
**Template: `../templates/effect-plugin.ts`**
|
||||
|
||||
`@opencode/plugin/effect` is the Effect-native flavor of the server plugin API.
|
||||
Context operations return Effects or Streams, callbacks return Effects, and
|
||||
lifetime is a `Scope`. Use it when the project already uses Effect. Otherwise
|
||||
use the Promise flavor in `SKILL.md`.
|
||||
|
||||
## Install
|
||||
|
||||
OpenCode provides `effect`, `effect/*`, and `@opencode/plugin` at runtime. Do
|
||||
**not** bundle them. The page says to install `effect` and `@opencode/plugin` as
|
||||
peer/dev dependencies for local type-checking and testing.
|
||||
|
||||
```sh
|
||||
bun add --peer @opencode/plugin effect
|
||||
```
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"peerDependencies": { "effect": ">=4.0.0-rc.112" }
|
||||
}
|
||||
```
|
||||
|
||||
If you bundle with a bundler, mark `effect` and `effect/*` external. OpenCode
|
||||
redirects these imports (including from your `node_modules`) to the host's
|
||||
runtime so fibers, loggers, and `Schema` parsers share one copy. Dependencies
|
||||
built on another Effect major (e.g. Effect 3) are **not** supported. Importing a
|
||||
private `effect/internal/*` path fails to load with an error naming the path.
|
||||
|
||||
## Shape
|
||||
|
||||
```ts
|
||||
import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "example",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.storage.set("loaded", true)
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Use `effect:`, not `setup:`. Exporting from `.opencode/plugins/` loads it
|
||||
automatically; packages use the same `plugins` config as Promise plugins.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The scope closes on reload or unload, releasing registrations, scoped fibers, and
|
||||
finalizers together.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.logInfo("unloaded"))
|
||||
})
|
||||
```
|
||||
|
||||
Background work uses scoped fibers; the scope interrupts them at cleanup.
|
||||
|
||||
```ts
|
||||
yield* Effect.repeat(
|
||||
storage.set("heartbeat", { time: Date.now() }),
|
||||
{ schedule: Schedule.spaced("1 minute") },
|
||||
).pipe(Effect.forkScoped)
|
||||
```
|
||||
|
||||
`Effect.repeat` takes an Effect **value**, not a function.
|
||||
|
||||
## Key differences from Promise plugins
|
||||
|
||||
| Aspect | Promise | Effect |
|
||||
| ------ | ------- | ------ |
|
||||
| Entry | `setup(ctx)` returning `Promise`/cleanup fn | `effect(ctx)` returning `Effect` |
|
||||
| Reads | `await ctx.model.list()` | `yield* ctx.model.list()` |
|
||||
| Transforms | `await ctx.model.transform(cb)` — cb sync | `yield* ctx.model.transform(cb)` — cb **still sync** |
|
||||
| Cleanup | `return () => {}` | `Effect.addFinalizer` / `forkScoped` |
|
||||
| Events | `for await (…)` + manual `AbortController` | `Stream` + `forkScoped` |
|
||||
| Tool I/O | raw JSON Schema | Effect `Schema` |
|
||||
| Hooks | no rejection API documented for tool hooks | `Effect.fail(new Tool.Error(...))` |
|
||||
| Registration | `await reg.dispose()` | `yield* reg.dispose` |
|
||||
|
||||
`Hooks<Spec, Failures>` and `Transform<Input>` are the shared registration types.
|
||||
Both return an Effect that requires the plugin scope, which is why
|
||||
`transform`/`hook`/`dispose` are all yieldable and scoped:
|
||||
|
||||
```ts
|
||||
type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <Name extends keyof Spec>(
|
||||
name: Name,
|
||||
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
```
|
||||
|
||||
These are the page's simplified shared types. They are **not** the complete
|
||||
signature for the session domain: the page's session hook type is
|
||||
`SessionHookDomain { readonly hook: ModelHooks<SessionHooks> }`, `ModelHooks` is
|
||||
not defined on the page, and the page's own examples pass a third
|
||||
`{ providerID }` argument that the `Hooks` type above does not accept. Trust the
|
||||
page's examples over the simplified type.
|
||||
|
||||
The transform callback is still synchronous in both flavors. It must not return
|
||||
an Effect or a Promise.
|
||||
|
||||
Result shape is **per operation, not per flavor**. Some reads are enveloped and
|
||||
some are not, in both flavors — check the doc example for the call you are using.
|
||||
|
||||
```ts
|
||||
// enveloped
|
||||
const models = yield* ctx.model.list().pipe(Effect.orDie)
|
||||
models.data.length
|
||||
|
||||
// returned directly
|
||||
const created = yield* ctx.session.create({ title: "Review" }).pipe(Effect.orDie)
|
||||
created.id
|
||||
const settings = yield* ctx.storage.get("settings")
|
||||
```
|
||||
|
||||
The page states direct returns for storage ("Storage accepts Effect's JSON type
|
||||
and returns Effects directly"), for `integration.connection.active` (`Effect<ConnectionInfo | undefined>`) and
|
||||
`integration.connection.resolve` (a `Credential.Value | undefined`), and for
|
||||
`ToolDomain.list`
|
||||
(`Effect<readonly (Tool.Info & { id: string })[]>`); editor `list()` calls also
|
||||
return plain arrays. For everything else, mirror the page's own example rather
|
||||
than assuming either shape.
|
||||
|
||||
Many examples in the page pipe reads through `Effect.orDie` to drop the error
|
||||
channel, then read `.data` — copy that shape when the example uses it.
|
||||
|
||||
## Transforms
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const provider = ctx.provider
|
||||
const source = { providers: yield* loadFromSource() }
|
||||
|
||||
yield* provider.transform((editor) => {
|
||||
source.providers.forEach((p) => editor.add(p))
|
||||
})
|
||||
|
||||
yield* Effect.repeat(
|
||||
Effect.gen(function* () {
|
||||
source.providers = yield* loadFromSource()
|
||||
yield* provider.reload()
|
||||
}),
|
||||
{ schedule: Schedule.spaced("1 minute") },
|
||||
).pipe(Effect.forkScoped)
|
||||
})
|
||||
```
|
||||
|
||||
Rebuild, replay-order, and no-implicit-watch semantics are identical to the
|
||||
Promise flavor. See `SKILL.md`.
|
||||
|
||||
## Tools
|
||||
|
||||
Effect plugins register tools with Effect `Schema` instead of raw JSON Schema,
|
||||
and the executor returns typed output.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool.transform((editor) => {
|
||||
editor.namespace({ name: "acme", description: "Customer account tools" })
|
||||
editor.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}!` } }
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Return `{ output }` for typed output — that is the shape the page's executor
|
||||
example uses. The page also says an executor may return "typed output, display
|
||||
content, or metadata" without showing those other shapes, so confirm on the page
|
||||
before relying on a `{ content }` form in an Effect plugin.
|
||||
Effective-name rules (`acme_greeting`), `update`/`remove` semantics, snapshot
|
||||
behavior, and `dispose` are the same as the Promise flavor.
|
||||
|
||||
## Hooks
|
||||
|
||||
Callbacks return Effects. Registration stays in the plugin scope.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const session = ctx.session
|
||||
|
||||
yield* session.hook("context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.system.push({ type: "text", text: "Keep the review focused on correctness." })
|
||||
delete event.tools.write
|
||||
}),
|
||||
)
|
||||
|
||||
yield* session.hook("compaction", (event) =>
|
||||
Effect.map(summarize(event.messages), (summary) => {
|
||||
event.result = { summary }
|
||||
}),
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Failing a tool hook with `Tool.Error`:
|
||||
|
||||
```ts
|
||||
yield* ctx.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 }),
|
||||
)
|
||||
```
|
||||
|
||||
Hook names, event fields, ordering, and override precedence are the same
|
||||
**where the Effect page documents them** — but the two flavors do not have the
|
||||
same hook set:
|
||||
|
||||
- The Effect `SessionHooks` type lists `context`, `compaction`, `generate`,
|
||||
`title`, `model.request`, `http.request`, `http.response`, the three
|
||||
`experimental.ws.*`, and `retry`. **It has no `prompt` hook.** Do not carry
|
||||
the Promise `prompt` example over without confirming it on this page.
|
||||
- Provider scoping (`{ providerID: "anthropic" }` as a third argument) is
|
||||
documented here for `model.request`, `experimental.ws.handshake`, and
|
||||
`experimental.ws.send`. The simplified `Hooks<Spec, Failures>` type quoted
|
||||
earlier in this file does not show that third parameter, so do not treat it as
|
||||
the complete signature.
|
||||
|
||||
For the request-hook rules that both flavors share, see `SKILL.md`.
|
||||
|
||||
Effect callbacks use Effect interruption instead of `signal`; the other
|
||||
`experimental.ws.*` and `retry` hooks behave as documented there.
|
||||
|
||||
## Events
|
||||
|
||||
`ctx.event.subscribe()` is a `Stream`. Fork it in scope for automatic
|
||||
interruption — no `AbortController` needed.
|
||||
|
||||
```ts
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.tap((item) => Effect.logDebug("OpenCode event", { type: item.type })),
|
||||
Stream.runDrain,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((item) => item.type === "config.updated"),
|
||||
Stream.runForEach(() => Effect.logInfo("configuration changed")),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
```
|
||||
|
||||
## Branded values
|
||||
|
||||
The re-exported Effect schema constructors produce branded IDs rather than bare
|
||||
strings. Narrow and construct with them:
|
||||
|
||||
```ts
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const modelID = Model.ID.make("claude-sonnet-4-5")
|
||||
|
||||
const created = yield* ctx.session.create({ title: "Review" }).pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session
|
||||
.switchModel({ sessionID: created.id, model: { providerID, id: modelID } })
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.skill.transform((editor) => {
|
||||
editor.add(Skill.Info.make({
|
||||
id: Skill.ID.make("review"),
|
||||
name: Skill.Name.make("Review"),
|
||||
description: "Review the current changes",
|
||||
path: "/workspace/.opencode/skills/review/SKILL.md",
|
||||
content: "Review the current changes for correctness and missing tests.",
|
||||
}))
|
||||
})
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
The command transform is add-only here too. The executor returns an Effect.
|
||||
|
||||
```ts
|
||||
yield* command.transform((editor) => {
|
||||
editor.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),
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Integrations
|
||||
|
||||
OAuth method callbacks are Effects and may acquire scoped resources.
|
||||
|
||||
```ts
|
||||
yield* integration.transform((editor) => {
|
||||
editor.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),
|
||||
}),
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
`authorize` returns `mode: "auto"` (callback is the Effect that performs the
|
||||
flow) or `mode: "code"` (callback takes the code). The Effect's `R` may include
|
||||
`Scope.Scope`, so it can hold scoped resources for the attempt's duration.
|
||||
|
||||
## Context shape
|
||||
|
||||
`Context` declares `app`, `location`, `options`, and these domains: `agent`,
|
||||
`aisdk`, `command`, `event`, `experimental`, `integration`, `mcp`, `model`,
|
||||
`generate`, `permission`, `plugin`, `provider`, `reference`, `rpc`, `session`,
|
||||
`shell`, `skill`, `storage`, `tool`, `vcs`, `websearch`, `worktree`.
|
||||
|
||||
Three narrowed members worth noting:
|
||||
|
||||
- `experimental` is only
|
||||
`{ terminal: Pick<ExperimentalApi<unknown>["persistentPty"], "read"> }`. The
|
||||
narrowed member is `experimental.terminal`, not `experimental`.
|
||||
- `plugin` is only `Pick<PluginApi<unknown>, "list">`; `list` reports active,
|
||||
failed, and resolved plugins, so filter in a pipeline when you need only
|
||||
active ones. The page's example filters on `item.status === "active"`, but
|
||||
the shipped `Plugin.Info` nests that as `state.status` — trust the type.
|
||||
- `vcs` omits `init` as a client method, but a registered VCS **definition** may
|
||||
still implement it.
|
||||
|
||||
This is the **Effect** `Context`. Do not assume it matches the Promise context
|
||||
member for member — this page does not state that they are identical, and
|
||||
`aisdk` appears here with no Promise-page counterpart.
|
||||
|
||||
The context exposes the Effect client plus plugin-only transforms, hooks,
|
||||
storage, reloads, and options. It does **not** expose OpenCode's private Core
|
||||
services.
|
||||
|
||||
## Plugin type
|
||||
|
||||
```ts
|
||||
interface Plugin<R = Scope.Scope> {
|
||||
readonly id: string
|
||||
readonly effect: (context: Context) => Effect.Effect<void, never, R>
|
||||
}
|
||||
```
|
||||
|
||||
The `R` defaults to `Scope.Scope`, so registrations and scoped fibers are
|
||||
released when the plugin scope closes.
|
||||
|
||||
Two runtime caveats:
|
||||
|
||||
- Only **public** `effect` subpaths are provided. Importing an `internal` module
|
||||
fails to load with an error naming the path.
|
||||
- The compiled OpenCode binary omits the Scalar/Swagger UI assets used by
|
||||
Effect's HTTP API doc pages; serving those from a plugin shows a notice.
|
||||
|
||||
## Publish
|
||||
|
||||
Declare `effect` as a `peerDependency` and export the Effect implementation from
|
||||
the main entrypoint.
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "opencode-acme-effect-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"dependencies": { "@opencode/plugin": "latest" },
|
||||
"peerDependencies": { "effect": ">=4.0.0-rc.112" }
|
||||
}
|
||||
```
|
||||
|
||||
Test the installed tarball, not only a workspace link:
|
||||
|
||||
```sh
|
||||
bun pm pack
|
||||
bun add ./opencode-acme-effect-plugin-1.0.0.tgz
|
||||
```
|
||||
@@ -0,0 +1,329 @@
|
||||
# RPC Plugins
|
||||
|
||||
**Promise docs: <https://opencode.ai/v2/docs/build/plugins/rpc/>**
|
||||
**Effect docs: <https://opencode.ai/v2/docs/build/plugins/effect/rpc/>**
|
||||
**Templates: `../templates/rpc.ts` + `rpc-implementation.ts` (Promise),
|
||||
`../templates/rpc-effect.ts` + `rpc-implementation-effect.ts` (Effect)**
|
||||
|
||||
RPC lets a plugin expose custom **methods and events that run on the server** and
|
||||
can be called by other plugins or by clients. It is a contract plus a
|
||||
registration — the contract file itself contains no implementation.
|
||||
|
||||
There are two flavors with the same shape but different types and calling
|
||||
conventions. Check the differences table at the end before copying a snippet,
|
||||
and confirm each identifier on the page for your flavor.
|
||||
|
||||
## Define (Promise page)
|
||||
|
||||
Everything from here down to the Effect section documents the **Promise** page
|
||||
(<https://opencode.ai/v2/docs/build/plugins/rpc/>). The Effect flavor follows below.
|
||||
|
||||
Use `Rpc.define` in a file that other packages can import without loading your
|
||||
plugin. Publish it as the `./rpc` export.
|
||||
|
||||
```ts title="src/rpc.ts"
|
||||
import { Rpc } from "@opencode/plugin/rpc"
|
||||
|
||||
export const Acme = Rpc.define({
|
||||
id: "acme",
|
||||
methods: {
|
||||
search: {
|
||||
input: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
required: ["text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
errors: {
|
||||
not_found: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
updated: {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { itemID: { type: "string" }, text: { type: "string" } },
|
||||
required: ["itemID", "text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The RPC page does not describe what `id` affects; it only shows received event
|
||||
types arriving prefixed — `rpc.acme.updated` for an event named `updated` under `id: "acme"` —
|
||||
and does not otherwise describe how `id` affects method routing, so do not rely
|
||||
on that beyond what the page states. Pick a name that will not collide.
|
||||
|
||||
The `./rpc` export is described as optional on the **plugins** page
|
||||
(<https://opencode.ai/v2/docs/build/plugins>), not on the RPC page:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "opencode-acme-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./rpc": "./src/rpc.ts"
|
||||
},
|
||||
"dependencies": { "@opencode/plugin": "latest" }
|
||||
}
|
||||
```
|
||||
|
||||
## Schemas (Promise page)
|
||||
|
||||
Two formats are supported:
|
||||
|
||||
- **JSON Schema** — simple, no dependencies. Values are `unknown` in
|
||||
TypeScript, so **narrow them before use**.
|
||||
- **Any Standard Schema validator** — Zod, Valibot, ArkType. Types are inferred.
|
||||
|
||||
```ts
|
||||
import * as z from "zod"
|
||||
|
||||
search: { input: z.object({ query: z.string() }), output: z.object({ text: z.string() }) }
|
||||
```
|
||||
|
||||
Omit `input` or `output` when the method takes or returns nothing.
|
||||
|
||||
## Errors (Promise page)
|
||||
|
||||
Add an `errors` map for expected failures. Each key becomes the error's `type`;
|
||||
its schema defines the error's `data`. Names beginning with `rpc.` are reserved
|
||||
by OpenCode.
|
||||
|
||||
## Events (Promise page)
|
||||
|
||||
Top-level `events` map. Event data must be an object — scalars, arrays, `null`,
|
||||
and `undefined` are invalid. Use an empty object schema for no data:
|
||||
|
||||
```ts
|
||||
events: { refreshed: { schema: { type: "object", additionalProperties: false } } }
|
||||
```
|
||||
|
||||
## Implement (Promise page)
|
||||
|
||||
Register inside `setup`:
|
||||
|
||||
```ts title="src/index.ts"
|
||||
import { Plugin } from "@opencode/plugin"
|
||||
import { Acme } from "./rpc.js" // your contract file, e.g. src/rpc.ts
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme-plugin",
|
||||
async setup(ctx) {
|
||||
const registration = await ctx.rpc.register(Acme, {
|
||||
search: async (input, context) => {
|
||||
const { query } = input as { query: string }
|
||||
const text = await findText(query, { signal: context.signal })
|
||||
if (!text) return context.error("not_found", "Result not found", { query })
|
||||
return { text }
|
||||
},
|
||||
})
|
||||
|
||||
// the same plugin can call its own RPC
|
||||
const acme = ctx.rpc(Acme)
|
||||
const result = await acme.search({ query: "hello" })
|
||||
|
||||
await registration.events.emit("updated", { itemID: "item-1", text: "ready" })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The second argument gives `signal` for cancellation and `context.error(...)` for
|
||||
declared errors — return it or throw it. One plugin may register several RPCs;
|
||||
the page states that disposing the registration removes it.
|
||||
|
||||
## Call (Promise page)
|
||||
|
||||
### From a client over HTTP
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import { Acme } from "opencode-acme-plugin/rpc"
|
||||
|
||||
const client = OpenCode.make({ baseUrl: "http://localhost:4096" })
|
||||
const acme = client.rpc(Acme)
|
||||
const result = await acme.search({ query: "hello" })
|
||||
```
|
||||
|
||||
### From another plugin
|
||||
|
||||
Plugins already hold a client. A TUI plugin uses `context.client`:
|
||||
|
||||
```ts title="src/tui.ts"
|
||||
import { Plugin } from "@opencode/plugin/tui"
|
||||
import { Acme } from "opencode-acme-plugin/rpc"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme-tui",
|
||||
async setup(context) {
|
||||
const acme = context.client.rpc(Acme)
|
||||
const result = await acme.search({ query: "hello" })
|
||||
console.log(result)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Subscribe (Promise page)
|
||||
|
||||
Promise flavor. Callback form returns an unsubscribe function:
|
||||
|
||||
```ts
|
||||
const unsubscribe = acme.events.on("updated", (event) => {
|
||||
console.log(event.type, event.location.directory, event.data.text)
|
||||
})
|
||||
unsubscribe()
|
||||
```
|
||||
|
||||
Async iterable form:
|
||||
|
||||
```ts
|
||||
for await (const event of acme.events.subscribe("updated")) {
|
||||
console.log(event.data.text)
|
||||
}
|
||||
```
|
||||
|
||||
Details stated on the Promise page:
|
||||
|
||||
- Subscribe with the **local** name (`updated`). The received `type` is
|
||||
**prefixed** (`rpc.acme.updated`).
|
||||
- Every event carries `data` and `location`.
|
||||
- Subscriptions are **live only** — a disconnected subscriber misses events.
|
||||
- Plugin unload closes its subscriptions.
|
||||
- External clients receive events from **every** location, so check
|
||||
`event.location`. The server plugin must still be configured and running.
|
||||
|
||||
## Effect flavor
|
||||
|
||||
The Effect page
|
||||
(<https://opencode.ai/v2/docs/build/plugins/effect/rpc/>) has the same contract
|
||||
shape but Effect types and conventions. `Rpc.define` is imported from
|
||||
`@opencode/plugin/rpc` here too — not from `@opencode/plugin/effect`.
|
||||
|
||||
```ts title="src/rpc.ts"
|
||||
import { Rpc } from "@opencode/plugin/rpc"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Acme = Rpc.define({
|
||||
id: "acme",
|
||||
methods: {
|
||||
search: {
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
errors: { not_found: Schema.Struct({ query: Schema.String }) },
|
||||
},
|
||||
},
|
||||
events: {
|
||||
updated: {
|
||||
schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }),
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Effect Schema validates and infers types, so no casts. The page also states that
|
||||
JSON Schema and Standard Schema are supported, so an Effect contract is not
|
||||
restricted to Effect Schema.
|
||||
|
||||
Registration happens inside the plugin `effect`, not `setup`:
|
||||
|
||||
```ts title="src/index.ts"
|
||||
import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Effect } from "effect"
|
||||
import { Acme } from "./rpc.js"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme-effect-plugin",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const registration = yield* ctx.rpc.register(Acme, {
|
||||
search: ({ query }, context) =>
|
||||
findText(query).pipe(
|
||||
Effect.flatMap((text) =>
|
||||
text
|
||||
? Effect.succeed({ text })
|
||||
: Effect.fail(context.error("not_found", "Result not found", { query })),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const acme = ctx.rpc(Acme)
|
||||
const result = yield* acme.search({ query: "hello" })
|
||||
|
||||
yield* registration.events.emit("updated", { itemID: "item-1", text: result.text })
|
||||
}).pipe(Effect.orDie),
|
||||
})
|
||||
```
|
||||
|
||||
Input arrives already decoded, so the handler destructures instead of casting.
|
||||
A declared error is raised with `Effect.fail(context.error(type, message, data))`.
|
||||
`context.signal` is **not** mentioned on the Effect page.
|
||||
|
||||
Calling over HTTP needs the Effect client plus an HTTP client layer:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode/client/effect"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Acme } from "opencode-acme-plugin/rpc"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
|
||||
const acme = client.rpc(Acme)
|
||||
return yield* acme.search({ query: "hello" })
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer)))
|
||||
```
|
||||
|
||||
Subscriptions are a Stream; `events.on` is not mentioned on this page:
|
||||
|
||||
```ts
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
const acme = ctx.rpc(Acme)
|
||||
yield* acme.events.subscribe("updated").pipe(
|
||||
Stream.runForEach((event) => Effect.logInfo(event.data.text)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
```
|
||||
|
||||
"Subscriptions are live only and close when Stream consumption stops."
|
||||
|
||||
## Promise vs Effect
|
||||
|
||||
| Aspect | Promise | Effect |
|
||||
| ------ | ------- | ------ |
|
||||
| `Rpc.define` import | `@opencode/plugin/rpc` | `@opencode/plugin/rpc` (same) |
|
||||
| Schemas | JSON Schema or Standard Schema; JSON Schema values are `unknown`, narrow them | Effect `Schema`, types inferred. JSON Schema and Standard Schema also supported |
|
||||
| Handler signature | `async (input, context)`, then `input as { … }` | `({ … }, context) => Effect`, input already decoded |
|
||||
| Success | `return { text }` | `Effect.succeed({ text })` |
|
||||
| Declared error | `return context.error("not_found", msg, data)` | `Effect.fail(context.error("not_found", msg, data))` |
|
||||
| Cancellation | pass `context.signal` to the work | not documented on that page; use interruption |
|
||||
| Registered in | `await ctx.rpc.register` in `async setup` | `yield* ctx.rpc.register` in `Effect.gen` under `effect:` |
|
||||
| Emit | `await registration.events.emit(…)` | `yield* registration.events.emit(…)` |
|
||||
| HTTP client | `OpenCode.make({…})` from `@opencode/client` | `yield* OpenCode.make({…})` from `@opencode/client/effect`, plus `FetchHttpClient.layer` |
|
||||
| Another plugin calls it | TUI plugin: `context.client.rpc(Acme)` | Effect plugin: `ctx.rpc(Acme)` |
|
||||
| Subscribe | `events.on(…)` → unsubscribe fn, or `events.subscribe(…)` → async iterable | `events.subscribe(…)` → Stream + `Effect.forkScoped` |
|
||||
|
||||
Stated only on the **Promise** page — do not assume these carry over to the
|
||||
Effect flavor without checking that page: `rpc.`-prefixed error names being
|
||||
reserved, the "scalars, arrays, `null`, and `undefined` are not valid event
|
||||
data" rule and its empty-object-schema form, the prefixed `rpc.acme.updated`
|
||||
type, `event.location`, plugin unload closing subscriptions, `events.on`, and
|
||||
disposing the registration.
|
||||
Reference in New Issue
Block a user