From d921d7f989ef7048877f9dbfc970511a8e27f89c Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 26 Mar 2026 14:28:00 +0100 Subject: [PATCH] v1 no enumeration --- .../src/cli/cmd/tui/plugin/internal.ts | 19 ++--- .../src/cli/cmd/tui/plugin/runtime.ts | 76 ++++++------------- packages/opencode/src/plugin/index.ts | 34 +++++++-- packages/opencode/src/plugin/shared.ts | 22 +++--- .../test/cli/tui/plugin-lifecycle.test.ts | 8 +- .../test/cli/tui/plugin-loader.test.ts | 6 +- .../test/plugin/loader-shared.test.ts | 36 +++++++++ packages/plugin/src/index.ts | 5 ++ packages/plugin/src/tui.ts | 6 +- 9 files changed, 121 insertions(+), 91 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index e502f96813..f774feec66 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -1,15 +1,16 @@ -import * as HomeTips from "../feature-plugins/home/tips" -import * as SidebarContext from "../feature-plugins/sidebar/context" -import * as SidebarMcp from "../feature-plugins/sidebar/mcp" -import * as SidebarLsp from "../feature-plugins/sidebar/lsp" -import * as SidebarTodo from "../feature-plugins/sidebar/todo" -import * as SidebarFiles from "../feature-plugins/sidebar/files" -import * as SidebarFooter from "../feature-plugins/sidebar/footer" -import * as PluginManager from "../feature-plugins/system/plugins" +import HomeTips from "../feature-plugins/home/tips" +import SidebarContext from "../feature-plugins/sidebar/context" +import SidebarMcp from "../feature-plugins/sidebar/mcp" +import SidebarLsp from "../feature-plugins/sidebar/lsp" +import SidebarTodo from "../feature-plugins/sidebar/todo" +import SidebarFiles from "../feature-plugins/sidebar/files" +import SidebarFooter from "../feature-plugins/sidebar/footer" +import PluginManager from "../feature-plugins/system/plugins" +import type { TuiPluginModule } from "@opencode-ai/plugin/tui" export type InternalTuiPlugin = { name: string - module: Record + module: TuiPluginModule root?: string } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index f689edaa2f..cf8582e54b 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -3,6 +3,7 @@ import { type TuiDispose, type TuiPlugin, type TuiPluginApi, + type TuiPluginModule, type TuiPluginMeta, type TuiPluginStatus, type TuiTheme, @@ -16,7 +17,7 @@ import { Log } from "@/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" import { Instance } from "@/project/instance" -import { isDeprecatedPlugin, resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared" +import { getDefaultPlugin, isDeprecatedPlugin, resolvePluginTarget } from "@/plugin/shared" import { PluginMeta } from "@/plugin/meta" import { addTheme, hasTheme } from "../context/theme" import { Global } from "@/global" @@ -45,7 +46,6 @@ type PluginScope = { type PluginEntry = { id: string - export_name: string load: PluginLoad meta: TuiPluginMeta plugin: TuiPlugin @@ -102,14 +102,9 @@ function runCleanup(fn: () => unknown, ms: number): Promise { }) } -function getTuiPlugin(value: unknown) { - if (!isRecord(value) || !("tui" in value)) return - if (typeof value.tui !== "function") return - return value.tui as TuiPlugin -} - function isTheme(value: unknown) { if (!isRecord(value)) return false + if (!("theme" in value)) return false if (!isRecord(value.theme)) return false return true } @@ -249,7 +244,7 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad { spec, target, retry: false, - exports: item.module, + exports: { default: item.module }, install_theme: createThemeInstaller( { scope: "global", @@ -341,32 +336,24 @@ function createPluginScope(load: PluginLoad, name: string) { } } -function defaultPluginId(meta: TuiPluginMeta, export_name: string) { - if (meta.source === "internal") { - const base = `internal:${meta.name}` - if (export_name === "default") return base - return `${base}:${export_name}` - } - if (export_name === "default") return meta.name - return `${meta.name}:${export_name}` +function defaultPluginId(meta: TuiPluginMeta) { + if (meta.source === "internal") return `internal:${meta.name}` + return meta.name } -function readPluginIdFromExport(value: unknown, load: PluginLoad, export_name: string) { - if (!isRecord(value)) return - if (!("id" in value)) return - if (typeof value.id !== "string") { +function readPluginId(mod: TuiPluginModule, load: PluginLoad) { + if (mod.id === undefined) return + if (typeof mod.id !== "string") { log.warn("ignoring invalid tui plugin id", { path: load.spec, - name: export_name, - type: typeof value.id, + type: typeof mod.id, }) return } - const id = value.id.trim() + const id = mod.id.trim() if (id) return id log.warn("ignoring empty tui plugin id", { path: load.spec, - name: export_name, }) } @@ -411,7 +398,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per if (persist) writePluginEnabledState(state.api, plugin.id, true) if (plugin.scope) return true - const scope = createPluginScope(plugin.load, plugin.export_name) + const scope = createPluginScope(plugin.load, plugin.id) const api = pluginApi(state, plugin.load, scope, plugin.id) const ok = await Promise.resolve() .then(async () => { @@ -419,9 +406,9 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per return true }) .catch((error) => { - fail("failed to initialize tui plugin export", { + fail("failed to initialize tui plugin", { path: plugin.load.spec, - name: plugin.export_name, + id: plugin.id, error, }) return false @@ -534,34 +521,20 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, } function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) { - const plugins: PluginEntry[] = [] + // TUI stays default-only so plugin ids, lifecycle, and errors remain stable. + const mod = getDefaultPlugin(load.exports) as TuiPluginModule | undefined + if (!mod?.tui) return [] const options = load.item ? Config.pluginOptions(load.item) : undefined - - for (const [export_name, value] of uniqueModuleEntries(load.exports)) { - if (!value || typeof value !== "object") { - log.warn("ignoring non-object tui plugin export", { - path: load.spec, - name: export_name, - type: value === null ? "null" : typeof value, - }) - continue - } - - const handler = getTuiPlugin(value) - if (!handler) continue - const id = readPluginIdFromExport(value, load, export_name) ?? defaultPluginId(meta, export_name) - plugins.push({ - id, - export_name, + return [ + { + id: readPluginId(mod, load) ?? defaultPluginId(meta), load, meta, - plugin: handler, + plugin: mod.tui, options, enabled: true, - }) - } - - return plugins + }, + ] } function addPluginEntry(state: RuntimeState, plugin: PluginEntry) { @@ -569,7 +542,6 @@ function addPluginEntry(state: RuntimeState, plugin: PluginEntry) { fail("duplicate tui plugin id", { id: plugin.id, path: plugin.load.spec, - name: plugin.export_name, }) return } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 5788d44ded..0e868edbd5 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,4 +1,4 @@ -import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin" +import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin" import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "../util/log" @@ -14,7 +14,7 @@ import { Effect, Layer, ServiceMap, Stream } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { errorMessage } from "@/util/error" -import { isDeprecatedPlugin, parsePluginSpecifier, resolvePluginTarget, uniqueModuleEntries } from "./shared" +import { getDefaultPlugin, isDeprecatedPlugin, parsePluginSpecifier, resolvePluginTarget } from "./shared" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -64,6 +64,21 @@ export namespace Plugin { return value.server } + function getLegacyPlugins(mod: Record) { + const seen = new Set() + const result: PluginInstance[] = [] + + for (const entry of Object.values(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + const plugin = getServerPlugin(entry) + if (!plugin) throw new TypeError("Plugin export is not a function") + result.push(plugin) + } + + return result + } + async function resolvePlugin(spec: string) { const parsed = parsePluginSpecifier(spec) const target = await resolvePluginTarget(spec, parsed).catch((err) => { @@ -108,12 +123,15 @@ export namespace Plugin { } async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) { - // 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`). - // uniqueModuleEntries keeps only the first export for each shared value reference. - for (const [, entry] of uniqueModuleEntries(load.mod)) { - const server = getServerPlugin(entry) - if (!server) throw new TypeError("Plugin export is not a function") + const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined + // A v1 default export must win so server plugins do not mix two loading models. + if (plugin?.server) { + hooks.push(await plugin.server(input, Config.pluginOptions(load.item))) + return + } + + // v0 stays as the fallback for existing server plugins that enumerate exports. + for (const server of getLegacyPlugins(load.mod)) { hooks.push(await server(input, Config.pluginOptions(load.item))) } } diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 5dda94c739..e9d26bf10e 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -1,4 +1,5 @@ import { BunProc } from "@/bun" +import { isRecord } from "@/util/record" // Old npm package names for plugins that are now built-in export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"] @@ -19,15 +20,14 @@ export async function resolvePluginTarget(spec: string, parsed = parsePluginSpec return BunProc.install(parsed.pkg, parsed.version) } -export function uniqueModuleEntries(mod: Record) { - const seen = new Set() - const entries: [string, unknown][] = [] - - for (const [name, entry] of Object.entries(mod)) { - if (seen.has(entry)) continue - seen.add(entry) - entries.push([name, entry]) - } - - return entries +export function getDefaultPlugin(mod: Record) { + // A single default object keeps v1 detection explicit and avoids scanning exports. + const value = mod.default + if (!isRecord(value)) return + const server = "server" in value ? value.server : undefined + const tui = "tui" in value ? value.tui : undefined + if (server !== undefined && typeof server !== "function") return + if (tui !== undefined && typeof tui !== "function") return + if (server === undefined && tui === undefined) return + return value } diff --git a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts index 2220416c99..dfed49c2dd 100644 --- a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts @@ -81,20 +81,20 @@ test("disposes tracked event, route, and command hooks", async () => { expect(count.event_drop).toBe(0) expect(count.route_add).toBe(1) expect(count.route_drop).toBe(0) - expect(count.command_add).toBe(2) + expect(count.command_add).toBe(3) expect(count.command_drop).toBe(1) await TuiPluginRuntime.dispose() expect(count.event_drop).toBe(1) expect(count.route_drop).toBe(1) - expect(count.command_drop).toBe(2) + expect(count.command_drop).toBe(3) await TuiPluginRuntime.dispose() expect(count.event_drop).toBe(1) expect(count.route_drop).toBe(1) - expect(count.command_drop).toBe(2) + expect(count.command_drop).toBe(3) const marker = await fs.readFile(tmp.extra.marker, "utf8") expect(marker).toContain("custom") @@ -275,7 +275,7 @@ export default { expect(marker).toContain(`id:${name}:1`) const hit = err.mock.calls.find( - (item) => typeof item[0] === "string" && item[0].includes("failed to initialize tui plugin export"), + (item) => typeof item[0] === "string" && item[0].includes("failed to initialize tui plugin"), ) expect(hit).toBeUndefined() } finally { diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 1acb9e5c1d..afd04636aa 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -80,12 +80,12 @@ async function load(): Promise { await Bun.write( localPluginPath, - `export default async (_input, options) => { + `export const ignored = async (_input, options) => { if (!options?.fn_marker) return await Bun.write(options.fn_marker, "called") } -export const object_plugin = { +export default { tui: async (api, options) => { if (!options?.marker) return const cfg_theme = api.tuiConfig.theme @@ -420,7 +420,7 @@ describe("tui.plugin.loader", () => { data = await load() }) - test("passes keybind, kv, state, and dialog APIs to object plugins", () => { + test("passes keybind, kv, state, and dialog APIs to v1 plugins", () => { expect(data.local.key_modal).toBe("ctrl+alt+m") expect(data.local.key_close).toBe("q") expect(data.local.key_unknown).toBe("ctrl+k") diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index a03b54ba48..3ecc955410 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -91,6 +91,7 @@ describe("plugin.loader.shared", () => { init: async (dir) => { const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "count.txt") + await Bun.write(mark, "") await Bun.write( file, [ @@ -118,6 +119,41 @@ describe("plugin.loader.shared", () => { expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1") }) + test("uses only default v1 server plugin when present", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "count.txt") + await Bun.write( + file, + [ + "export default {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "default")`, + " return {}", + " },", + "}", + "export const named = async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "named")`, + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2), + ) + + return { mark } + }, + }) + + await load(tmp.path) + expect(await Bun.file(tmp.extra.mark).text()).toBe("default") + }) + test("resolves npm plugin specs with explicit and default versions", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index d6d7a758dc..d289689b93 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -40,6 +40,11 @@ export type Config = Omit & { export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise +export type PluginModule = { + id?: string + server?: Plugin +} + type Rule = { key: string op: "eq" | "neq" diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 4d558b065e..2257e1ff6a 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -15,7 +15,7 @@ import type { } from "@opencode-ai/sdk/v2" import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core" import type { JSX, SolidPlugin } from "@opentui/solid" -import type { Config as PluginConfig, Plugin, PluginOptions } from "./index.js" +import type { Config as PluginConfig, Plugin, PluginModule, PluginOptions } from "./index.js" export type { CliRenderer, SlotMode } from "@opentui/core" @@ -397,8 +397,6 @@ export type TuiPluginApi = { export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise -export type TuiPluginModule = { - id?: string - server?: Plugin +export type TuiPluginModule = PluginModule & { tui?: TuiPlugin }