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,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