summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSebastian <[email protected]>2026-03-28 00:44:46 +0100
committerGitHub <[email protected]>2026-03-27 23:44:46 +0000
commitf3997d8082413c8b3a506d24fbfb3c58a0c3dedb (patch)
tree615a6331ab6896abd62a05a9a2894e34203e098a
parent02b19bc3d733ee2e4220971fa421d4a6f05a9468 (diff)
downloadopencode-f3997d8082413c8b3a506d24fbfb3c58a0c3dedb.tar.gz
opencode-f3997d8082413c8b3a506d24fbfb3c58a0c3dedb.zip
Single target plugin entrypoints (#19467)
-rw-r--r--.opencode/plugins/tui-smoke.tsx15
-rw-r--r--packages/opencode/specs/tui-plugins.md14
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx6
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx6
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx6
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx6
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx6
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx6
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx6
-rw-r--r--packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx18
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/runtime.ts11
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx41
-rw-r--r--packages/opencode/src/plugin/index.ts19
-rw-r--r--packages/opencode/src/plugin/shared.ts47
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts57
-rw-r--r--packages/opencode/test/plugin/loader-shared.test.ts77
-rw-r--r--packages/plugin/src/index.ts3
-rw-r--r--packages/plugin/src/tui.ts10
18 files changed, 292 insertions, 62 deletions
diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx
index 3e90bafb6..deb3c3e3e 100644
--- a/.opencode/plugins/tui-smoke.tsx
+++ b/.opencode/plugins/tui-smoke.tsx
@@ -1,7 +1,14 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
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 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
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",
tui,
}
+
+export default plugin
diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md
index 1a7ba55a0..02b2a9741 100644
--- a/packages/opencode/specs/tui-plugins.md
+++ b/packages/opencode/specs/tui-plugins.md
@@ -8,6 +8,8 @@ Technical reference for the current TUI plugin system.
- Author package entrypoint is `@opencode-ai/plugin/tui`.
- Internal plugins load inside the CLI app the same way external TUI plugins do.
- 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
@@ -27,6 +29,7 @@ Example:
- `plugin` entries can be either a string spec or `[spec, options]`.
- 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.
+- 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 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.
@@ -46,7 +49,7 @@ Minimal module shape:
```tsx
/** @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) => {
api.command.register(() => [
@@ -69,16 +72,20 @@ const tui: TuiPlugin = async (api, options, meta) => {
])
}
-export default {
+const plugin: TuiPluginModule & { id: string } = {
id: "acme.demo",
tui,
}
+
+export default plugin
```
- 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>`.
- 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`.
- 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.
@@ -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.
- Tuple targets in `oc-plugin` provide default options written into config.
- 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.
- Local file plugins are configured directly in `tui.json`.
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx
index 1a1d3c174..c0e02f74a 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx
@@ -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 { Tips } from "./tips-view"
@@ -42,7 +42,9 @@ const tui: TuiPlugin = async (api) => {
})
}
-export default {
+const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
+
+export default plugin
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
index c8538ae2a..9ffe77979 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx
@@ -1,5 +1,5 @@
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"
const id = "internal:sidebar-context"
@@ -55,7 +55,9 @@ const tui: TuiPlugin = async (api) => {
})
}
-export default {
+const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
+
+export default plugin
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx
index 16bed7287..c865c5eb4 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx
@@ -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"
const id = "internal:sidebar-files"
@@ -54,7 +54,9 @@ const tui: TuiPlugin = async (api) => {
})
}
-export default {
+const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
+
+export default plugin
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx
index a6bff01a5..b468d851b 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx
@@ -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 { Global } from "@/global"
@@ -85,7 +85,9 @@ const tui: TuiPlugin = async (api) => {
})
}
-export default {
+const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
+
+export default plugin
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx
index db9b3a7e5..cb4050fdb 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx
@@ -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"
const id = "internal:sidebar-lsp"
@@ -58,7 +58,9 @@ const tui: TuiPlugin = async (api) => {
})
}
-export default {
+const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
+
+export default plugin
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx
index 178050abd..391bf27b9 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx
@@ -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"
const id = "internal:sidebar-mcp"
@@ -88,7 +88,9 @@ const tui: TuiPlugin = async (api) => {
})
}
-export default {
+const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
+
+export default plugin
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx
index c9e904deb..eed0cb703 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx
@@ -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 { TodoItem } from "../../component/todo-item"
@@ -40,7 +40,9 @@ const tui: TuiPlugin = async (api) => {
})
}
-export default {
+const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
+
+export default plugin
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx
index 8293be506..f2fd25ffb 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx
@@ -1,9 +1,9 @@
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 { fileURLToPath } from "url"
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 key = Keybind.parse("space").at(0)
@@ -53,11 +53,17 @@ function Install(props: { api: TuiPluginApi }) {
<props.api.ui.DialogPrompt
title="Install plugin"
placeholder="npm package name"
+ busy={busy()}
+ busyText="Installing plugin..."
description={() => (
<box flexDirection="row" gap={1}>
<text fg={props.api.theme.current.textMuted}>scope:</text>
- <text fg={props.api.theme.current.text}>{global() ? "global" : "local"}</text>
- <text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
+ <text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
+ {global() ? "global" : "local"}
+ </text>
+ <Show when={!busy()}>
+ <text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
+ </Show>
</box>
)}
onConfirm={(raw) => {
@@ -256,7 +262,9 @@ const tui: TuiPlugin = async (api) => {
])
}
-export default {
+const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
+
+export default plugin
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
index 9cc5194df..0e1674bda 100644
--- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
+++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
@@ -20,10 +20,10 @@ import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import {
checkPluginCompatibility,
- getDefaultPlugin,
isDeprecatedPlugin,
pluginSource,
readPluginId,
+ readV1Plugin,
resolvePluginEntrypoint,
resolvePluginId,
resolvePluginTarget,
@@ -231,9 +231,7 @@ async function loadExternalPlugin(
const mod = await import(entry)
.then((raw) => {
- const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
- if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
- return mod
+ return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
})
.catch((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) {
- // 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
return [
{
id: load.id,
load,
meta,
- plugin,
+ plugin: load.module.tui,
options,
enabled: true,
},
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx
index b1b05a0f1..cb1b8257a 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx
@@ -1,14 +1,17 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
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 { Spinner } from "../component/spinner"
export type DialogPromptProps = {
title: string
description?: () => JSX.Element
placeholder?: string
value?: string
+ busy?: boolean
+ busyText?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
@@ -19,6 +22,12 @@ export function DialogPrompt(props: DialogPromptProps) {
let textarea: TextareaRenderable
useKeyboard((evt) => {
+ if (props.busy) {
+ if (evt.name === "escape") return
+ evt.preventDefault()
+ evt.stopPropagation()
+ return
+ }
if (evt.name === "return") {
props.onConfirm?.(textarea.plainText)
}
@@ -28,11 +37,21 @@ export function DialogPrompt(props: DialogPromptProps) {
dialog.setSize("medium")
setTimeout(() => {
if (!textarea || textarea.isDestroyed) return
+ if (props.busy) return
textarea.focus()
}, 1)
textarea.gotoLineEnd()
})
+ createEffect(() => {
+ if (!textarea || textarea.isDestroyed) return
+ if (props.busy) {
+ textarea.blur()
+ return
+ }
+ textarea.focus()
+ })
+
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -47,22 +66,28 @@ export function DialogPrompt(props: DialogPromptProps) {
{props.description}
<textarea
onSubmit={() => {
+ if (props.busy) return
props.onConfirm?.(textarea.plainText)
}}
height={3}
- keyBindings={[{ name: "return", action: "submit" }]}
+ keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"}
- textColor={theme.text}
- focusedTextColor={theme.text}
- cursorColor={theme.text}
+ textColor={props.busy ? theme.textMuted : theme.text}
+ focusedTextColor={props.busy ? theme.textMuted : theme.text}
+ cursorColor={props.busy ? theme.backgroundElement : theme.text}
/>
+ <Show when={props.busy}>
+ <Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
+ </Show>
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
- <text fg={theme.text}>
- enter <span style={{ fg: theme.textMuted }}>submit</span>
- </text>
+ <Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
+ <text fg={theme.text}>
+ enter <span style={{ fg: theme.textMuted }}>submit</span>
+ </text>
+ </Show>
</box>
</box>
)
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index fe4be0372..9698487f9 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -17,12 +17,15 @@ import { errorMessage } from "@/util/error"
import { Installation } from "@/installation"
import {
checkPluginCompatibility,
- getDefaultPlugin,
isDeprecatedPlugin,
parsePluginSpecifier,
pluginSource,
+ readPluginId,
+ readV1Plugin,
resolvePluginEntrypoint,
+ resolvePluginId,
resolvePluginTarget,
+ type PluginSource,
} from "./shared"
export namespace Plugin {
@@ -35,6 +38,8 @@ export namespace Plugin {
type Loaded = {
item: Config.PluginSpec
spec: string
+ target: string
+ source: PluginSource
mod: Record<string, unknown>
}
@@ -112,7 +117,8 @@ export namespace Plugin {
const resolved = await resolvePlugin(spec)
if (!resolved) return
- if (pluginSource(spec) === "npm") {
+ const source = pluginSource(spec)
+ if (source === "npm") {
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
.then(() => false)
.catch((err) => {
@@ -156,14 +162,17 @@ export namespace Plugin {
return {
item,
spec,
+ target,
+ source,
mod,
}
}
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
- const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
- if (plugin?.server) {
- hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
+ const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
+ if (plugin) {
+ 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
}
diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts
index ee2ee6dd7..b6b25f89c 100644
--- a/packages/opencode/src/plugin/shared.ts
+++ b/packages/opencode/src/plugin/shared.ts
@@ -21,6 +21,7 @@ export function parsePluginSpecifier(spec: string) {
export type PluginSource = "file" | "npm"
export type PluginKind = "server" | "tui"
+type PluginMode = "strict" | "detect"
export function pluginSource(spec: string): PluginSource {
return spec.startsWith("file://") ? "file" : "npm"
@@ -123,6 +124,40 @@ export function readPluginId(id: unknown, spec: string) {
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) {
if (source === "file") {
if (id) return id
@@ -135,15 +170,3 @@ export async function resolvePluginId(source: PluginSource, spec: string, target
}
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
-}
diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
index e9b1135f0..92f7dc170 100644
--- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
@@ -130,3 +130,60 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
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: "[email protected]" }
+ },
+ })
+
+ 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
+ }
+})
diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts
index 572f790fa..a225f66e7 100644
--- a/packages/opencode/test/plugin/loader-shared.test.ts
+++ b/packages/opencode/test/plugin/loader-shared.test.ts
@@ -128,6 +128,7 @@ describe("plugin.loader.shared", () => {
file,
[
"export default {",
+ ' id: "demo.v1-default",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "default")`,
" return {}",
@@ -154,6 +155,82 @@ describe("plugin.loader.shared", () => {
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 () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts
index d289689b9..a264cf5aa 100644
--- a/packages/plugin/src/index.ts
+++ b/packages/plugin/src/index.ts
@@ -42,7 +42,8 @@ export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Ho
export type PluginModule = {
id?: string
- server?: Plugin
+ server: Plugin
+ tui?: never
}
type Rule = {
diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts
index 62747884f..cbb6f62b6 100644
--- a/packages/plugin/src/tui.ts
+++ b/packages/plugin/src/tui.ts
@@ -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, PluginModule, PluginOptions } from "./index.js"
+import type { Config as PluginConfig, PluginOptions } from "./index.js"
export type { CliRenderer, SlotMode } from "@opentui/core"
@@ -107,6 +107,8 @@ export type TuiDialogPromptProps = {
description?: () => JSX.Element
placeholder?: string
value?: string
+ busy?: boolean
+ busyText?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
@@ -414,6 +416,8 @@ export type TuiPluginApi = {
export type TuiPlugin = (api: TuiPluginApi, options: PluginOptions | undefined, meta: TuiPluginMeta) => Promise<void>
-export type TuiPluginModule = PluginModule & {
- tui?: TuiPlugin
+export type TuiPluginModule = {
+ id?: string
+ tui: TuiPlugin
+ server?: never
}