Single target plugin entrypoints (#19467)

pull/19475/head^2
Sebastian 2026-03-28 00:44:46 +01:00 committed by GitHub
parent 02b19bc3d7
commit f3997d8082
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 292 additions and 62 deletions

View File

@ -1,7 +1,14 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core" import { RGBA, VignetteEffect } from "@opentui/core"
import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui" import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"] const tabs = ["overview", "counter", "help"]
const bind = { const bind = {
@ -813,7 +820,7 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
]) ])
} }
const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => { const tui: TuiPlugin = async (api, options, meta) => {
if (options?.enabled === false) return if (options?.enabled === false) return
await api.theme.install("./smoke-theme.json") await api.theme.install("./smoke-theme.json")
@ -846,7 +853,9 @@ const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, m
} }
} }
export default { const plugin: TuiPluginModule & { id: string } = {
id: "tui-smoke", id: "tui-smoke",
tui, tui,
} }
export default plugin

View File

@ -8,6 +8,8 @@ Technical reference for the current TUI plugin system.
- Author package entrypoint is `@opencode-ai/plugin/tui`. - Author package entrypoint is `@opencode-ai/plugin/tui`.
- Internal plugins load inside the CLI app the same way external TUI plugins do. - Internal plugins load inside the CLI app the same way external TUI plugins do.
- Package plugins can be installed from CLI or TUI. - Package plugins can be installed from CLI or TUI.
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
## TUI config ## TUI config
@ -27,6 +29,7 @@ Example:
- `plugin` entries can be either a string spec or `[spec, options]`. - `plugin` entries can be either a string spec or `[spec, options]`.
- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths. - Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
- Relative path specs are resolved relative to the config file that declared them. - Relative path specs are resolved relative to the config file that declared them.
- A file module listed in `tui.json` must be a TUI module (`default export { id?, tui }`) and must not export `server`.
- Duplicate npm plugins are deduped by package name; higher-precedence config wins. - Duplicate npm plugins are deduped by package name; higher-precedence config wins.
- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded. - Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
- `plugin_enabled` is keyed by plugin id, not by plugin spec. - `plugin_enabled` is keyed by plugin id, not by plugin spec.
@ -46,7 +49,7 @@ Minimal module shape:
```tsx ```tsx
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import type { TuiPlugin } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
const tui: TuiPlugin = async (api, options, meta) => { const tui: TuiPlugin = async (api, options, meta) => {
api.command.register(() => [ api.command.register(() => [
@ -69,16 +72,20 @@ const tui: TuiPlugin = async (api, options, meta) => {
]) ])
} }
export default { const plugin: TuiPluginModule & { id: string } = {
id: "acme.demo", id: "acme.demo",
tui, tui,
} }
export default plugin
``` ```
- Loader only reads the module default export object. Named exports are ignored. - Loader only reads the module default export object. Named exports are ignored.
- TUI shape is `default export { id?, tui }`. - TUI shape is `default export { id?, tui }`; including `server` is rejected.
- A single module cannot export both `server` and `tui`.
- `tui` signature is `(api, options, meta) => Promise<void>`. - `tui` signature is `(api, options, meta) => Promise<void>`.
- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target. - If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
- File/path plugins must export a non-empty `id`. - File/path plugins must export a non-empty `id`.
- npm plugins may omit `id`; package `name` is used. - npm plugins may omit `id`; package `name` is used.
- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids. - Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
@ -137,6 +144,7 @@ npm plugins can declare a version compatibility range in `package.json` using th
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept. - With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
- Tuple targets in `oc-plugin` provide default options written into config. - Tuple targets in `oc-plugin` provide default options written into config.
- A package can target `server`, `tui`, or both. - A package can target `server`, `tui`, or both.
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
- There is no uninstall, list, or update CLI command for external plugins. - There is no uninstall, list, or update CLI command for external plugins.
- Local file plugins are configured directly in `tui.json`. - Local file plugins are configured directly in `tui.json`.

View File

@ -1,4 +1,4 @@
import type { TuiPlugin } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view" import { Tips } from "./tips-view"
@ -42,7 +42,9 @@ const tui: TuiPlugin = async (api) => {
}) })
} }
export default { const plugin: TuiPluginModule & { id: string } = {
id, id,
tui, tui,
} }
export default plugin

View File

@ -1,5 +1,5 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
const id = "internal:sidebar-context" const id = "internal:sidebar-context"
@ -55,7 +55,9 @@ const tui: TuiPlugin = async (api) => {
}) })
} }
export default { const plugin: TuiPluginModule & { id: string } = {
id, id,
tui, tui,
} }
export default plugin

