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:
2026-10-08 13:12:10 +08:00
commit 15a1bbcee0
17 changed files with 3494 additions and 0 deletions
@@ -0,0 +1,78 @@
# Templates
Starter files transcribed from the OpenCode V2 doc pages. **Copy one and fill in
the blanks** rather than writing a plugin from scratch.
| Template | Use for | Source doc |
| -------- | ------- | ---------- |
| `promise-plugin.ts` | Server plugin, Promise flavor (default) | <https://opencode.ai/v2/docs/build/plugins> |
| `effect-plugin.ts` | Server plugin, Effect-native | <https://opencode.ai/v2/docs/build/plugins/effect/> |
| `rpc.ts` | RPC contract, Promise flavor (copy to `src/rpc.ts`) | <https://opencode.ai/v2/docs/build/plugins/rpc/> |
| `rpc-implementation.ts` | RPC registration + calls, Promise | <https://opencode.ai/v2/docs/build/plugins/rpc/> |
| `rpc-effect.ts` | RPC contract, Effect flavor | <https://opencode.ai/v2/docs/build/plugins/effect/rpc/> |
| `rpc-implementation-effect.ts` | RPC registration + calls, Effect | <https://opencode.ai/v2/docs/build/plugins/effect/rpc/> |
| `tui-plugin.tsx` | TUI / CLI plugin | <https://opencode.ai/v2/docs/build/plugins/cli/> |
| `opencode-plugin-config.jsonc` | `plugins` in `opencode.json(c)` | <https://opencode.ai/v2/docs/plugins> |
| `package.json` | Publishing a plugin package (strict JSON) | <https://opencode.ai/v2/docs/build/plugins> |
| `package-json.md` | Publishing guidance, peer deps, entrypoints | build/plugins, build/plugins/effect, build/plugins/cli |
| `README.md` | This index | — |
`package.json` is strict JSON with no comments, because Node, bun, npm, and
bundlers parse it with a strict parser. Publishing guidance therefore lives in
`package-json.md` rather than inside the manifest.
The two RPC template pairs are **not** interchangeable: same contract shape,
different schema dialect, handler signature, and registration entrypoint. See the
Promise-vs-Effect table in `../references/rpc.md`.
## Before you write code
1. Check the version: `opencode --version`, `opencode api get /api/info`.
2. Pick the flavor. Default to Promise. Effect only if the project already uses
Effect. TUI is a separate export. RPC is a contract plus a registration.
3. Open the source doc in the table above and keep it open.
4. Copy the template. Delete the sections you do not need.
5. Verify every field name against that page. Do not guess.
## Rules the templates encode
These are the constraints the pages state, not stylistic preferences. Note which
are type-level rather than documented runtime failures.
- A `transform` callback is typed `(editor) => void` in both flavors — no
Promise, no Effect.
- Tool `update` preserves name and namespace. **Assign** a new schema or options
object to replace one rather than mutating nested values. In-place edits inside
an `update` callback are fine — that is what the mutable draft is for.
- `reload()` replays transforms. It does **not** rerun `setup` / `effect`.
- Captured inputs are not watched. Load external data before the sync callback,
then call `reload()` when it changes.
- Use the **effective** tool name — including its namespace — for `update` and
`remove` (`example_greeting`, not `greeting`). Only `add` creates a tool.
- A `context` hook covers the agent loop only. Register each request kind you
actually need.
- Result shape is **per operation, not per flavor**: `storage.get` returns a
value, `model.list()` is enveloped. Copy the page's example for the call you
use.
- Effect plugins use `effect`, not `setup`. The two flavors do not have the same
context members or the same hook set.
- Relative plugin paths in config resolve from the **config file**, not the
working directory.
- Registering an event subscription without tearing it down leaks it.
`forkScoped` handles this in Effect; `AbortController` in Promise.
- `package.json` must be strict JSON — `//` comments there break `bun`, `npm`,
and every bundler. `opencode.jsonc` may use them.
## Anything not in a template
The pages carry more detail than fits here. When you need something not in these
files, go to the source doc rather than improvising:
- Every `Agent.Info`, `Provider.Info`, `Model.Info`, `Session.Info`,
`Tool.Content`, `Mcp.Server`, `Skill.Info`, `Vcs.Info`, `Reference.Info`,
`Integration.Info`, `Connection.Info`, `Form.Answer`, `Command.Info`,
`Plugin.Info`, `WebSearch.*`, `Worktree*` shape:
<https://opencode.ai/v2/docs/api>
- The exact contract of the running server: `/openapi.json` on that server.
- Hook event fields: the `Reference` code block at the end of each section on
the relevant page.
@@ -0,0 +1,143 @@
// Effect server plugin — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/effect/
//
// Copy this file, keep only what you need, delete the rest.
// Transcribed from the doc page. Note the differences from the Promise flavor:
// entry is `effect`, not `setup`; reads are yieldable; cleanup is via Scope.
// ---------------------------------------------------------------------------
// PREREQUISITE: the package is not bundled with the CLI. Install it at the
// version matching your OpenCode release, then restart the service:
//
// cd ~/.config/opencode
// npm install @opencode/plugin@$(opencode --version | sed 's/^v//')
// opencode service restart
//
// A stale `@opencode-ai/plugin` (the V1-era name) in node_modules is a leftover
// dependency, not what the host provides — ignore its export list.
// ---------------------------------------------------------------------------
// The Effect page imports these from the effect entrypoint; the Transforms
// example shows `import { Model, Plugin, Provider } from "@opencode/plugin/effect"`.
import { Model, Plugin, Provider } from "@opencode/plugin/effect"
import { Effect, Schedule, Schema, Stream } from "effect"
export default Plugin.define({
id: "example",
// `effect` runs at load. The scope closes on reload or unload, which releases
// registrations, scoped fibers, and finalizers together.
effect: (ctx) =>
Effect.gen(function* () {
// ---------------------------------------------------------------- storage
yield* ctx.storage.set("loaded", true)
// ---------------------------------------------------------------- options
// Untyped. Narrow before use.
const strict = ctx.options.strict === true
// -------------------------------------------------------------- logging
yield* Effect.logInfo("loaded", { version: ctx.app.version })
// ---------------------------------------------------------------- reads
// Effect reads usually return a decoded envelope: result lands on .data
const models = yield* ctx.model.list().pipe(Effect.orDie)
yield* Effect.logInfo("models", { count: models.data.length })
const active = yield* ctx.plugin
.list()
.pipe(
Effect.orDie,
// NOTE: the doc page writes `p.status === "active"`, but the shipped
// Plugin.Info nests it as `state.status`. Trust the type.
Effect.map((r) => r.data.filter((p) => p.state.status === "active")),
)
yield* Effect.logInfo("active plugins", { count: active.length })
// ------------------------------------------------------------- transform
// The callback is STILL SYNCHRONOUS, even here. No Effect, no Promise.
yield* ctx.model.transform((editor) => {
editor
.list()
.filter((model) => model.cost.some((tier) => tier.output > 20))
.forEach((model) => {
editor.remove(model.providerID, model.id)
})
})
// ----------------------------------------------------------------- tool
// Effect plugins use Effect Schema instead of raw JSON Schema, so input
// and output are typed.
yield* ctx.tool.transform((editor) => {
editor.namespace({ name: "example", description: "Example tools" })
editor.add({
name: "greeting",
description: "Create a greeting",
input: Schema.Struct({ name: Schema.String }),
output: Schema.Struct({ greeting: Schema.String }),
options: { namespace: "example", codemode: true },
execute: ({ name }, context) =>
Effect.gen(function* () {
yield* context.progress({ status: "greeting" })
// The page's executor example returns { output } for typed output.
return { output: { greeting: `Hello ${name}!` } }
}),
})
})
// ---------------------------------------------------------------- hooks
// Callbacks return Effects.
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.system.push({ type: "text", text: "Keep the review focused on correctness." })
delete event.tools.write
}),
)
// A hook may fail with Tool.Error to block a tool call.
// 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 }),
// )
// ------------------------------------------------------------- provider
// Optional: contribute providers and refresh them from a source. The
// page's example loader returns entries shaped
// `{ info: Provider.Info, models: readonly Model.Info[] }`.
if (strict) {
const source = { providers: yield* loadFromSource() }
yield* ctx.provider.transform((editor) => {
source.providers.forEach((provider) => editor.add(provider))
})
// Captured inputs are not watched: fork a scoped fiber to refresh.
yield* Effect.repeat(
Effect.gen(function* () {
source.providers = yield* loadFromSource()
yield* ctx.provider.reload()
}),
{ schedule: Schedule.spaced("1 minute") },
).pipe(Effect.forkScoped)
}
// ---------------------------------------------------------------- events
// subscribe() is a Stream. forkScoped handles interruption for you.
yield* ctx.event.subscribe().pipe(
Stream.filter((item) => item.type === "config.updated"),
Stream.runForEach(() => Effect.logInfo("configuration changed")),
Effect.forkScoped,
)
// No explicit cleanup needed: the scope closes it all.
}),
})
// Stand-in for your own loader, used by the provider block above. The doc
// page's example loader returns `{ info: Provider.Info, models: readonly
// Model.Info[] }` entries.
declare function loadFromSource(): Effect.Effect<
readonly { info: Provider.Info; models: readonly Model.Info[] }[]
>
@@ -0,0 +1,122 @@
// opencode.jsonc — plugin configuration template
//
// Docs: https://opencode.ai/v2/docs/plugins
// Terminal plugins: https://opencode.ai/v2/docs/cli/plugins
//
// https://opencode.ai/v2/docs/config states: "OpenCode supports JSON and
// JSONC. Use JSONC when you want comments or trailing commas." Use the
// two-slash form used below. (Hash comments would not parse — that is a
// property of JSONC, not something the page states.)
//
// Keep the $schema line to match the docs. Be aware that
// https://opencode.ai/config.json does not match the V2 config shape: its
// Config has additionalProperties: false, has no "plugins" key, types "skills"
// as an object, and has no "worktree" key. So some editors will flag the array
// below as invalid. There is no published V2 config schema
// (https://opencode.ai/v2/config.json returns 404). Never infer V2 field names
// from that file. Drop the line if you prefer a clean editor.
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
// --- package plugins ---
"opencode-acme-plugin",
"opencode-acme-plugin@1.2.0", // exact versions stay pinned
"@acme/opencode-plugin",
// --- local paths ---
// RELATIVE PATHS RESOLVE FROM THE CONFIG FILE CONTAINING THIS ENTRY,
// not from the working directory. (The `skills` array is the opposite.)
"./plugins/local",
"../shared/plugin.ts",
"/absolute/path/plugin.ts",
"file:///home/me/plugins/local",
// --- with options --- (read them in setup via ctx.options, untyped)
{
"package": "./plugins/company",
"options": {
"strict": true
}
}
]
}
// ---------------------------------------------------------------------------
// Disabling plugins
//
// Entries are processed in order. Prefix an ID or wildcard with "-" to disable,
// "*" for every plugin, ".*" for an ID prefix. A later ID re-enables one.
//
// "plugins": ["*", "-opencode.provider.*", "opencode.provider.openai", "-acme.reviewer"]
//
// Two built-ins ignore removals so a repository cannot switch off policy
// enforcement: opencode.config.policy and opencode.provider.opencode.
//
// ---------------------------------------------------------------------------
// CLI-only plugins go in cli.json, not here.
//
// A plugin in this file that exports "./tui" is loaded automatically by the CLI.
// Do NOT also add it to cli.json. The CLI gets the active plugin list from the
// connected server, so it works when the server is remote.
//
// cli.json is for plugins that run locally in the terminal and must stay active
// against a remote server.
//
// ---------------------------------------------------------------------------
// Precedence
//
// Plugin arrays from applicable config files apply from lowest to highest
// precedence rather than replacing one another:
//
// ~/.config/opencode/opencode.jsonc
// ./opencode.jsonc
// ./.opencode/opencode.jsonc
//
// ---------------------------------------------------------------------------
// Discovery
//
// Direct .ts/.js files and immediate package directories load from every
// discovered .opencode/plugins/, plus the global config directory:
//
// .opencode/plugins/concise.ts
// .opencode/plugins/acme-package/
// ~/.config/opencode/plugins/status/index.ts
// ~/.config/opencode/plugins/status/tui.ts
//
// A plugins/ directory beside a project-root opencode.json(c) is NOT
// discovered automatically — configure it explicitly or move it under .opencode/.
//
// ---------------------------------------------------------------------------
// CLI commands
//
// add and remove write to the GLOBAL config (~/.config/opencode/opencode.jsonc).
//
// opencode plugin add opencode-acme-plugin@1.2.0
// opencode plugin add github:acme/opencode-plugin
// opencode plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
// opencode plugin list
// opencode plugin list --builtin
// opencode plugin check
// opencode plugin update
// opencode plugin update opencode-acme-plugin
// opencode plugin remove opencode-acme-plugin@1.2.0
//
// Pass a configured package target to check or update only that package.
// plugin add accepts npm names with versions, tags, or ranges, plus
// npm-compatible Git specs. Tarballs and npm aliases are NOT accepted.
//
// plugin check covers server and TUI-only package plugins. Local plugins and
// exact package revisions are skipped.
//
// ---------------------------------------------------------------------------
// Reload
//
// Watched config directories reload automatically. Startup loads cached package
// plugins immediately, installs missing packages in the background, and checks
// unpinned npm and Git plugins for updates without changing the installed
// package. Exact versions and full commit hashes stay pinned.
//
// touch .opencode/plugins/concise/index.ts
// opencode service restart // for unwatched local dependencies
@@ -0,0 +1,91 @@
# Publishing a plugin package
`package.json` here is **strict JSON with no comments**, because Node, bun, npm,
and every bundler parse it with a strict parser. Copy it as-is and delete the
entrypoints you do not implement. Guidance lives in this file rather than in
comments inside the manifest.
Source: <https://opencode.ai/v2/docs/build/plugins> (Publish)
## Entrypoints
```json title="package.json"
{
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./rpc": "./src/rpc.ts",
"./tui": "./src/tui.tsx"
},
"dependencies": {
"@opencode/plugin": "latest"
}
}
```
| Export | Needed for | Documented on |
| ------ | ---------- | ------------- |
| `.` | a server plugin | <https://opencode.ai/v2/docs/build/plugins> |
| `./rpc` | publishing a shared RPC contract others can import without loading your implementation. The docs call this export **optional**. | <https://opencode.ai/v2/docs/build/plugins> |
| `./tui` | the TUI plugin. Exporting it makes the CLI load the plugin automatically when the package is listed in `opencode.json(c)`. **Do not also add the package to `cli.json`** — the CLI gets the active plugin list from the server. | <https://opencode.ai/v2/docs/build/plugins/cli/> and <https://opencode.ai/v2/docs/cli/plugins> |
## Effect plugin
The documented manifest puts `effect` in `peerDependencies`, not
`dependencies`:
```json
{
"dependencies": { "@opencode/plugin": "latest" },
"peerDependencies": { "effect": ">=4.0.0-rc.112" }
}
```
Do **not** bundle `effect`. OpenCode provides `effect`, `effect/*`, and
`@opencode/plugin` at runtime and redirects imports — including from your
`node_modules` — to the host instance, so fibers, loggers, and `Schema` parsers
share one runtime copy. If you bundle, mark `effect` and `effect/*` external.
The same Effect page also says to install `effect` and `@opencode/plugin` as
peer/dev dependencies for local type-checking:
```sh
bun add --peer @opencode/plugin effect
```
Plugins always execute against the host release's `effect`. Using APIs or module
paths from another `effect` major (such as Effect 3) is not supported. 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, so serving those
from a plugin shows a notice.
## TUI plugin rendering JSX
```json
{
"peerDependencies": {
"@opentui/core": ">=0.5.8",
"@opentui/solid": ">=0.5.8",
"solid-js": ">=1.9.0"
}
}
```
From <https://opencode.ai/v2/docs/build/plugins/cli/>. Confirm the version floors
against that page before publishing; they are the kind of value that goes stale.
## Testing before publishing
Test the **installed** package, not only a workspace-linked copy:
```sh
bun pm pack
bun add ./opencode-acme-plugin-1.0.0.tgz
```
Use versions compatible with the OpenCode release you target — check with
`opencode --version`. If a release changes the plugin API, publish a compatible
update rather than leaving an old contract in place.
@@ -0,0 +1,13 @@
{
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./rpc": "./src/rpc.ts",
"./tui": "./src/tui.tsx"
},
"dependencies": {
"@opencode/plugin": "latest"
}
}
@@ -0,0 +1,138 @@
// Promise server plugin — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins
// Config: https://opencode.ai/v2/docs/plugins
//
// Copy this file, keep only the sections you need, and delete the rest.
// Transcribed from the doc page's own examples. Where a field name is uncertain,
// re-read the page above before inventing it.
//
// ---------------------------------------------------------------------------
// PREREQUISITE: the package is not bundled with the CLI. Install it at the
// version matching your OpenCode release, then restart the service (the running
// service caches module resolution):
//
// cd ~/.config/opencode
// npm install @opencode/plugin@$(opencode --version | sed 's/^v//')
// opencode service restart
//
// A stale `@opencode-ai/plugin` (the V1-era name) in node_modules is a leftover
// dependency, not what the host provides — ignore its export list.
// ---------------------------------------------------------------------------
// Model.Info.default(providerID, Model.ID.make("id")) builds a model definition.
import { Model, Plugin, Provider } from "@opencode/plugin"
export default Plugin.define({
// Give this a name that will not collide with another plugin.
id: "example",
// Runs when the plugin loads. May return a cleanup function.
async setup(ctx) {
// ---------------------------------------------------------------- storage
// Durable JSON scoped to the plugin. Storage returns values directly.
await ctx.storage.set("settings", { strict: true })
// ctx.storage.get("settings") -> the value
// ctx.storage.scan({ prefix: "cache/", limit: 100 }) -> { entries, next }
// ctx.storage.remove("settings")
// ---------------------------------------------------------------- options
// Untyped. Narrow before use.
const strict = ctx.options.strict === true
// ------------------------------------------------------------- transform
// The callback is SYNCHRONOUS. Do not return a Promise or an Effect from it.
await ctx.model.transform((editor) => {
editor
.list()
// Number() and String(): the shipped types brand these money and id
// fields (Money.USDPerMillionTokens, Model.ID), so `>` and `remove`
// do not typecheck against the raw values. The docs show the unbranded
// form; widen here.
.filter((model) => model.cost.some((tier) => Number(tier.output) > 20))
.forEach((model) => {
editor.remove(String(model.providerID), String(model.id))
})
})
// ------------------------------------------------------------- a tool
// Registration can be disposed to remove just this transform.
const registration = await ctx.tool.transform((editor) => {
editor.namespace({ name: "example", description: "Example tools" })
editor.add({
name: "greeting",
description: "Create a greeting",
// Raw JSON Schema in the Promise flavor, so `input` arrives as `unknown`
// in TypeScript. Narrow it once at the top of execute.
input: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
},
options: { namespace: "example", codemode: true },
execute: async (rawInput, context) => {
const { name } = rawInput as { name: string }
// Pass the signal so stopping the session stops the work.
await context.progress({ status: "greeting" })
return { content: `Hello ${name}!` }
},
})
})
// --------------------------------------------------------------- a hook
// "context" runs for the agent loop only. Register each kind you need.
const hook = await ctx.session.hook("context", (event) => {
event.system.push({ type: "text", text: "Keep the review focused on correctness." })
delete event.tools.write
event.options.temperature = 0.2
})
// ------------------------------------------------------------- provider
// Optional: contribute a provider and its models.
if (strict) {
const providerID = Provider.ID.make("acme")
const models = [{ ...Model.Info.default(providerID, Model.ID.make("reasoner")), name: "Acme Reasoner" }]
await ctx.provider.transform((editor) => {
editor.add({
info: {
...Provider.Info.empty(providerID),
name: "Acme",
activation: "enabled",
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "http://127.0.0.1:8000/v1" },
},
models,
})
})
}
// --------------------------------------------------------------- events
// Must abort during cleanup.
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
console.log(event.type)
}
})()
// ------------------------------------------------- refresh from a source
// Captured inputs are NOT watched. Load first, then reload() on change.
// const source = { models: await loadModels() }
// const timer = setInterval(() => {
// void (async () => {
// source.models = await loadModels()
// await ctx.model.reload()
// })().catch(console.error)
// }, 60_000)
// Return a cleanup function for anything you own. Returning nothing is fine.
return () => {
controller.abort()
void registration.dispose()
void hook.dispose()
// clearInterval(timer)
}
},
})
@@ -0,0 +1,55 @@
// RPC contract (Effect flavor) — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/effect/rpc/
//
// Copy to src/rpc.ts — the implementation template's doc-accurate import is
// "./rpc.js", so the file must be named rpc.ts. Contract only, no
// implementation. Same contract shape as rpc.ts, but with Effect Schema
// instead of raw JSON Schema. Registered by rpc-implementation-effect.ts.
//
// Rpc.define is imported from "@opencode/plugin/rpc" here too, NOT from
// "@opencode/plugin/effect". The Effect page imports Schema from the root
// "effect" module.
//
// TYPE NOTE: the shipped `ValueSchema` type is
// Schema.Codec | StandardSchemaV1 | JsonSchema
// and Effect's `Schema.Struct` is not a `Codec`, so `tsc --strict` can reject
// the form below. The page states JSON Schema and Standard Schema are supported
// here too, so either of these typechecks instead:
// input: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
// input: z.object({ query: z.string() }) // or Valibot / ArkType
// A method needs BOTH input and output to be present for the types to line up.
//
// PREREQUISITE: install @opencode/plugin at your OpenCode version and restart
// the service — see the PREREQUISITE block in promise-plugin.ts.
//
import { Rpc } from "@opencode/plugin/rpc"
import { Schema } from "effect"
export const Acme = Rpc.define({
// The page does not describe how `id` affects method routing. Pick a name
// that will not collide.
id: "acme",
methods: {
search: {
// Effect Schema validates and infers types, so the handler needs no casts.
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
// Omit input or output if the method takes or returns nothing.
// Each error key becomes the error's `type`.
errors: {
not_found: Schema.Struct({ query: Schema.String }),
},
},
},
events: {
// Event data must be an object.
updated: {
schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }),
},
},
})
// The Effect page also states that JSON Schema and Standard Schema are
// supported here, so an Effect contract is not restricted to Effect Schema.
@@ -0,0 +1,74 @@
// RPC implementation (Effect flavor) — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/effect/rpc/
//
// Needs templates/rpc-effect.ts copied next to this file as src/rpc.ts, so the
// doc's "./rpc.js" specifier resolves. Rename it rpc.ts on save; the contract
// shape is identical, only the schema dialect differs.
//
// Registration goes inside the plugin `effect`, NOT `setup`.
//
// The docs use "@opencode/plugin" and "@opencode/plugin/effect". Install the
// package at your OpenCode version and restart the service first — see the
// PREREQUISITE block in promise-plugin.ts.
import { Plugin } from "@opencode/plugin/effect"
import { Effect, Stream } 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, {
// Input arrives already decoded — destructure it, do not cast.
search: ({ query }, context) =>
findText(query).pipe(
Effect.flatMap((text) =>
text
? Effect.succeed({ text })
: Effect.fail(context.error("not_found", "Result not found", { query })),
),
),
})
// The same plugin can call its own RPC.
const acme = ctx.rpc(Acme)
const result = yield* acme.search({ query: "hello" })
yield* registration.events.emit("updated", { itemID: "item-1", text: result.text })
// Subscriptions are a Stream; forkScoped handles interruption.
// "Subscriptions are live only and close when Stream consumption stops."
yield* acme.events.subscribe("updated").pipe(
Stream.runForEach((event) => Effect.logInfo(event.data.text)),
Effect.forkScoped,
)
// context.signal is NOT documented on the Effect page. Use interruption.
}).pipe(Effect.orDie),
})
// Calling over HTTP uses the Effect client and needs an HTTP client layer:
//
// 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)),
// )
//
// Another Effect plugin calls it with ctx.rpc(Acme). events.on and the
// prefixed event type are documented only on the Promise page — check before
// relying on either here.
declare function findText(query: string): Effect.Effect<string | undefined>
@@ -0,0 +1,79 @@
// RPC implementation + calling — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/rpc/
//
// Two files. The contract is importable without loading the implementation, so
// other plugins and clients can depend on it.
//
// Needs templates/rpc.ts copied next to this file as src/rpc.ts, so the
// doc's "./rpc.js" specifier resolves. An Effect plugin would import the
// Effect-dialect contract instead — see rpc-effect.ts.
//
// The docs use "@opencode/plugin" and "@opencode/plugin/effect". Install the
// package at your OpenCode version and restart the service first — see the
// PREREQUISITE block in promise-plugin.ts.
import { Plugin } from "@opencode/plugin"
import { Acme } from "./rpc.js"
export default Plugin.define({
id: "acme-plugin",
async setup(ctx) {
const registration = await ctx.rpc.register(Acme, {
search: async (input, context) => {
// JSON Schema input is `unknown` — narrow it.
const { query } = input as { query: string }
// `signal` cancels with the request; `context.error` declares a failure.
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 once registered.
const acme = ctx.rpc(Acme)
// Raw JSON Schema output arrives as `unknown` in the Promise flavor; the
// docs say to narrow it.
const result = (await acme.search({ query: "hello" })) as { text: string }
// Emit to subscribers.
await registration.events.emit("updated", { itemID: "item-1", text: result.text })
// ---------------------------------------------------------------- events
// Subscribe with the LOCAL name. 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 them.
//
// const unsubscribe = acme.events.on("updated", (event) => {
// console.log(event.type, event.location.directory, event.data.text)
// })
// unsubscribe()
//
// for await (const event of acme.events.subscribe("updated")) {
// console.log(event.data.text)
// }
return () => {
void registration.dispose()
}
},
})
// Calling from a client over HTTP:
// 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" })
// Calling from another plugin (a TUI plugin uses `context.client`):
// import { Plugin } from "@opencode/plugin/tui"
// const acme = context.client.rpc(Acme)
// const result = await acme.search({ query: "hello" })
declare function findText(query: string, options: { signal: AbortSignal }): Promise<string | undefined>
+69
View File
@@ -0,0 +1,69 @@
// RPC contract — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/rpc/
//
// Copy to src/rpc.ts. This file is the contract only — no implementation.
// Registered by rpc-implementation.ts (Promise) or rpc-implementation-effect.ts
// (Effect). Use rpc-effect.ts instead if you want Effect Schema.
// ---------------------------------------------------------------------------
// PREREQUISITE: install @opencode/plugin at your OpenCode version and restart
// the service — see the PREREQUISITE block in promise-plugin.ts.
// ---------------------------------------------------------------------------
import { Rpc } from "@opencode/plugin/rpc"
// ---------------------------------------------------------------------------
// src/rpc.ts — the contract. No implementation here.
//
// Schemas may be JSON Schema (simple, no deps; values are `unknown` in
// TypeScript so narrow them) or any Standard Schema validator such as Zod,
// Valibot, or ArkType (types are inferred).
//
// Error names starting with "rpc." are reserved by OpenCode.
// Event data must be an object: scalars, arrays, null, and undefined are invalid.
export const Acme = Rpc.define({
// The page shows received event types arriving prefixed, e.g. rpc.acme.updated.
// It does not otherwise describe how `id` affects method routing.
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,
},
// Omit input or output entirely if the method takes or returns nothing.
errors: {
// Each key becomes the error's `type`; this schema defines its `data`.
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,
},
},
// No data at all? Use an empty object schema.
// refreshed: { schema: { type: "object", additionalProperties: false } },
},
})
@@ -0,0 +1,146 @@
// TUI / CLI plugin — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/cli/
// Loading: https://opencode.ai/v2/docs/cli/plugins
//
// A TUI plugin is a SEPARATE export from a server plugin. One package can have
// both: "." is the server plugin, "./tui" is this file.
// PREREQUISITE: install the package at your OpenCode version and restart the
// service (see the PREREQUISITE block in promise-plugin.ts).
//
// This entrypoint also needs its JSX runtime peers: solid-js, @opentui/core,
// @opentui/solid, @opencode/theme. Without them the file will not load, and
// `tsc` reports that JSX.IntrinsicElements is missing.
import { Plugin } from "@opencode/plugin/tui"
export default Plugin.define({
id: "acme.cli",
// The page's own examples use `async setup(context)` when they await.
async setup(context) {
// ---------------------------------------------------------------- basics
const location = context.location ?? context.data.location.default()
void context.app.version
void context.options.compact
// ---------------------------------------------------------------- toasts
context.ui.toast.show({ message: "CLI plugin loaded", variant: "success" })
// -------------------------------------------------------------- commands
// The layer factory MUST be pure. OpenCode evaluates it once without
// dependency tracking to validate the initial shape, then reactively.
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"],
}))
// ---------------------------------------------------------------- events
// Both return an unsubscribe function.
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))
// ---------------------------------------------------------------- dialog
// await context.ui.dialog.alert({ title: "Acme", message: "Ready" })
// const ok = await context.ui.dialog.confirm({ title: "Continue?", message: "Run it?" })
// 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" },
// ],
// // `search` owns matching and order. It runs for EVERY query, including
// // the empty one, and receives only enabled options.
// search: (query, options) =>
// options
// .filter((o) => o.title.toLowerCase().includes(query.toLowerCase()))
// .toSorted((l, r) => r.title.localeCompare(l.title)),
// // Actions appear in the footer. They do NOT close the dialog or settle
// // the promise. Never bind ctrl+n / ctrl+p — the dialog reserves them.
// 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() },
// ],
// })
// ---------------------------------------------------------------- slots
// Placement: prepend | append | before | after | replace.
// Use "home.footer.status" to ADD to the built-in footer without replacing
// it. Status contributions appear after health indicators, before the version.
// The page's own snippet RETURNS context.ui.slot(...) from setup, but never
// says the return value is a teardown (unlike router.register, where it
// does). Treat removeSlot() below as an assumption to verify.
const removeSlot = context.ui.slot({
append: "sidebar.content",
render: ({ sessionID }) => <text>{context.data.session.get(sessionID)?.title}</text>,
})
// ---------------------------------------------------------------- storage
// store: persists across restarts, syncs across TUI instances.
// memory: survives plugin reloads, discarded when the TUI exits.
const [settings, updateSettings] = context.storage.store("settings", {
initial: { compact: false },
})
await updateSettings((draft) => {
draft.compact = true
})
// -------------------------------------------------------------- attention
// context.attention.notify({
// title: "OpenCode",
// message: "Session done",
// notification: { when: "blurred" },
// sound: { name: "done", volume: 0.5, when: "always" },
// })
// ----------------------------------------------------------------- model
// current() and variant.list() are reactive in a Solid computation.
// variant.set returns false when no model is selected or it has no such
// variant. set(undefined) returns to the model default.
// const selected = context.ui.model.current()
// context.ui.model.variant.set("high")
// ------------------------------------------------------------------ tabs
// 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)
// }
// ------------------------------------------------------------- formatting
// const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project")
// Return a cleanup function for resources the plugin owns. Separately, a
// KEYMAP LAYER is also removed when the plugin is disabled, reloaded, or
// fails setup, or when the component/reactive computation that created it
// (a slot, route, or dialog) is disposed. Disabling a plugin removes slot
// contributions through normal slot cleanup.
return () => {
stopPermission()
stopAll()
removeSlot()
void settings
void location
}
},
})