Single target plugin entrypoints (#19467)
parent
02b19bc3d7
commit
f3997d8082
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue