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
139 lines
5.5 KiB
TypeScript
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)
|
|
}
|
|
},
|
|
})
|