From fdd6498909c86cdd71d6cb30029ddc1d2928b06b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 14:19:56 -0400 Subject: [PATCH] feat(tui): load external plugin exports --- 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, 129 insertions(+), 55 deletions(-) create mode 100644 packages/cli/src/plugin-host.ts create 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 1f1a441571..4387ec8df3 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -9,6 +9,7 @@ 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* () { @@ -59,10 +60,9 @@ export default Runtime.handler(Commands, (input) => get: () => runPromise(config.get()), update: (update) => runPromise(config.update(update)), }, - packages: { - resolve: (spec) => - runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))), - }, + pluginHost: createPluginHost((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 new file mode 100644 index 0000000000..9de52d7310 --- /dev/null +++ b/packages/cli/src/plugin-host.ts @@ -0,0 +1,59 @@ +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 new file mode 100644 index 0000000000..57b155bb17 --- /dev/null +++ b/packages/cli/test/plugin-host.test.ts @@ -0,0 +1,56 @@ +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 931c716d95..bf5329fe2e 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -34,6 +34,7 @@ "./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 a0b2e3fc0b..c9d08bacfa 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 PackageResolver } from "./plugin/context" +import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PluginHost } 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 - packages: PackageResolver + pluginHost: PluginHost terminalHandoff?: () => Promise< | { readonly renderer: CliRenderer @@ -346,7 +346,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + Promise +export interface PluginHost { + readonly load: (spec: string, directory: string) => Promise } type State = @@ -56,7 +54,7 @@ type Registration = { const PluginContext = createContext() -export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) { +export function PluginProvider(props: ParentProps<{ pluginHost: PluginHost }>) { const client = useClient() const data = useData() const route = useRoute() @@ -231,7 +229,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const options = typeof entry === "string" ? undefined : entry.options setStore("states", (items) => [...items, { target, status: "loading" }]) - const plugin = await loadPlugin(target, directory, props.packages).catch((error) => { + const plugin = await props.pluginHost.load(target, directory).catch((error) => { setStore("states", (items) => items.map((state) => state.target === target @@ -333,46 +331,6 @@ 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 a6cd421ea1..5809fb6bf1 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 () => ({}) }, - packages: { resolve: async () => undefined }, + pluginHost: { load: 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 () => ({}) }, - packages: { resolve: async () => undefined }, + pluginHost: { load: async () => undefined }, args: { sessionID: "dummy" }, log: () => {}, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),