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