diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 4f195917fd..4e28f49f3d 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -126,7 +126,7 @@ Done now: Still open and likely worth migrating: -- [ ] `Plugin` +- [x] `Plugin` - [ ] `ToolRegistry` - [ ] `Pty` - [ ] `Worktree` diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index c05458d5df..8119bdc66c 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -10,6 +10,7 @@ import { ProviderAuth } from "@/provider/auth" import { Question } from "@/question" import { Skill } from "@/skill/skill" import { Snapshot } from "@/snapshot" +import { Plugin } from "@/plugin" import { InstanceContext } from "./instance-context" import { registerDisposer } from "./instance-registry" @@ -26,6 +27,7 @@ export type InstanceServices = | File.Service | Skill.Service | Snapshot.Service + | Plugin.Service // NOTE: LayerMap only passes the key (directory string) to lookup, but we need // the full instance context (directory, worktree, project). We read from the @@ -46,6 +48,7 @@ function lookup(_key: string) { Layer.fresh(File.layer), Layer.fresh(Skill.defaultLayer), Layer.fresh(Snapshot.defaultLayer), + Layer.fresh(Plugin.layer), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 755ce2c211..7d429433df 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -5,13 +5,15 @@ import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { Server } from "../server/server" import { BunProc } from "../bun" -import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth" +import { Effect, Layer, ServiceMap } from "effect" +import { InstanceContext } from "@/effect/instance-context" +import { runPromiseInstance } from "@/effect/runtime" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -19,126 +21,163 @@ export namespace Plugin { // Built-in plugins that are directly imported (not installed from npm) const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin] - const state = Instance.state(async () => { - const client = createOpencodeClient({ - baseUrl: "http://localhost:4096", - directory: Instance.directory, - headers: Flag.OPENCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, - fetch: async (...args) => Server.Default().fetch(...args), - }) - const config = await Config.get() - const hooks: Hooks[] = [] - const input: PluginInput = { - client, - project: Instance.project, - worktree: Instance.worktree, - directory: Instance.directory, - get serverUrl(): URL { - return Server.url ?? new URL("http://localhost:4096") - }, - $: Bun.$, - } + export interface Interface { + readonly trigger: < + Name extends Exclude, "auth" | "event" | "tool">, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >( + name: Name, + input: Input, + output: Output, + ) => Effect.Effect + readonly list: () => Effect.Effect + readonly init: () => Effect.Effect + } - for (const plugin of INTERNAL_PLUGINS) { - log.info("loading internal plugin", { name: plugin.name }) - const init = await plugin(input).catch((err) => { - log.error("failed to load internal plugin", { name: plugin.name, error: err }) + export class Service extends ServiceMap.Service()("@opencode/Plugin") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext + const hooks: Hooks[] = [] + + yield* Effect.promise(async () => { + const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + directory: instance.directory, + headers: Flag.OPENCODE_SERVER_PASSWORD + ? { + Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, + } + : undefined, + fetch: async (...args) => Server.Default().fetch(...args), + }) + const config = await Config.get() + const input: PluginInput = { + client, + project: instance.project, + worktree: instance.worktree, + directory: instance.directory, + get serverUrl(): URL { + return Server.url ?? new URL("http://localhost:4096") + }, + $: Bun.$, + } + + for (const plugin of INTERNAL_PLUGINS) { + log.info("loading internal plugin", { name: plugin.name }) + const init = await plugin(input).catch((err) => { + log.error("failed to load internal plugin", { name: plugin.name, error: err }) + }) + if (init) hooks.push(init) + } + + let plugins = config.plugin ?? [] + if (plugins.length) await Config.waitForDependencies() + + for (let plugin of plugins) { + // ignore old codex plugin since it is supported first party now + if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue + log.info("loading plugin", { path: plugin }) + if (!plugin.startsWith("file://")) { + const lastAtIndex = plugin.lastIndexOf("@") + const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin + const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" + plugin = await BunProc.install(pkg, version).catch((err) => { + const cause = err instanceof Error ? err.cause : err + const detail = cause instanceof Error ? cause.message : String(cause ?? err) + log.error("failed to install plugin", { pkg, version, error: detail }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to install plugin ${pkg}@${version}: ${detail}`, + }).toObject(), + }) + return "" + }) + if (!plugin) continue + } + // Prevent duplicate initialization when plugins export the same function + // as both a named export and default export (e.g., `export const X` and `export default X`). + // Object.entries(mod) would return both entries pointing to the same function reference. + await import(plugin) + .then(async (mod) => { + const seen = new Set() + for (const [_name, fn] of Object.entries(mod)) { + if (seen.has(fn)) continue + seen.add(fn) + hooks.push(await fn(input)) + } + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err) + log.error("failed to load plugin", { path: plugin, error: message }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to load plugin ${plugin}: ${message}`, + }).toObject(), + }) + }) + } }) - if (init) hooks.push(init) - } - let plugins = config.plugin ?? [] - if (plugins.length) await Config.waitForDependencies() - - for (let plugin of plugins) { - // ignore old codex plugin since it is supported first party now - if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue - log.info("loading plugin", { path: plugin }) - if (!plugin.startsWith("file://")) { - const lastAtIndex = plugin.lastIndexOf("@") - const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin - const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" - plugin = await BunProc.install(pkg, version).catch((err) => { - const cause = err instanceof Error ? err.cause : err - const detail = cause instanceof Error ? cause.message : String(cause ?? err) - log.error("failed to install plugin", { pkg, version, error: detail }) - Bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to install plugin ${pkg}@${version}: ${detail}`, - }).toObject(), - }) - return "" - }) - if (!plugin) continue - } - // Prevent duplicate initialization when plugins export the same function - // as both a named export and default export (e.g., `export const X` and `export default X`). - // Object.entries(mod) would return both entries pointing to the same function reference. - await import(plugin) - .then(async (mod) => { - const seen = new Set() - for (const [_name, fn] of Object.entries(mod)) { - if (seen.has(fn)) continue - seen.add(fn) - hooks.push(await fn(input)) + const trigger = Effect.fn("Plugin.trigger")(function* < + Name extends Exclude, "auth" | "event" | "tool">, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >(name: Name, input: Input, output: Output) { + if (!name) return output + yield* Effect.promise(async () => { + for (const hook of hooks) { + const fn = hook[name] + if (!fn) continue + // @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you + // give up. + // try-counter: 2 + await fn(input, output) } }) - .catch((err) => { - const message = err instanceof Error ? err.message : String(err) - log.error("failed to load plugin", { path: plugin, error: message }) - Bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to load plugin ${plugin}: ${message}`, - }).toObject(), + return output + }) + + const list = Effect.fn("Plugin.list")(function* () { + return hooks + }) + + const init = Effect.fn("Plugin.init")(function* () { + yield* Effect.promise(async () => { + const config = await Config.get() + for (const hook of hooks) { + await (hook as any).config?.(config) + } + Bus.subscribeAll(async (input) => { + for (const hook of hooks) { + hook["event"]?.({ + event: input, + }) + } }) }) - } + }) - return { - hooks, - input, - } - }) + return Service.of({ trigger, list, init }) + }), + ) export async function trigger< Name extends Exclude, "auth" | "event" | "tool">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { - if (!name) return output - for (const hook of await state().then((x) => x.hooks)) { - const fn = hook[name] - if (!fn) continue - // @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you - // give up. - // try-counter: 2 - await fn(input, output) - } - return output + return runPromiseInstance(Service.use((svc) => svc.trigger(name, input, output))) } export async function list() { - return state().then((x) => x.hooks) + return runPromiseInstance(Service.use((svc) => svc.list())) } export async function init() { - const hooks = await state().then((x) => x.hooks) - const config = await Config.get() - for (const hook of hooks) { - // @ts-expect-error this is because we haven't moved plugin to sdk v2 - await hook.config?.(config) - } - Bus.subscribeAll(async (input) => { - const hooks = await state().then((x) => x.hooks) - for (const hook of hooks) { - hook["event"]?.({ - event: input, - }) - } - }) + return runPromiseInstance(Service.use((svc) => svc.init())) } }