View File

@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js" import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-files" const id = "internal:sidebar-files"
@ -54,7 +54,9 @@ const tui: TuiPlugin = async (api) => {
}) })
} }
export default { const plugin: TuiPluginModule & { id: string } = {
id, id,
tui, tui,
} }
export default plugin

View File

@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { Global } from "@/global" import { Global } from "@/global"
@ -85,7 +85,9 @@ const tui: TuiPlugin = async (api) => {
}) })
} }
export default { const plugin: TuiPluginModule & { id: string } = {
id, id,
tui, tui,
} }
export default plugin

View File

@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js" import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-lsp" const id = "internal:sidebar-lsp"
@ -58,7 +58,9 @@ const tui: TuiPlugin = async (api) => {
}) })
} }
export default { const plugin: TuiPluginModule & { id: string } = {
id, id,
tui, tui,
} }
export default plugin

View File

@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js" import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
const id = "internal:sidebar-mcp" const id = "internal:sidebar-mcp"
@ -88,7 +88,9 @@ const tui: TuiPlugin = async (api) => {
}) })
} }
export default { const plugin: TuiPluginModule & { id: string } = {
id, id,
tui, tui,
} }
export default plugin

View File

@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js" import { createMemo, For, Show, createSignal } from "solid-js"
import { TodoItem } from "../../component/todo-item" import { TodoItem } from "../../component/todo-item"
@ -40,7 +40,9 @@ const tui: TuiPlugin = async (api) => {
}) })
} }
export default { const plugin: TuiPluginModule & { id: string } = {
id, id,
tui, tui,
} }
export default plugin

View File

@ -1,9 +1,9 @@
import { Keybind } from "@/util/keybind" import { Keybind } from "@/util/keybind"
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { createEffect, createMemo, createSignal } from "solid-js" import { Show, createEffect, createMemo, createSignal } from "solid-js"
const id = "internal:plugin-manager" const id = "internal:plugin-manager"
const key = Keybind.parse("space").at(0) const key = Keybind.parse("space").at(0)
@ -53,11 +53,17 @@ function Install(props: { api: TuiPluginApi }) {
<props.api.ui.DialogPrompt <props.api.ui.DialogPrompt
title="Install plugin" title="Install plugin"
placeholder="npm package name" placeholder="npm package name"
busy={busy()}
busyText="Installing plugin..."
description={() => ( description={() => (
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={props.api.theme.current.textMuted}>scope:</text> <text fg={props.api.theme.current.textMuted}>scope:</text>
<text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</text> <text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text> {global() ? "global" : "local"}
</text>
<Show when={!busy()}>
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
</Show>
</box> </box>
)} )}
onConfirm={(raw) => { onConfirm={(raw) => {
@ -256,7 +262,9 @@ const tui: TuiPlugin = async (api) => {
]) ])
} }
export default { const plugin: TuiPluginModule & { id: string } = {
id, id,
tui, tui,
} }
export default plugin

View File

