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,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[] }[]
>