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
+329
View File
@@ -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.