15a1bbcee0
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
144 lines
6.0 KiB
TypeScript
144 lines
6.0 KiB
TypeScript
// 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[] }[]
|
|
>
|