v1 no enumeration

This commit is contained in:
Sebastian Herrlinger
2026-03-26 14:28:00 +01:00
parent 64aaadc97d
commit d921d7f989
9 changed files with 121 additions and 91 deletions

View File

@@ -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<string, unknown>
module: TuiPluginModule
root?: string
}

View File

@@ -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<CleanupResult> {
})
}
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
}

View File

@@ -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<string, unknown>) {
const seen = new Set<unknown>()
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)))
}
}

View File

@@ -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<string, unknown>) {
const seen = new Set<unknown>()
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<string, unknown>) {
// 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
}

View File

@@ -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 {

View File

@@ -80,12 +80,12 @@ async function load(): Promise<Data> {
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")

View File

@@ -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) => {

View File

@@ -40,6 +40,11 @@ export type Config = Omit<SDKConfig, "plugin"> & {
export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>
export type PluginModule = {
id?: string
server?: Plugin
}
type Rule = {
key: string
op: "eq" | "neq"

View File

@@ -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<void>
export type TuiPluginModule = {
id?: string
server?: Plugin
export type TuiPluginModule = PluginModule & {
tui?: TuiPlugin
}