mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-23 22:34:53 +00:00
v1 no enumeration
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user