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,143 @@
|
||||
// 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[] }[]
|
||||
>
|
||||
Reference in New Issue
Block a user