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,78 @@
|
||||
# Templates
|
||||
|
||||
Starter files transcribed from the OpenCode V2 doc pages. **Copy one and fill in
|
||||
the blanks** rather than writing a plugin from scratch.
|
||||
|
||||
| Template | Use for | Source doc |
|
||||
| -------- | ------- | ---------- |
|
||||
| `promise-plugin.ts` | Server plugin, Promise flavor (default) | <https://opencode.ai/v2/docs/build/plugins> |
|
||||
| `effect-plugin.ts` | Server plugin, Effect-native | <https://opencode.ai/v2/docs/build/plugins/effect/> |
|
||||
| `rpc.ts` | RPC contract, Promise flavor (copy to `src/rpc.ts`) | <https://opencode.ai/v2/docs/build/plugins/rpc/> |
|
||||
| `rpc-implementation.ts` | RPC registration + calls, Promise | <https://opencode.ai/v2/docs/build/plugins/rpc/> |
|
||||
| `rpc-effect.ts` | RPC contract, Effect flavor | <https://opencode.ai/v2/docs/build/plugins/effect/rpc/> |
|
||||
| `rpc-implementation-effect.ts` | RPC registration + calls, Effect | <https://opencode.ai/v2/docs/build/plugins/effect/rpc/> |
|
||||
| `tui-plugin.tsx` | TUI / CLI plugin | <https://opencode.ai/v2/docs/build/plugins/cli/> |
|
||||
| `opencode-plugin-config.jsonc` | `plugins` in `opencode.json(c)` | <https://opencode.ai/v2/docs/plugins> |
|
||||
| `package.json` | Publishing a plugin package (strict JSON) | <https://opencode.ai/v2/docs/build/plugins> |
|
||||
| `package-json.md` | Publishing guidance, peer deps, entrypoints | build/plugins, build/plugins/effect, build/plugins/cli |
|
||||
| `README.md` | This index | — |
|
||||
|
||||
`package.json` is strict JSON with no comments, because Node, bun, npm, and
|
||||
bundlers parse it with a strict parser. Publishing guidance therefore lives in
|
||||
`package-json.md` rather than inside the manifest.
|
||||
|
||||
The two RPC template pairs are **not** interchangeable: same contract shape,
|
||||
different schema dialect, handler signature, and registration entrypoint. See the
|
||||
Promise-vs-Effect table in `../references/rpc.md`.
|
||||
|
||||
## Before you write code
|
||||
|
||||
1. Check the version: `opencode --version`, `opencode api get /api/info`.
|
||||
2. Pick the flavor. Default to Promise. Effect only if the project already uses
|
||||
Effect. TUI is a separate export. RPC is a contract plus a registration.
|
||||
3. Open the source doc in the table above and keep it open.
|
||||
4. Copy the template. Delete the sections you do not need.
|
||||
5. Verify every field name against that page. Do not guess.
|
||||
|
||||
## Rules the templates encode
|
||||
|
||||
These are the constraints the pages state, not stylistic preferences. Note which
|
||||
are type-level rather than documented runtime failures.
|
||||
|
||||
- A `transform` callback is typed `(editor) => void` in both flavors — no
|
||||
Promise, no Effect.
|
||||
- Tool `update` preserves name and namespace. **Assign** a new schema or options
|
||||
object to replace one rather than mutating nested values. In-place edits inside
|
||||
an `update` callback are fine — that is what the mutable draft is for.
|
||||
- `reload()` replays transforms. It does **not** rerun `setup` / `effect`.
|
||||
- Captured inputs are not watched. Load external data before the sync callback,
|
||||
then call `reload()` when it changes.
|
||||
- Use the **effective** tool name — including its namespace — for `update` and
|
||||
`remove` (`example_greeting`, not `greeting`). Only `add` creates a tool.
|
||||
- A `context` hook covers the agent loop only. Register each request kind you
|
||||
actually need.
|
||||
- Result shape is **per operation, not per flavor**: `storage.get` returns a
|
||||
value, `model.list()` is enveloped. Copy the page's example for the call you
|
||||
use.
|
||||
- Effect plugins use `effect`, not `setup`. The two flavors do not have the same
|
||||
context members or the same hook set.
|
||||
- Relative plugin paths in config resolve from the **config file**, not the
|
||||
working directory.
|
||||
- Registering an event subscription without tearing it down leaks it.
|
||||
`forkScoped` handles this in Effect; `AbortController` in Promise.
|
||||
- `package.json` must be strict JSON — `//` comments there break `bun`, `npm`,
|
||||
and every bundler. `opencode.jsonc` may use them.
|
||||
|
||||
## Anything not in a template
|
||||
|
||||
The pages carry more detail than fits here. When you need something not in these
|
||||
files, go to the source doc rather than improvising:
|
||||
|
||||
- Every `Agent.Info`, `Provider.Info`, `Model.Info`, `Session.Info`,
|
||||
`Tool.Content`, `Mcp.Server`, `Skill.Info`, `Vcs.Info`, `Reference.Info`,
|
||||
`Integration.Info`, `Connection.Info`, `Form.Answer`, `Command.Info`,
|
||||
`Plugin.Info`, `WebSearch.*`, `Worktree*` shape:
|
||||
<https://opencode.ai/v2/docs/api>
|
||||
- The exact contract of the running server: `/openapi.json` on that server.
|
||||
- Hook event fields: the `Reference` code block at the end of each section on
|
||||
the relevant page.
|
||||
@@ -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[] }[]
|
||||
>
|
||||
@@ -0,0 +1,122 @@
|
||||
// opencode.jsonc — plugin configuration template
|
||||
//
|
||||
// Docs: https://opencode.ai/v2/docs/plugins
|
||||
// Terminal plugins: https://opencode.ai/v2/docs/cli/plugins
|
||||
//
|
||||
// https://opencode.ai/v2/docs/config states: "OpenCode supports JSON and
|
||||
// JSONC. Use JSONC when you want comments or trailing commas." Use the
|
||||
// two-slash form used below. (Hash comments would not parse — that is a
|
||||
// property of JSONC, not something the page states.)
|
||||
//
|
||||
// Keep the $schema line to match the docs. Be aware that
|
||||
// https://opencode.ai/config.json does not match the V2 config shape: its
|
||||
// Config has additionalProperties: false, has no "plugins" key, types "skills"
|
||||
// as an object, and has no "worktree" key. So some editors will flag the array
|
||||
// below as invalid. There is no published V2 config schema
|
||||
// (https://opencode.ai/v2/config.json returns 404). Never infer V2 field names
|
||||
// from that file. Drop the line if you prefer a clean editor.
|
||||
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
|
||||
"plugins": [
|
||||
// --- package plugins ---
|
||||
"opencode-acme-plugin",
|
||||
"opencode-acme-plugin@1.2.0", // exact versions stay pinned
|
||||
"@acme/opencode-plugin",
|
||||
|
||||
// --- local paths ---
|
||||
// RELATIVE PATHS RESOLVE FROM THE CONFIG FILE CONTAINING THIS ENTRY,
|
||||
// not from the working directory. (The `skills` array is the opposite.)
|
||||
"./plugins/local",
|
||||
"../shared/plugin.ts",
|
||||
"/absolute/path/plugin.ts",
|
||||
"file:///home/me/plugins/local",
|
||||
|
||||
// --- with options --- (read them in setup via ctx.options, untyped)
|
||||
{
|
||||
"package": "./plugins/company",
|
||||
"options": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disabling plugins
|
||||
//
|
||||
// Entries are processed in order. Prefix an ID or wildcard with "-" to disable,
|
||||
// "*" for every plugin, ".*" for an ID prefix. A later ID re-enables one.
|
||||
//
|
||||
// "plugins": ["*", "-opencode.provider.*", "opencode.provider.openai", "-acme.reviewer"]
|
||||
//
|
||||
// Two built-ins ignore removals so a repository cannot switch off policy
|
||||
// enforcement: opencode.config.policy and opencode.provider.opencode.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI-only plugins go in cli.json, not here.
|
||||
//
|
||||
// A plugin in this file that exports "./tui" is loaded automatically by the CLI.
|
||||
// Do NOT also add it to cli.json. The CLI gets the active plugin list from the
|
||||
// connected server, so it works when the server is remote.
|
||||
//
|
||||
// cli.json is for plugins that run locally in the terminal and must stay active
|
||||
// against a remote server.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Precedence
|
||||
//
|
||||
// Plugin arrays from applicable config files apply from lowest to highest
|
||||
// precedence rather than replacing one another:
|
||||
//
|
||||
// ~/.config/opencode/opencode.jsonc
|
||||
// ./opencode.jsonc
|
||||
// ./.opencode/opencode.jsonc
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discovery
|
||||
//
|
||||
// Direct .ts/.js files and immediate package directories load from every
|
||||
// discovered .opencode/plugins/, plus the global config directory:
|
||||
//
|
||||
// .opencode/plugins/concise.ts
|
||||
// .opencode/plugins/acme-package/
|
||||
// ~/.config/opencode/plugins/status/index.ts
|
||||
// ~/.config/opencode/plugins/status/tui.ts
|
||||
//
|
||||
// A plugins/ directory beside a project-root opencode.json(c) is NOT
|
||||
// discovered automatically — configure it explicitly or move it under .opencode/.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI commands
|
||||
//
|
||||
// add and remove write to the GLOBAL config (~/.config/opencode/opencode.jsonc).
|
||||
//
|
||||
// opencode plugin add opencode-acme-plugin@1.2.0
|
||||
// opencode plugin add github:acme/opencode-plugin
|
||||
// opencode plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
|
||||
// opencode plugin list
|
||||
// opencode plugin list --builtin
|
||||
// opencode plugin check
|
||||
// opencode plugin update
|
||||
// opencode plugin update opencode-acme-plugin
|
||||
// opencode plugin remove opencode-acme-plugin@1.2.0
|
||||
//
|
||||
// Pass a configured package target to check or update only that package.
|
||||
// plugin add accepts npm names with versions, tags, or ranges, plus
|
||||
// npm-compatible Git specs. Tarballs and npm aliases are NOT accepted.
|
||||
//
|
||||
// plugin check covers server and TUI-only package plugins. Local plugins and
|
||||
// exact package revisions are skipped.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reload
|
||||
//
|
||||
// Watched config directories reload automatically. Startup loads cached package
|
||||
// plugins immediately, installs missing packages in the background, and checks
|
||||
// unpinned npm and Git plugins for updates without changing the installed
|
||||
// package. Exact versions and full commit hashes stay pinned.
|
||||
//
|
||||
// touch .opencode/plugins/concise/index.ts
|
||||
// opencode service restart // for unwatched local dependencies
|
||||
@@ -0,0 +1,91 @@
|
||||
# Publishing a plugin package
|
||||
|
||||
`package.json` here is **strict JSON with no comments**, because Node, bun, npm,
|
||||
and every bundler parse it with a strict parser. Copy it as-is and delete the
|
||||
entrypoints you do not implement. Guidance lives in this file rather than in
|
||||
comments inside the manifest.
|
||||
|
||||
Source: <https://opencode.ai/v2/docs/build/plugins> (Publish)
|
||||
|
||||
## Entrypoints
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "opencode-acme-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./rpc": "./src/rpc.ts",
|
||||
"./tui": "./src/tui.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Export | Needed for | Documented on |
|
||||
| ------ | ---------- | ------------- |
|
||||
| `.` | a server plugin | <https://opencode.ai/v2/docs/build/plugins> |
|
||||
| `./rpc` | publishing a shared RPC contract others can import without loading your implementation. The docs call this export **optional**. | <https://opencode.ai/v2/docs/build/plugins> |
|
||||
| `./tui` | the TUI plugin. Exporting it makes the CLI load the plugin automatically when the package is listed in `opencode.json(c)`. **Do not also add the package to `cli.json`** — the CLI gets the active plugin list from the server. | <https://opencode.ai/v2/docs/build/plugins/cli/> and <https://opencode.ai/v2/docs/cli/plugins> |
|
||||
|
||||
## Effect plugin
|
||||
|
||||
The documented manifest puts `effect` in `peerDependencies`, not
|
||||
`dependencies`:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": { "@opencode/plugin": "latest" },
|
||||
"peerDependencies": { "effect": ">=4.0.0-rc.112" }
|
||||
}
|
||||
```
|
||||
|
||||
Do **not** bundle `effect`. OpenCode provides `effect`, `effect/*`, and
|
||||
`@opencode/plugin` at runtime and redirects imports — including from your
|
||||
`node_modules` — to the host instance, so fibers, loggers, and `Schema` parsers
|
||||
share one runtime copy. If you bundle, mark `effect` and `effect/*` external.
|
||||
|
||||
The same Effect page also says to install `effect` and `@opencode/plugin` as
|
||||
peer/dev dependencies for local type-checking:
|
||||
|
||||
```sh
|
||||
bun add --peer @opencode/plugin effect
|
||||
```
|
||||
|
||||
Plugins always execute against the host release's `effect`. Using APIs or module
|
||||
paths from another `effect` major (such as Effect 3) is not supported. Only
|
||||
public `effect` subpaths are provided; importing an `internal` module fails to
|
||||
load with an error naming the path. The compiled OpenCode binary omits the
|
||||
Scalar/Swagger UI assets used by Effect's HTTP API doc pages, so serving those
|
||||
from a plugin shows a notice.
|
||||
|
||||
## TUI plugin rendering JSX
|
||||
|
||||
```json
|
||||
{
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.5.8",
|
||||
"@opentui/solid": ">=0.5.8",
|
||||
"solid-js": ">=1.9.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
From <https://opencode.ai/v2/docs/build/plugins/cli/>. Confirm the version floors
|
||||
against that page before publishing; they are the kind of value that goes stale.
|
||||
|
||||
## Testing before publishing
|
||||
|
||||
Test the **installed** package, not only a workspace-linked copy:
|
||||
|
||||
```sh
|
||||
bun pm pack
|
||||
bun add ./opencode-acme-plugin-1.0.0.tgz
|
||||
```
|
||||
|
||||
Use versions compatible with the OpenCode release you target — check with
|
||||
`opencode --version`. If a release changes the plugin API, publish a compatible
|
||||
update rather than leaving an old contract in place.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "opencode-acme-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./rpc": "./src/rpc.ts",
|
||||
"./tui": "./src/tui.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
// RPC contract (Effect flavor) — template
|
||||
//
|
||||
// Docs: https://opencode.ai/v2/docs/build/plugins/effect/rpc/
|
||||
//
|
||||
// Copy to src/rpc.ts — the implementation template's doc-accurate import is
|
||||
// "./rpc.js", so the file must be named rpc.ts. Contract only, no
|
||||
// implementation. Same contract shape as rpc.ts, but with Effect Schema
|
||||
// instead of raw JSON Schema. Registered by rpc-implementation-effect.ts.
|
||||
//
|
||||
// Rpc.define is imported from "@opencode/plugin/rpc" here too, NOT from
|
||||
// "@opencode/plugin/effect". The Effect page imports Schema from the root
|
||||
// "effect" module.
|
||||
//
|
||||
// TYPE NOTE: the shipped `ValueSchema` type is
|
||||
// Schema.Codec | StandardSchemaV1 | JsonSchema
|
||||
// and Effect's `Schema.Struct` is not a `Codec`, so `tsc --strict` can reject
|
||||
// the form below. The page states JSON Schema and Standard Schema are supported
|
||||
// here too, so either of these typechecks instead:
|
||||
// input: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
|
||||
// input: z.object({ query: z.string() }) // or Valibot / ArkType
|
||||
// A method needs BOTH input and output to be present for the types to line up.
|
||||
|
||||
//
|
||||
// PREREQUISITE: install @opencode/plugin at your OpenCode version and restart
|
||||
// the service — see the PREREQUISITE block in promise-plugin.ts.
|
||||
//
|
||||
import { Rpc } from "@opencode/plugin/rpc"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Acme = Rpc.define({
|
||||
// The page does not describe how `id` affects method routing. Pick a name
|
||||
// that will not collide.
|
||||
id: "acme",
|
||||
methods: {
|
||||
search: {
|
||||
// Effect Schema validates and infers types, so the handler needs no casts.
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
// Omit input or output if the method takes or returns nothing.
|
||||
// Each error key becomes the error's `type`.
|
||||
errors: {
|
||||
not_found: Schema.Struct({ query: Schema.String }),
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
// Event data must be an object.
|
||||
updated: {
|
||||
schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// The Effect page also states that JSON Schema and Standard Schema are
|
||||
// supported here, so an Effect contract is not restricted to Effect Schema.
|
||||
@@ -0,0 +1,74 @@
|
||||
// RPC implementation (Effect flavor) — template
|
||||
//
|
||||
// Docs: https://opencode.ai/v2/docs/build/plugins/effect/rpc/
|
||||
//
|
||||
// Needs templates/rpc-effect.ts copied next to this file as src/rpc.ts, so the
|
||||
// doc's "./rpc.js" specifier resolves. Rename it rpc.ts on save; the contract
|
||||
// shape is identical, only the schema dialect differs.
|
||||
//
|
||||
// Registration goes inside the plugin `effect`, NOT `setup`.
|
||||
|
||||
//
|
||||
// The docs use "@opencode/plugin" and "@opencode/plugin/effect". Install the
|
||||
// package at your OpenCode version and restart the service first — see the
|
||||
// PREREQUISITE block in promise-plugin.ts.
|
||||
import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Acme } from "./rpc.js"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme-effect-plugin",
|
||||
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const registration = yield* ctx.rpc.register(Acme, {
|
||||
// Input arrives already decoded — destructure it, do not cast.
|
||||
search: ({ query }, context) =>
|
||||
findText(query).pipe(
|
||||
Effect.flatMap((text) =>
|
||||
text
|
||||
? Effect.succeed({ text })
|
||||
: Effect.fail(context.error("not_found", "Result not found", { query })),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
// The same plugin can call its own RPC.
|
||||
const acme = ctx.rpc(Acme)
|
||||
const result = yield* acme.search({ query: "hello" })
|
||||
|
||||
yield* registration.events.emit("updated", { itemID: "item-1", text: result.text })
|
||||
|
||||
// Subscriptions are a Stream; forkScoped handles interruption.
|
||||
// "Subscriptions are live only and close when Stream consumption stops."
|
||||
yield* acme.events.subscribe("updated").pipe(
|
||||
Stream.runForEach((event) => Effect.logInfo(event.data.text)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
// context.signal is NOT documented on the Effect page. Use interruption.
|
||||
}).pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
// Calling over HTTP uses the Effect client and needs an HTTP client layer:
|
||||
//
|
||||
// import { OpenCode } from "@opencode/client/effect"
|
||||
// import { Effect } from "effect"
|
||||
// import { FetchHttpClient } from "effect/unstable/http"
|
||||
// import { Acme } from "opencode-acme-plugin/rpc"
|
||||
//
|
||||
// const program = Effect.gen(function* () {
|
||||
// const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
|
||||
// const acme = client.rpc(Acme)
|
||||
// return yield* acme.search({ query: "hello" })
|
||||
// })
|
||||
//
|
||||
// const result = await Effect.runPromise(
|
||||
// program.pipe(Effect.provide(FetchHttpClient.layer)),
|
||||
// )
|
||||
//
|
||||
// Another Effect plugin calls it with ctx.rpc(Acme). events.on and the
|
||||
// prefixed event type are documented only on the Promise page — check before
|
||||
// relying on either here.
|
||||
|
||||
declare function findText(query: string): Effect.Effect<string | undefined>
|
||||
@@ -0,0 +1,79 @@
|
||||
// RPC implementation + calling — template
|
||||
//
|
||||
// Docs: https://opencode.ai/v2/docs/build/plugins/rpc/
|
||||
//
|
||||
// Two files. The contract is importable without loading the implementation, so
|
||||
// other plugins and clients can depend on it.
|
||||
//
|
||||
// Needs templates/rpc.ts copied next to this file as src/rpc.ts, so the
|
||||
// doc's "./rpc.js" specifier resolves. An Effect plugin would import the
|
||||
// Effect-dialect contract instead — see rpc-effect.ts.
|
||||
|
||||
//
|
||||
// The docs use "@opencode/plugin" and "@opencode/plugin/effect". Install the
|
||||
// package at your OpenCode version and restart the service first — see the
|
||||
// PREREQUISITE block in promise-plugin.ts.
|
||||
import { Plugin } from "@opencode/plugin"
|
||||
import { Acme } from "./rpc.js"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme-plugin",
|
||||
|
||||
async setup(ctx) {
|
||||
const registration = await ctx.rpc.register(Acme, {
|
||||
search: async (input, context) => {
|
||||
// JSON Schema input is `unknown` — narrow it.
|
||||
const { query } = input as { query: string }
|
||||
|
||||
// `signal` cancels with the request; `context.error` declares a failure.
|
||||
const text = await findText(query, { signal: context.signal })
|
||||
if (!text) return context.error("not_found", "Result not found", { query })
|
||||
|
||||
return { text }
|
||||
},
|
||||
})
|
||||
|
||||
// The same plugin can call its own RPC once registered.
|
||||
const acme = ctx.rpc(Acme)
|
||||
// Raw JSON Schema output arrives as `unknown` in the Promise flavor; the
|
||||
// docs say to narrow it.
|
||||
const result = (await acme.search({ query: "hello" })) as { text: string }
|
||||
|
||||
// Emit to subscribers.
|
||||
await registration.events.emit("updated", { itemID: "item-1", text: result.text })
|
||||
|
||||
// ---------------------------------------------------------------- events
|
||||
// Subscribe with the LOCAL name. The received `type` is prefixed
|
||||
// ("rpc.acme.updated"). Every event carries `data` and `location`.
|
||||
//
|
||||
// Subscriptions are LIVE ONLY: a disconnected subscriber misses events.
|
||||
// Plugin unload closes them.
|
||||
//
|
||||
// const unsubscribe = acme.events.on("updated", (event) => {
|
||||
// console.log(event.type, event.location.directory, event.data.text)
|
||||
// })
|
||||
// unsubscribe()
|
||||
//
|
||||
// for await (const event of acme.events.subscribe("updated")) {
|
||||
// console.log(event.data.text)
|
||||
// }
|
||||
|
||||
return () => {
|
||||
void registration.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Calling from a client over HTTP:
|
||||
// import { OpenCode } from "@opencode/client"
|
||||
// import { Acme } from "opencode-acme-plugin/rpc"
|
||||
// const client = OpenCode.make({ baseUrl: "http://localhost:4096" })
|
||||
// const acme = client.rpc(Acme)
|
||||
// const result = await acme.search({ query: "hello" })
|
||||
|
||||
// Calling from another plugin (a TUI plugin uses `context.client`):
|
||||
// import { Plugin } from "@opencode/plugin/tui"
|
||||
// const acme = context.client.rpc(Acme)
|
||||
// const result = await acme.search({ query: "hello" })
|
||||
|
||||
declare function findText(query: string, options: { signal: AbortSignal }): Promise<string | undefined>
|
||||
@@ -0,0 +1,69 @@
|
||||
// RPC contract — template
|
||||
//
|
||||
// Docs: https://opencode.ai/v2/docs/build/plugins/rpc/
|
||||
//
|
||||
// Copy to src/rpc.ts. This file is the contract only — no implementation.
|
||||
// Registered by rpc-implementation.ts (Promise) or rpc-implementation-effect.ts
|
||||
// (Effect). Use rpc-effect.ts instead if you want Effect Schema.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PREREQUISITE: install @opencode/plugin at your OpenCode version and restart
|
||||
// the service — see the PREREQUISITE block in promise-plugin.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
import { Rpc } from "@opencode/plugin/rpc"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// src/rpc.ts — the contract. No implementation here.
|
||||
//
|
||||
// Schemas may be JSON Schema (simple, no deps; values are `unknown` in
|
||||
// TypeScript so narrow them) or any Standard Schema validator such as Zod,
|
||||
// Valibot, or ArkType (types are inferred).
|
||||
//
|
||||
// Error names starting with "rpc." are reserved by OpenCode.
|
||||
// Event data must be an object: scalars, arrays, null, and undefined are invalid.
|
||||
export const Acme = Rpc.define({
|
||||
// The page shows received event types arriving prefixed, e.g. rpc.acme.updated.
|
||||
// It does not otherwise describe how `id` affects method routing.
|
||||
id: "acme",
|
||||
methods: {
|
||||
search: {
|
||||
input: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
required: ["text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
// Omit input or output entirely if the method takes or returns nothing.
|
||||
errors: {
|
||||
// Each key becomes the error's `type`; this schema defines its `data`.
|
||||
not_found: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
updated: {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
itemID: { type: "string" },
|
||||
text: { type: "string" },
|
||||
},
|
||||
required: ["itemID", "text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
// No data at all? Use an empty object schema.
|
||||
// refreshed: { schema: { type: "object", additionalProperties: false } },
|
||||
},
|
||||
})
|
||||
@@ -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