Files
xiaoshuai 15a1bbcee0 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
2026-10-08 13:12:10 +08:00

139 lines
5.5 KiB
TypeScript

// Promise server plugin — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins
// Config: https://opencode.ai/v2/docs/plugins
//
// Copy this file, keep only the sections you need, and delete the rest.
// Transcribed from the doc page's own examples. Where a field name is uncertain,
// re-read the page above before inventing it.
//
// ---------------------------------------------------------------------------
// PREREQUISITE: the package is not bundled with the CLI. Install it at the
// version matching your OpenCode release, then restart the service (the running
// service caches module resolution):
//
// 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.
// ---------------------------------------------------------------------------
// Model.Info.default(providerID, Model.ID.make("id")) builds a model definition.
import { Model, Plugin, Provider } from "@opencode/plugin"
export default Plugin.define({
// Give this a name that will not collide with another plugin.
id: "example",
// Runs when the plugin loads. May return a cleanup function.
async setup(ctx) {
// ---------------------------------------------------------------- storage
// Durable JSON scoped to the plugin. Storage returns values directly.
await ctx.storage.set("settings", { strict: true })
// ctx.storage.get("settings") -> the value
// ctx.storage.scan({ prefix: "cache/", limit: 100 }) -> { entries, next }
// ctx.storage.remove("settings")
// ---------------------------------------------------------------- options
// Untyped. Narrow before use.
const strict = ctx.options.strict === true
// ------------------------------------------------------------- transform
// The callback is SYNCHRONOUS. Do not return a Promise or an Effect from it.
await ctx.model.transform((editor) => {
editor
.list()
// Number() and String(): the shipped types brand these money and id
// fields (Money.USDPerMillionTokens, Model.ID), so `>` and `remove`
// do not typecheck against the raw values. The docs show the unbranded
// form; widen here.
.filter((model) => model.cost.some((tier) => Number(tier.output) > 20))
.forEach((model) => {
editor.remove(String(model.providerID), String(model.id))
})
})
// ------------------------------------------------------------- a tool
// Registration can be disposed to remove just this transform.
const registration = await ctx.tool.transform((editor) => {
editor.namespace({ name: "example", description: "Example tools" })
editor.add({
name: "greeting",
description: "Create a greeting",
// Raw JSON Schema in the Promise flavor, so `input` arrives as `unknown`
// in TypeScript. Narrow it once at the top of execute.
input: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
},
options: { namespace: "example", codemode: true },
execute: async (rawInput, context) => {
const { name } = rawInput as { name: string }
// Pass the signal so stopping the session stops the work.
await context.progress({ status: "greeting" })
return { content: `Hello ${name}!` }
},
})
})
// --------------------------------------------------------------- a hook
// "context" runs for the agent loop only. Register each kind you need.
const hook = await ctx.session.hook("context", (event) => {
event.system.push({ type: "text", text: "Keep the review focused on correctness." })
delete event.tools.write
event.options.temperature = 0.2
})
// ------------------------------------------------------------- provider
// Optional: contribute a provider and its models.
if (strict) {
const providerID = Provider.ID.make("acme")
const models = [{ ...Model.Info.default(providerID, Model.ID.make("reasoner")), name: "Acme Reasoner" }]
await ctx.provider.transform((editor) => {
editor.add({
info: {
...Provider.Info.empty(providerID),
name: "Acme",
activation: "enabled",
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "http://127.0.0.1:8000/v1" },
},
models,
})
})
}
// --------------------------------------------------------------- events
// Must abort during cleanup.
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
console.log(event.type)
}
})()
// ------------------------------------------------- refresh from a source
// Captured inputs are NOT watched. Load first, then reload() on change.
// const source = { models: await loadModels() }
// const timer = setInterval(() => {
// void (async () => {
// source.models = await loadModels()
// await ctx.model.reload()
// })().catch(console.error)
// }, 60_000)
// Return a cleanup function for anything you own. Returning nothing is fine.
return () => {
controller.abort()
void registration.dispose()
void hook.dispose()
// clearInterval(timer)
}
},
})