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,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
|
||||
```
|
||||
Reference in New Issue
Block a user