@ -20,10 +20,10 @@ import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { import {
checkPluginCompatibility, checkPluginCompatibility,
getDefaultPlugin,
isDeprecatedPlugin, isDeprecatedPlugin,
pluginSource, pluginSource,
readPluginId, readPluginId,
readV1Plugin,
resolvePluginEntrypoint, resolvePluginEntrypoint,
resolvePluginId, resolvePluginId,
resolvePluginTarget, resolvePluginTarget,
@ -231,9 +231,7 @@ async function loadExternalPlugin(
const mod = await import(entry) const mod = await import(entry)
.then((raw) => { .then((raw) => {
const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
return mod
}) })
.catch((error) => { .catch((error) => {
fail("failed to load tui plugin", { path: spec, target: entry, retry, error }) fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
@ -566,16 +564,13 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
} }
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) { function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
// TUI stays default-only so plugin ids, lifecycle, and errors remain stable.
const plugin = load.module.tui
if (!plugin) return []
const options = load.item ? Config.pluginOptions(load.item) : undefined const options = load.item ? Config.pluginOptions(load.item) : undefined
return [ return [
{ {
id: load.id, id: load.id,
load, load,
meta, meta,
plugin, plugin: load.module.tui,
options, options,
enabled: true, enabled: true,
}, },

View File

@ -1,14 +1,17 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core" import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { onMount, type JSX } from "solid-js" import { Show, createEffect, onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import { Spinner } from "../component/spinner"
export type DialogPromptProps = { export type DialogPromptProps = {
title: string title: string
description?: () => JSX.Element description?: () => JSX.Element
placeholder?: string placeholder?: string
value?: string value?: string
busy?: boolean
busyText?: string
onConfirm?: (value: string) => void onConfirm?: (value: string) => void
onCancel?: () => void onCancel?: () => void
} }
@ -19,6 +22,12 @@ export function DialogPrompt(props: DialogPromptProps) {
let textarea: TextareaRenderable let textarea: TextareaRenderable
useKeyboard((evt) => { useKeyboard((evt) => {
if (props.busy) {
if (evt.name === "escape") return
evt.preventDefault()
evt.stopPropagation()
return
}
if (evt.name === "return") { if (evt.name === "return") {
props.onConfirm?.(textarea.plainText) props.onConfirm?.(textarea.plainText)
} }
@ -28,11 +37,21 @@ export function DialogPrompt(props: DialogPromptProps) {
dialog.setSize("medium") dialog.setSize("medium")
setTimeout(() => { setTimeout(() => {
if (!textarea || textarea.isDestroyed) return if (!textarea || textarea.isDestroyed) return
if (props.busy) return
textarea.focus() textarea.focus()
}, 1) }, 1)
textarea.gotoLineEnd() textarea.gotoLineEnd()
}) })
createEffect(() => {
if (!textarea || textarea.isDestroyed) return
if (props.busy) {
textarea.blur()
return
}
textarea.focus()
})
return ( return (
<box paddingLeft={2} paddingRight={2} gap={1}> <box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
@ -47,22 +66,28 @@ export function DialogPrompt(props: DialogPromptProps) {
{props.description} {props.description}
<textarea <textarea
onSubmit={() => { onSubmit={() => {
if (props.busy) return
props.onConfirm?.(textarea.plainText) props.onConfirm?.(textarea.plainText)
}} }}
height={3} height={3}
keyBindings={[{ name: "return", action: "submit" }]} keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)} ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value} initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"} placeholder={props.placeholder ?? "Enter text"}
textColor={theme.text} textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={theme.text} focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={theme.text} cursorColor={props.busy ? theme.backgroundElement : theme.text}
/> />
<Show when={props.busy}>
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
</Show>
</box> </box>
<box paddingBottom={1} gap={1} flexDirection="row"> <box paddingBottom={1} gap={1} flexDirection="row">
<text fg={theme.text}> <Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
enter <span style={{ fg: theme.textMuted }}>submit</span> <text fg={theme.text}>
</text> enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
</box> </box>
</box> </box>
) )

View File

@ -17,12 +17,15 @@ import { errorMessage } from "@/util/error"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { import {
checkPluginCompatibility, checkPluginCompatibility,
getDefaultPlugin,
isDeprecatedPlugin, isDeprecatedPlugin,
parsePluginSpecifier, parsePluginSpecifier,
pluginSource, pluginSource,
readPluginId,
readV1Plugin,
resolvePluginEntrypoint, resolvePluginEntrypoint,
resolvePluginId,
resolvePluginTarget, resolvePluginTarget,
type PluginSource,
} from "./shared" } from "./shared"
export namespace Plugin { export namespace Plugin {
@ -35,6 +38,8 @@ export namespace Plugin {
type Loaded = { type Loaded = {
item: Config.PluginSpec item: Config.PluginSpec
spec: string spec: string
target: string
source: PluginSource
mod: Record<string, unknown> mod: Record<string, unknown>
} }
@ -112,7 +117,8 @@ export namespace Plugin {
const resolved = await resolvePlugin(spec) const resolved = await resolvePlugin(spec)
if (!resolved) return if (!resolved) return
if (pluginSource(spec) === "npm") { const source = pluginSource(spec)
if (source === "npm") {
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION) const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
.then(() => false) .then(() => false)
.catch((err) => { .catch((err) => {
@ -156,14 +162,17 @@ export namespace Plugin {
return { return {
item, item,
spec, spec,
target,
source,
mod, mod,
} }
} }
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) { async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
if (plugin?.server) { if (plugin) {
hooks.push(await plugin.server(input, Config.pluginOptions(load.item))) await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec))
hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item)))
return return
} }

View File

@ -21,6 +21,7 @@ export function parsePluginSpecifier(spec: string) {
export type PluginSource = "file" | "npm" export type PluginSource = "file" | "npm"
export type PluginKind = "server" | "tui" export type PluginKind = "server" | "tui"
type PluginMode = "strict" | "detect"
export function pluginSource(spec: string): PluginSource { export function pluginSource(spec: string): PluginSource {
return spec.startsWith("file://") ? "file" : "npm" return spec.startsWith("file://") ? "file" : "npm"
@ -123,6 +124,40 @@ export function readPluginId(id: unknown, spec: string) {
return value return value
} }
export function readV1Plugin(
mod: Record<string, unknown>,
spec: string,
kind: PluginKind,
mode: PluginMode = "strict",
) {
const value = mod.default
if (!isRecord(value)) {
if (mode === "detect") return
throw new TypeError(`Plugin ${spec} must default export an object with ${kind}()`)
}
if (mode === "detect" && !("id" in value) && !("server" in value) && !("tui" in value)) return
const server = "server" in value ? value.server : undefined
const tui = "tui" in value ? value.tui : undefined
if (server !== undefined && typeof server !== "function") {
throw new TypeError(`Plugin ${spec} has invalid server export`)
}
if (tui !== undefined && typeof tui !== "function") {
throw new TypeError(`Plugin ${spec} has invalid tui export`)
}
if (server !== undefined && tui !== undefined) {
throw new TypeError(`Plugin ${spec} must default export either server() or tui(), not both`)
}
if (kind === "server" && server === undefined) {
throw new TypeError(`Plugin ${spec} must default export an object with server()`)
}
if (kind === "tui" && tui === undefined) {
throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
}
return value
}
export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) { export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
if (source === "file") { if (source === "file") {
if (id) return id if (id) return id
@ -135,15 +170,3 @@ export async function resolvePluginId(source: PluginSource, spec: string, target
} }
return pkg.json.name.trim() return pkg.json.name.trim()
} }
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

@ -130,3 +130,60 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
delete process.env.OPENCODE_PLUGIN_META_FILE delete process.env.OPENCODE_PLUGIN_META_FILE
} }
}) })
test("rejects npm tui plugin that exports server and tui together", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "mixed-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./tui": "./tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
await Bun.write(
path.join(mod, "tui.js"),
`export default {
id: "demo.mixed",
server: async () => ({}),
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.spec],
plugin_meta: {
[tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
},
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})

View File

@ -128,6 +128,7 @@ describe("plugin.loader.shared", () => {
file, file,
[ [
"export default {", "export default {",
' id: "demo.v1-default",',
" server: async () => {", " server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "default")`, ` await Bun.write(${JSON.stringify(mark)}, "default")`,
" return {}", " return {}",
@ -154,6 +155,82 @@ describe("plugin.loader.shared", () => {
expect(await Bun.file(tmp.extra.mark).text()).toBe("default") expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
}) })
test("rejects v1 file server plugin without id", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default {",
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
})
const errors = await errs(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
expect(errors.some((x) => x.includes("must export id"))).toBe(true)
})
test("rejects v1 plugin that exports server and tui together", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default {",
' id: "demo.mixed",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "server")`,
" return {}",
" },",
" tui: async () => {},",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
})
const errors = await errs(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
})
test("resolves npm plugin specs with explicit and default versions", async () => { test("resolves npm plugin specs with explicit and default versions", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {

View File

@ -42,7 +42,8 @@ export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Ho
export type PluginModule = { export type PluginModule = {
id?: string id?: string
server?: Plugin server: Plugin
tui?: never
} }
type Rule = { type Rule = {

View File

@ -15,7 +15,7 @@ import type {
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core" import type { CliRenderer, ParsedKey, RGBA } from "@opentui/core"
import type { JSX, SolidPlugin } from "@opentui/solid" import type { JSX, SolidPlugin } from "@opentui/solid"
import type { Config as PluginConfig, Plugin, PluginModule, PluginOptions } from "./index.js" import type { Config as PluginConfig, PluginOptions } from "./index.js"
export type { CliRenderer, SlotMode } from "@opentui/core" export type { CliRenderer, SlotMode } from "@opentui/core"
@ -107,6 +107,8 @@ export type TuiDialogPromptProps = {
description?: () => JSX.Element description?: () => JSX.Element
placeholder?: string placeholder?: string
value?: string value?: string
busy?: boolean
busyText?: string
onConfirm?: (value: string) => void onConfirm?: (value: string) => void
onCancel?: () => void onCancel?: () => void
} }
@ -414,6 +416,8 @@ export type TuiPluginApi = {
export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void> export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
export type TuiPluginModule = PluginModule & { export type TuiPluginModule = {
tui?: TuiPlugin id?: string
tui: TuiPlugin
server?: never
} }