mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-18 20:34:38 +00:00
fix(plugin): select plugins by id
This commit is contained in:
parent
2a4b298108
commit
fbb0fdf88a
8 changed files with 149 additions and 152 deletions
|
|
@ -72,23 +72,6 @@ type Operation =
|
|||
readonly target: string
|
||||
}
|
||||
|
||||
type Candidate =
|
||||
| {
|
||||
readonly type: "definition"
|
||||
readonly definition: Plugin
|
||||
}
|
||||
| {
|
||||
readonly type: "package"
|
||||
readonly specifier: string
|
||||
readonly options: Record<string, unknown>
|
||||
readonly mtime?: number
|
||||
}
|
||||
|
||||
type ConfiguredPackage = {
|
||||
readonly operation: Extract<Operation, { type: "add" }>
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
|
|
@ -109,13 +92,14 @@ const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Con
|
|||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return operation.type === "add" ? { ...operation, target } : { type: "remove" as const, target }
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
|
|
@ -136,88 +120,66 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
|||
post: readonly Plugin[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
const plan = apply(pre, post, operations)
|
||||
return yield* load(plan)
|
||||
})
|
||||
|
||||
function apply(pre: readonly Plugin[], post: readonly Plugin[], operations: readonly Operation[]) {
|
||||
const matches = (selector: string, target: string) =>
|
||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||
const plugins = [...pre, ...post]
|
||||
const enabled = new Set(plugins.map((plugin) => plugin.id))
|
||||
const packages = new Map<string, ConfiguredPackage>()
|
||||
const definitions = [...pre, ...post]
|
||||
const enabled = new Set(definitions.map((plugin) => plugin.id))
|
||||
const packages = new Map<string, Plugin>()
|
||||
const plugins = () => [...definitions, ...packages.values()]
|
||||
|
||||
for (const operation of operations) {
|
||||
if (operation.type === "remove") {
|
||||
plugins.filter((plugin) => matches(operation.target, plugin.id)).forEach((plugin) => enabled.delete(plugin.id))
|
||||
packages.forEach((item, target) => {
|
||||
if (matches(operation.target, target)) item.enabled = false
|
||||
})
|
||||
plugins()
|
||||
.filter((plugin) => matches(operation.target, plugin.id))
|
||||
.forEach((plugin) => enabled.delete(plugin.id))
|
||||
continue
|
||||
}
|
||||
|
||||
const matched = plugins.filter((plugin) => matches(operation.target, plugin.id))
|
||||
const selectsDefinitions =
|
||||
const matched = plugins().filter((plugin) => matches(operation.target, plugin.id))
|
||||
const selectsPlugins =
|
||||
matched.length > 0 ||
|
||||
operation.target === "*" ||
|
||||
operation.target.endsWith(".*") ||
|
||||
operation.target.startsWith("opencode.")
|
||||
if (selectsDefinitions) {
|
||||
if (selectsPlugins) {
|
||||
matched.forEach((plugin) => enabled.add(plugin.id))
|
||||
packages.forEach((item, target) => {
|
||||
if (matches(operation.target, target)) item.enabled = true
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
packages.set(operation.target, { operation, enabled: true })
|
||||
const plugin = yield* load(operation).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
if (!plugin) continue
|
||||
const previous = packages.get(operation.target)
|
||||
if (previous) enabled.delete(previous.id)
|
||||
packages.set(operation.target, plugin)
|
||||
enabled.add(plugin.id)
|
||||
}
|
||||
|
||||
const definitions: Candidate[] = pre.flatMap((definition) =>
|
||||
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
|
||||
)
|
||||
const configured: Candidate[] = Array.from(packages.values()).flatMap((item) =>
|
||||
item.enabled
|
||||
? [
|
||||
{
|
||||
type: "package",
|
||||
specifier: item.operation.target,
|
||||
options: item.operation.options,
|
||||
...(item.operation.mtime === undefined ? {} : { mtime: item.operation.mtime }),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
const posts: Candidate[] = post.flatMap((definition) =>
|
||||
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
|
||||
)
|
||||
return [...definitions, ...configured, ...posts]
|
||||
}
|
||||
return [
|
||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
]
|
||||
})
|
||||
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (plan: readonly Candidate[]) {
|
||||
return yield* Effect.forEach(plan, (candidate) => {
|
||||
if (candidate.type === "definition") return Effect.succeed(candidate.definition)
|
||||
return Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(candidate.specifier)
|
||||
? pathToFileURL(candidate.specifier).href
|
||||
: (yield* npm.add(candidate.specifier)).entrypoint
|
||||
if (!entrypoint) return undefined
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
candidate.mtime === undefined
|
||||
? entrypoint
|
||||
: `${candidate.specifier.replaceAll("\\", "/")}?mtime=${candidate.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => import(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
effect: (host) => plugin.effect({ ...host, options: candidate.options }),
|
||||
} satisfies Plugin
|
||||
}).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
}).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target)).entrypoint
|
||||
if (!entrypoint) return
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
operation.mtime === undefined
|
||||
? entrypoint
|
||||
: `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => import(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Plugin
|
||||
})
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,32 @@ describe("PluginSupervisor config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("disables configured plugins by exported ID", () => {
|
||||
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
|
||||
return withLocation(
|
||||
{ plugins: [plugin, "-config-promise-plugin"] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
expect((yield* plugins.list()).map((item) => String(item.id))).not.toContain("config-promise-plugin")
|
||||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("does not disable configured plugins by package target", () => {
|
||||
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
|
||||
return withLocation(
|
||||
{ plugins: [plugin, `-${plugin}`] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* PluginV2.Service
|
||||
expect((yield* plugins.list()).map((item) => String(item.id))).toContain("config-promise-plugin")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("loads configured Effect plugins with options", () =>
|
||||
withLocation(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ execution; and call a location-scoped subset of the V2 client.
|
|||
|
||||
Plugins can be loaded from npm packages, explicit local paths, or config
|
||||
directories. Each module must have one default export containing a unique
|
||||
plugin `id` and either a Promise `setup` function or an Effect `effect`
|
||||
function.
|
||||
plugin `id` and a `setup` function.
|
||||
|
||||
### Configuration
|
||||
|
||||
|
|
@ -75,26 +74,25 @@ relative config entry.
|
|||
|
||||
### Enable and disable
|
||||
|
||||
A string beginning with `-` removes a previously selected target. `*` matches
|
||||
everything, and a suffix of `.*` matches an ID or target prefix. Directives are
|
||||
A string beginning with `-` disables plugins by their exported `id`. `*`
|
||||
matches every ID, and a suffix of `.*` matches an ID prefix. Directives are
|
||||
applied in order:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"plugins": [
|
||||
"./plugins/reviewer.ts",
|
||||
"-acme.reviewer",
|
||||
"-opencode.provider.*",
|
||||
"opencode.provider.openai",
|
||||
"-./plugins/old.ts",
|
||||
"-*",
|
||||
"./plugins/only-this-one.ts"
|
||||
"opencode.provider.openai"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use the same package specifier or resolved local target to remove an external
|
||||
plugin. Built-in and embedded plugins can be selected by their plugin ID.
|
||||
Explicit config directives run after local auto-discovery, so they can disable
|
||||
discovered plugins.
|
||||
Package specifiers and local paths locate plugin modules; they are not disable
|
||||
selectors. Use the `id` from the plugin's default export to disable it. A later
|
||||
ID entry re-enables a loaded or built-in plugin. Explicit config directives run
|
||||
after local auto-discovery, so they can disable discovered plugins by ID.
|
||||
|
||||
User plugins are activated in configured order between OpenCode's internal
|
||||
plugin phases. Hooks run sequentially in registration order, and later hooks
|
||||
|
|
@ -114,12 +112,10 @@ visible from the plugin file, for example:
|
|||
|
||||
```sh
|
||||
cd .opencode
|
||||
bun add @opencode-ai/plugin@1.17.15 effect@4.0.0-beta.83
|
||||
bun add @opencode-ai/plugin
|
||||
```
|
||||
|
||||
`effect` is required for Effect plugins and for the `Schema` values used by
|
||||
typed tools. A Promise plugin that does not define tools may only need
|
||||
`@opencode-ai/plugin`. Match these versions to the OpenCode release you target.
|
||||
Match the plugin package version to the OpenCode release you target.
|
||||
|
||||
Configuration and discovered plugin files under watched config directories are
|
||||
reloaded when they change. Reloading replaces the active plugin generation and
|
||||
|
|
@ -128,8 +124,7 @@ package version or a local dependency when no watched file changed.
|
|||
|
||||
## Create a plugin
|
||||
|
||||
The Promise API is the simplest option. Export the result of `Plugin.define`
|
||||
as the module default:
|
||||
Export the result of `Plugin.define` as the module default:
|
||||
|
||||
```ts title=".opencode/plugins/reviewer.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
|
@ -156,38 +151,10 @@ export default Plugin.define({
|
|||
long-lived behavior during setup; do not wait there on an infinite event
|
||||
stream.
|
||||
|
||||
### Effect plugins
|
||||
|
||||
Use the Effect entrypoint when the implementation benefits from Effect
|
||||
composition, fibers, or scoped resources:
|
||||
|
||||
```ts title=".opencode/plugins/reviewer-effect.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.reviewer-effect",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.agent.transform((agents) => {
|
||||
agents.update("reviewer", (agent) => {
|
||||
agent.description = "Reviews code for regressions"
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
The plugin effect is scoped. Finalizers, scoped fibers, and registrations are
|
||||
released when the plugin reloads or unloads. OpenCode deliberately isolates the
|
||||
effect from its private Core services; use only the public `ctx` capabilities.
|
||||
|
||||
## Context
|
||||
|
||||
Promise methods return Promises; the equivalent Effect methods return
|
||||
`Effect`. Read and action methods use the same inputs and location-aware
|
||||
responses as the V2 client APIs.
|
||||
Context methods return Promises. Read and action methods use the same inputs
|
||||
and location-aware responses as the V2 client APIs.
|
||||
|
||||
| Capability | Available operations |
|
||||
| --- | --- |
|
||||
|
|
@ -271,12 +238,11 @@ handle expected errors inside the callback.
|
|||
|
||||
## Add a tool
|
||||
|
||||
Pass a plain object with Effect schemas to `tools.add`. Promise tools use async
|
||||
executors:
|
||||
Pass a tool declaration to `tools.add`. Define its input with JSON Schema and
|
||||
use an async executor:
|
||||
|
||||
```ts title=".opencode/plugins/greeting.ts"
|
||||
```js title=".opencode/plugins/greeting.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.greeting",
|
||||
|
|
@ -285,9 +251,21 @@ export default Plugin.define({
|
|||
tools.add({
|
||||
name: "greeting",
|
||||
description: "Create a greeting",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: async ({ name }) => `Hello, ${name}!`,
|
||||
jsonSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async ({ name }) => {
|
||||
const text = `Hello, ${name}!`
|
||||
return {
|
||||
structured: { greeting: text },
|
||||
content: [{ type: "text", text }],
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
|
|
@ -304,13 +282,11 @@ configure registration with `{ group, deferred }`:
|
|||
tool instead of exposing it directly.
|
||||
|
||||
The executor receives a second context argument containing `sessionID`,
|
||||
`agent`, `assistantMessageID`, and `toolCallID`. Effect plugins import their
|
||||
tool contracts from `@opencode-ai/plugin/v2/effect/tool` and return an `Effect`
|
||||
from `execute`.
|
||||
`agent`, `assistantMessageID`, and `toolCallID`.
|
||||
|
||||
## Types
|
||||
|
||||
`Plugin.define` infers the context and callbacks. The Promise root also
|
||||
`Plugin.define` infers the context and callbacks. The package also
|
||||
re-exports the canonical `Agent`, `Command`, `Connection`, `Credential`,
|
||||
`Integration`, `Model`, `Provider`, `Reference`, and `Skill` schema namespaces.
|
||||
Import narrower API types from their public subpaths when needed:
|
||||
|
|
@ -322,11 +298,8 @@ import type { AgentDraft } from "@opencode-ai/plugin/v2/agent"
|
|||
import type { ToolExecuteBeforeEvent } from "@opencode-ai/plugin/v2/tool"
|
||||
```
|
||||
|
||||
Effect equivalents live below `@opencode-ai/plugin/v2/effect`, such as
|
||||
`@opencode-ai/plugin/v2/effect/plugin` and
|
||||
`@opencode-ai/plugin/v2/effect/tool`. Avoid importing types or runtime values
|
||||
from `@opencode-ai/core` or `@opencode-ai/server`; those are private host
|
||||
implementation details.
|
||||
Avoid importing types or runtime values from `@opencode-ai/core` or
|
||||
`@opencode-ai/server`; those are private host implementation details.
|
||||
|
||||
## Publish a package
|
||||
|
||||
|
|
@ -340,8 +313,7 @@ manifest is:
|
|||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.17.15",
|
||||
"effect": "4.0.0-beta.83"
|
||||
"@opencode-ai/plugin": "1.17.18"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -363,3 +335,40 @@ If a plugin is absent, check the server log described in
|
|||
[Troubleshooting](/troubleshooting#read-logs). Invalid modules and setup failures are
|
||||
logged; one failing package does not prevent unrelated valid packages from
|
||||
being resolved.
|
||||
|
||||
## Effect
|
||||
|
||||
Plugins built with Effect use the `@opencode-ai/plugin/v2/effect` entrypoint.
|
||||
Install `effect` alongside the plugin package and export an `effect` function
|
||||
instead of `setup`:
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/plugin effect
|
||||
```
|
||||
|
||||
```ts title=".opencode/plugins/reviewer-effect.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.reviewer-effect",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.agent.transform((agents) => {
|
||||
agents.update("reviewer", (agent) => {
|
||||
agent.description = "Reviews code for regressions"
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Context operations return Effects. The plugin effect is scoped, so finalizers,
|
||||
fibers, and registrations are released when the plugin reloads or unloads.
|
||||
OpenCode does not expose its private Core services to the plugin; use the
|
||||
capabilities on `ctx`.
|
||||
|
||||
Typed tools can use `Schema` from `effect` and the contracts exported from
|
||||
`@opencode-ai/plugin/v2/effect/tool`. Their executors return an Effect and may
|
||||
fail with the typed tool failure channel.
|
||||
|
|
@ -4,7 +4,7 @@ description: "Embed an OpenCode host in an Effect application."
|
|||
---
|
||||
|
||||
`@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to
|
||||
host OpenCode in-process. Unlike the [network client](/client), it assembles the
|
||||
host OpenCode in-process. Unlike the [network client](/build/client), it assembles the
|
||||
OpenCode server and routes API calls through its HTTP router in memory. It opens
|
||||
no HTTP listener and adds no network hop between the client and server.
|
||||
|
||||
|
|
@ -72,4 +72,4 @@ const active = await Effect.runPromise(
|
|||
Call `opencode.plugin(...)` to register an embedded V2 plugin. Embedded plugins
|
||||
use the same discovery and location-scoped activation path as configured
|
||||
plugins. The SDK also exports `Tool` for plugin-defined tools. See the
|
||||
[Plugins guide](/plugins) for the plugin shape and available hooks.
|
||||
[Plugins guide](/build/plugins) for the plugin shape and available hooks.
|
||||
|
|
@ -421,7 +421,7 @@ accepts options.
|
|||
}
|
||||
```
|
||||
|
||||
See the [plugins guide](/plugins) for plugin development and configuration.
|
||||
See the [plugins guide](/build/plugins) for plugin development and configuration.
|
||||
|
||||
### Providers
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
"groups": [
|
||||
{
|
||||
"group": "Build with OpenCode",
|
||||
"pages": ["plugins", "client", "sdk/index"]
|
||||
"pages": ["build/plugins", "build/client", "build/sdk"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ plugin API is still being finalized during beta, and detailed plugin migration g
|
|||
ready.
|
||||
|
||||
Once the V2 plugin API is finalized, OpenCode should be able to migrate the majority of V1 plugins while keeping related
|
||||
local modules and dependencies together. See the current beta [Plugins guide](/plugins).
|
||||
local modules and dependencies together. See the current beta [Plugins guide](/build/plugins).
|
||||
|
||||
## Server API and clients
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue