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,146 @@
|
||||
// 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
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user