From 46148bbc6c3302c8da14ddc3c3fc9d7a09de1300 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 14:40:53 -0400 Subject: [PATCH] Revert "feat(tui): load external plugin exports" This reverts commit fdd6498909c86cdd71d6cb30029ddc1d2928b06b. --- packages/cli/src/commands/handlers/default.ts | 8 +-- packages/cli/src/plugin-host.ts | 59 ------------------- packages/cli/test/plugin-host.test.ts | 56 ------------------ packages/tui/package.json | 1 - packages/tui/src/app.tsx | 6 +- packages/tui/src/plugin/context.tsx | 50 ++++++++++++++-- packages/tui/test/app-lifecycle.test.tsx | 4 +- 7 files changed, 55 insertions(+), 129 deletions(-) delete mode 100644 packages/cli/src/plugin-host.ts delete mode 100644 packages/cli/test/plugin-host.test.ts diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 4387ec8df3..1f1a441571 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -9,7 +9,6 @@ import { ServerConnection } from "../../services/server-connection" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" import { Npm } from "@opencode-ai/core/npm" -import { createPluginHost } from "../../plugin-host" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { @@ -60,9 +59,10 @@ export default Runtime.handler(Commands, (input) => get: () => runPromise(config.get()), update: (update) => runPromise(config.update(update)), }, - pluginHost: createPluginHost((spec) => - runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))), - ), + packages: { + resolve: (spec) => + runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))), + }, terminalHandoff: () => preflight.finish(), log: (level, message, tags) => { const effect = diff --git a/packages/cli/src/plugin-host.ts b/packages/cli/src/plugin-host.ts deleted file mode 100644 index 9de52d7310..0000000000 --- a/packages/cli/src/plugin-host.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Plugin } from "@opencode-ai/plugin/v2/tui" -import type { PluginHost } from "@opencode-ai/tui/plugin/context" -import path from "path" -import { stat } from "fs/promises" -import { fileURLToPath, pathToFileURL } from "url" - -export function createPluginHost(resolvePackage: (spec: string) => Promise): PluginHost { - return { - async load(spec, directory) { - const local = spec.startsWith("file://") - ? new URL(spec) - : spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec) - ? pathToFileURL(path.resolve(directory, spec)) - : undefined - const entrypoint = local ? await resolveLocal(local) : await resolvePackage(spec) - if (!entrypoint) return - const mod: { readonly default?: unknown } = await import(entrypoint) - if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`) - return mod.default - }, - } -} - -async function resolveLocal(url: URL) { - const info = await stat(url) - if (info.isFile()) return url.href - if (!info.isDirectory()) return - const manifest = Bun.file(path.join(fileURLToPath(url), "package.json")) - if (await manifest.exists()) { - const value: unknown = await manifest.json() - if (typeof value === "object" && value !== null && "exports" in value) { - const exports = value.exports - const target = - typeof exports === "object" && exports !== null && "./tui" in exports ? exports["./tui"] : undefined - if (typeof target === "string") return pathToFileURL(path.resolve(fileURLToPath(url), target)).href - } - } - return resolve(pathToFileURL(path.join(fileURLToPath(url), "tui")).href) -} - -function resolve(specifier: string) { - try { - return import.meta.resolve(specifier) - } catch { - return undefined - } -} - -function isPlugin(value: unknown): value is Plugin.Definition { - return ( - typeof value === "object" && - value !== null && - "id" in value && - typeof value.id === "string" && - value.id.length > 0 && - "setup" in value && - typeof value.setup === "function" - ) -} diff --git a/packages/cli/test/plugin-host.test.ts b/packages/cli/test/plugin-host.test.ts deleted file mode 100644 index 57b155bb17..0000000000 --- a/packages/cli/test/plugin-host.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import path from "path" -import { createPluginHost } from "../src/plugin-host" - -const directories: string[] = [] - -afterEach(async () => { - await Promise.all(directories.splice(0).map((directory) => Bun.$`rm -rf ${directory}`.quiet())) -}) - -describe("plugin host", () => { - test("loads the tui export from a local package", async () => { - const directory = await temp() - await Bun.write( - path.join(directory, "package.json"), - JSON.stringify({ type: "module", exports: { "./tui": "./src/tui.js" } }), - ) - await Bun.write( - path.join(directory, "src/tui.js"), - "export default { id: 'example.tui', setup() { return () => {} } }", - ) - - const plugin = await createPluginHost(async () => undefined).load(directory, directory) - - expect(plugin?.id).toBe("example.tui") - }) - - test("loads a package resolver tui entrypoint", async () => { - const directory = await temp() - const entrypoint = path.join(directory, "tui.js") - await Bun.write(entrypoint, "export default { id: 'npm.tui', setup() {} }") - - const plugin = await createPluginHost(async (spec) => { - expect(spec).toBe("example-plugin") - return entrypoint - }).load("example-plugin", directory) - - expect(plugin?.id).toBe("npm.tui") - }) - - test("reports invalid tui exports without terminating the host", async () => { - const directory = await temp() - const entrypoint = path.join(directory, "tui.js") - await Bun.write(entrypoint, "export default { id: 'invalid' }") - const host = createPluginHost(async () => entrypoint) - - expect(host.load("invalid-plugin", directory)).rejects.toThrow("Invalid V2 TUI plugin module: invalid-plugin") - expect(await host.load("unsupported-plugin", directory).catch(() => undefined)).toBeUndefined() - }) -}) - -async function temp() { - const directory = await Bun.$`mktemp -d`.text() - directories.push(directory.trim()) - return directory.trim() -} diff --git a/packages/tui/package.json b/packages/tui/package.json index bf5329fe2e..931c716d95 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -34,7 +34,6 @@ "./prompt/content": "./src/prompt/content.ts", "./prompt/display": "./src/prompt/display.ts", "./plugin/runtime": "./src/plugin/runtime.tsx", - "./plugin/context": "./src/plugin/context.tsx", "./plugin/slots": "./src/plugin/slots.tsx", "./plugin/command-shim": "./src/plugin/command-shim.ts", "./parsers-config": "./src/parsers-config.ts", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c9d08bacfa..a0b2e3fc0b 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -78,7 +78,7 @@ import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { Config, ConfigProvider, useConfig } from "./config" import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime" -import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PluginHost } from "./plugin/context" +import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context" import { CommandPaletteDialog } from "./component/command-palette" import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap" @@ -149,7 +149,7 @@ export type TuiInput = { } args: Args config: Config.Interface - pluginHost: PluginHost + packages: PackageResolver terminalHandoff?: () => Promise< | { readonly renderer: CliRenderer @@ -346,7 +346,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + Promise +export interface PackageResolver { + readonly resolve: (spec: string) => Promise } type State = @@ -54,7 +56,7 @@ type Registration = { const PluginContext = createContext() -export function PluginProvider(props: ParentProps<{ pluginHost: PluginHost }>) { +export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) { const client = useClient() const data = useData() const route = useRoute() @@ -229,7 +231,7 @@ export function PluginProvider(props: ParentProps<{ pluginHost: PluginHost }>) { const options = typeof entry === "string" ? undefined : entry.options setStore("states", (items) => [...items, { target, status: "loading" }]) - const plugin = await props.pluginHost.load(target, directory).catch((error) => { + const plugin = await loadPlugin(target, directory, props.packages).catch((error) => { setStore("states", (items) => items.map((state) => state.target === target @@ -331,6 +333,46 @@ function matches(selector: string, id: string) { return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1))) } +async function loadPlugin(spec: string, directory: string, packages: PackageResolver) { + const local = spec.startsWith("file://") + ? new URL(spec) + : spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec) + ? pathToFileURL(path.resolve(directory, spec)) + : undefined + const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec) + if (!entrypoint) return + const mod: { readonly default?: unknown } = await import(entrypoint) + if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`) + return mod.default +} + +async function resolveLocal(url: URL) { + const info = await stat(url) + if (info.isFile()) return url.href + if (!info.isDirectory()) return + return resolve(pathToFileURL(path.join(fileURLToPath(url), "tui")).href) +} + +function resolve(specifier: string) { + try { + return import.meta.resolve(specifier) + } catch { + return undefined + } +} + +function isPlugin(value: unknown): value is Plugin.Definition { + return ( + typeof value === "object" && + value !== null && + "id" in value && + typeof value.id === "string" && + value.id.length > 0 && + "setup" in value && + typeof value.setup === "function" + ) +} + export function usePlugin() { const value = useContext(PluginContext) if (!value) throw new Error("PluginProvider is missing") diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 5809fb6bf1..a6cd421ea1 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -30,7 +30,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { run({ server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, - pluginHost: { load: async () => undefined }, + packages: { resolve: async () => undefined }, args: {}, log: () => {}, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), @@ -102,7 +102,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after run({ server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, - pluginHost: { load: async () => undefined }, + packages: { resolve: async () => undefined }, args: { sessionID: "dummy" }, log: () => {}, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),