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

147 lines
6.5 KiB
TypeScript

// TUI / CLI plugin — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/cli/
// Loading: https://opencode.ai/v2/docs/cli/plugins
//
// A TUI plugin is a SEPARATE export from a server plugin. One package can have
// both: "." is the server plugin, "./tui" is this file.
// PREREQUISITE: install the package at your OpenCode version and restart the
// service (see the PREREQUISITE block in promise-plugin.ts).
//
// This entrypoint also needs its JSX runtime peers: solid-js, @opentui/core,
// @opentui/solid, @opencode/theme. Without them the file will not load, and
// `tsc` reports that JSX.IntrinsicElements is missing.
import { Plugin } from "@opencode/plugin/tui"
export default Plugin.define({
id: "acme.cli",
// The page's own examples use `async setup(context)` when they await.
async setup(context) {
// ---------------------------------------------------------------- basics
const location = context.location ?? context.data.location.default()
void context.app.version
void context.options.compact
// ---------------------------------------------------------------- toasts
context.ui.toast.show({ message: "CLI plugin loaded", variant: "success" })
// -------------------------------------------------------------- commands
// The layer factory MUST be pure. OpenCode evaluates it once without
// dependency tracking to validate the initial shape, then reactively.
context.keymap.layer(() => ({
mode: "global",
priority: 10,
commands: [
{
id: "acme.status",
title: "Show Acme status",
group: "Acme",
bind: "ctrl+g",
palette: true,
slash: { name: "acme", aliases: ["status"], arguments: true },
enabled: () => true,
suggested: true,
run: async (input) => context.ui.toast.show({ message: input ?? "Ready" }),
},
],
bindings: ["acme.status"],
}))
// ---------------------------------------------------------------- events
// Both return an unsubscribe function.
const stopPermission = context.data.on("permission.asked", (event) => {
context.ui.toast.show({ message: `Permission ${event.data.id}` })
})
const stopAll = context.data.listen(({ details }) => console.log(details.type))
// ---------------------------------------------------------------- dialog
// await context.ui.dialog.alert({ title: "Acme", message: "Ready" })
// const ok = await context.ui.dialog.confirm({ title: "Continue?", message: "Run it?" })
// const name = await context.ui.dialog.prompt({ title: "Name", placeholder: "release" })
// const mode = await context.ui.dialog.select({
// title: "Mode",
// current: "safe",
// options: [
// { title: "Safe", value: "safe", description: "Ask before changes" },
// { title: "Fast", value: "fast", category: "Advanced" },
// ],
// // `search` owns matching and order. It runs for EVERY query, including
// // the empty one, and receives only enabled options.
// search: (query, options) =>
// options
// .filter((o) => o.title.toLowerCase().includes(query.toLowerCase()))
// .toSorted((l, r) => r.title.localeCompare(l.title)),
// // Actions appear in the footer. They do NOT close the dialog or settle
// // the promise. Never bind ctrl+n / ctrl+p — the dialog reserves them.
// actions: [
// { bind: "ctrl+o", title: "Inspect", onTrigger: (value) => context.ui.toast.show({ message: value }) },
// { bind: "ctrl+a", title: "Close", side: "right", selection: "none", onTrigger: () => context.ui.dialog.clear() },
// ],
// })
// ---------------------------------------------------------------- slots
// Placement: prepend | append | before | after | replace.
// Use "home.footer.status" to ADD to the built-in footer without replacing
// it. Status contributions appear after health indicators, before the version.
// The page's own snippet RETURNS context.ui.slot(...) from setup, but never
// says the return value is a teardown (unlike router.register, where it
// does). Treat removeSlot() below as an assumption to verify.
const removeSlot = context.ui.slot({
append: "sidebar.content",
render: ({ sessionID }) => <text>{context.data.session.get(sessionID)?.title}</text>,
})
// ---------------------------------------------------------------- storage
// store: persists across restarts, syncs across TUI instances.
// memory: survives plugin reloads, discarded when the TUI exits.
const [settings, updateSettings] = context.storage.store("settings", {
initial: { compact: false },
})
await updateSettings((draft) => {
draft.compact = true
})
// -------------------------------------------------------------- attention
// context.attention.notify({
// title: "OpenCode",
// message: "Session done",
// notification: { when: "blurred" },
// sound: { name: "done", volume: 0.5, when: "always" },
// })
// ----------------------------------------------------------------- model
// current() and variant.list() are reactive in a Solid computation.
// variant.set returns false when no model is selected or it has no such
// variant. set(undefined) returns to the model default.
// const selected = context.ui.model.current()
// context.ui.model.variant.set("high")
// ------------------------------------------------------------------ tabs
// if (context.ui.tabs.enabled()) {
// context.ui.tabs.open(backgroundSessionID) // leaves focus unchanged
// context.ui.tabs.focus(sessionID) // opens if needed
// const tabs = context.ui.tabs.list()
// context.ui.tabs.move(backgroundSessionID, tabs.length - 1)
// context.ui.tabs.close(sessionID)
// }
// ------------------------------------------------------------- formatting
// const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project")
// Return a cleanup function for resources the plugin owns. Separately, a
// KEYMAP LAYER is also removed when the plugin is disabled, reloaded, or
// fails setup, or when the component/reactive computation that created it
// (a slot, route, or dialog) is disposed. Disabling a plugin removes slot
// contributions through normal slot cleanup.
return () => {
stopPermission()
stopAll()
removeSlot()
void settings
void location
}
},
})