summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorSebastian <[email protected]>2026-03-30 20:36:21 +0200
committerGitHub <[email protected]>2026-03-30 20:36:21 +0200
commitfa95a61c4e15d6b55ac2e3a1da0176ceca76d8c2 (patch)
tree1f3de5b9a6f44829393121c6b14da00da05b287a /packages
parent9f3c2bd861691e2415c027f87e23302b57026a6e (diff)
downloadopencode-fa95a61c4e15d6b55ac2e3a1da0176ceca76d8c2.tar.gz
opencode-fa95a61c4e15d6b55ac2e3a1da0176ceca76d8c2.zip
Refactor into plugin loader and do not enforce (#20112)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/specs/tui-plugins.md10
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/runtime.ts226
-rw-r--r--packages/opencode/src/config/config.ts40
-rw-r--r--packages/opencode/src/config/tui.ts20
-rw-r--r--packages/opencode/src/plugin/index.ts185
-rw-r--r--packages/opencode/src/plugin/loader.ts135
-rw-r--r--packages/opencode/src/plugin/shared.ts165
-rw-r--r--packages/opencode/test/cli/tui/plugin-add.test.ts2
-rw-r--r--packages/opencode/test/cli/tui/plugin-install.test.ts9
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts313
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-pure.test.ts7
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader.test.ts94
-rw-r--r--packages/opencode/test/cli/tui/plugin-toggle.test.ts14
-rw-r--r--packages/opencode/test/config/config.test.ts32
-rw-r--r--packages/opencode/test/config/tui.test.ts34
-rw-r--r--packages/opencode/test/fixture/tui-runtime.ts19
-rw-r--r--packages/opencode/test/plugin/loader-shared.test.ts100
17 files changed, 1055 insertions, 350 deletions
diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md
index 5a7caa75b..d5fe48629 100644
--- a/packages/opencode/specs/tui-plugins.md
+++ b/packages/opencode/specs/tui-plugins.md
@@ -84,12 +84,18 @@ export default plugin
- 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 package `exports` contains `./tui`, the loader resolves that entrypoint.
+- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
+- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
+- `package.json` `main` is only used for server plugin entrypoint resolution.
- 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.
-- If a path spec points at a directory, that directory must have `package.json` with `main`.
+- If a path spec points at a directory, server loading can use `package.json` `main`.
+- TUI path loading never uses `package.json` `main`.
+- Legacy compatibility: path specs like `./plugin` can resolve to `./plugin/index.ts` (or `index.js`) when `package.json` is missing.
+- The `./plugin -> ./plugin/index.*` fallback applies to both server and TUI v1 loading.
- There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
## Package manifest and install
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
index e992577a6..3fde4fc29 100644
--- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
+++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
@@ -18,17 +18,8 @@ import { Log } from "@/util/log"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
-import {
- checkPluginCompatibility,
- isDeprecatedPlugin,
- pluginSource,
- readPluginId,
- readV1Plugin,
- resolvePluginEntrypoint,
- resolvePluginId,
- resolvePluginTarget,
- type PluginSource,
-} from "@/plugin/shared"
+import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
+import { PluginLoader } from "@/plugin/loader"
import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
import { hasTheme, upsertTheme } from "../context/theme"
@@ -36,13 +27,12 @@ import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Flag } from "@/flag/flag"
-import { Installation } from "@/installation"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"
type PluginLoad = {
- item?: Config.PluginSpec
+ options: Config.PluginOptions | undefined
spec: string
target: string
retry: boolean
@@ -67,7 +57,6 @@ type PluginEntry = {
meta: TuiPluginMeta
themes: Record<string, PluginMeta.Theme>
plugin: TuiPlugin
- options: Config.PluginOptions | undefined
enabled: boolean
scope?: PluginScope
}
@@ -78,13 +67,7 @@ type RuntimeState = {
slots: HostSlots
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
- pending: Map<
- string,
- {
- item: Config.PluginSpec
- meta: TuiConfig.PluginMeta
- }
- >
+ pending: Map<string, TuiConfig.PluginRecord>
}
const log = Log.create({ service: "tui.plugin" })
@@ -239,73 +222,76 @@ function createThemeInstaller(
}
}
-async function loadExternalPlugin(
- item: Config.PluginSpec,
- meta: TuiConfig.PluginMeta | undefined,
- retry = false,
-): Promise<PluginLoad | undefined> {
- const spec = Config.pluginSpecifier(item)
- if (isDeprecatedPlugin(spec)) return
- log.info("loading tui plugin", { path: spec, retry })
- const resolved = await resolvePluginTarget(spec).catch((error) => {
- fail("failed to resolve tui plugin", { path: spec, retry, error })
- return
- })
- if (!resolved) return
+async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
+ const plan = PluginLoader.plan(cfg.item)
+ if (plan.deprecated) return
- const source = pluginSource(spec)
- if (source === "npm") {
- const ok = await checkPluginCompatibility(resolved, Installation.VERSION)
- .then(() => true)
- .catch((error) => {
- fail("tui plugin incompatible", { path: spec, retry, error })
- return false
- })
- if (!ok) return
+ log.info("loading tui plugin", { path: plan.spec, retry })
+ const resolved = await PluginLoader.resolve(plan, "tui")
+ if (!resolved.ok) {
+ if (resolved.stage === "install") {
+ fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
+ return
+ }
+ if (resolved.stage === "compatibility") {
+ fail("tui plugin incompatible", { path: plan.spec, retry, error: resolved.error })
+ return
+ }
+ fail("failed to resolve tui plugin entry", { path: plan.spec, retry, error: resolved.error })
+ return
}
- const target = resolved
- if (!meta) {
- fail("missing tui plugin metadata", {
- path: spec,
+ const loaded = await PluginLoader.load(resolved.value)
+ if (!loaded.ok) {
+ fail("failed to load tui plugin", {
+ path: plan.spec,
+ target: resolved.value.entry,
retry,
+ error: loaded.error,
})
return
}
- const root = resolveRoot(source === "file" ? spec : target)
- const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
- fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
- return
- })
- if (!entry) return
-
- const mod = await import(entry)
- .then((raw) => {
- return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
+ const mod = await Promise.resolve()
+ .then(() => {
+ return readV1Plugin(loaded.value.mod as Record<string, unknown>, plan.spec, "tui") as TuiPluginModule
})
.catch((error) => {
- fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
+ fail("failed to load tui plugin", {
+ path: plan.spec,
+ target: loaded.value.entry,
+ retry,
+ error,
+ })
return
})
if (!mod) return
- const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => {
- fail("failed to load tui plugin", { path: spec, target, retry, error })
+ const id = await resolvePluginId(
+ loaded.value.source,
+ plan.spec,
+ loaded.value.target,
+ readPluginId(mod.id, plan.spec),
+ loaded.value.pkg,
+ ).catch((error) => {
+ fail("failed to load tui plugin", { path: plan.spec, target: loaded.value.target, retry, error })
return
})
if (!id) return
return {
- item,
- spec,
- target,
+ options: plan.options,
+ spec: plan.spec,
+ target: loaded.value.target,
retry,
- source,
+ source: loaded.value.source,
id,
module: mod,
- theme_meta: meta,
- theme_root: root,
+ theme_meta: {
+ scope: cfg.scope,
+ source: cfg.source,
+ },
+ theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
}
}
@@ -343,6 +329,7 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
const target = spec
return {
+ options: undefined,
spec,
target,
retry: false,
@@ -488,7 +475,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
const api = pluginApi(state, plugin, scope, plugin.id)
const ok = await Promise.resolve()
.then(async () => {
- await plugin.plugin(api, plugin.options, plugin.meta)
+ await plugin.plugin(api, plugin.load.options, plugin.meta)
return true
})
.catch((error) => {
@@ -613,21 +600,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
}
}
-function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta, themes: Record<string, PluginMeta.Theme> = {}) {
- const options = load.item ? Config.pluginOptions(load.item) : undefined
- return [
- {
- id: load.id,
- load,
- meta,
- themes,
- plugin: load.module.tui,
- options,
- enabled: true,
- },
- ]
-}
-
function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
if (state.plugins_by_id.has(plugin.id)) {
fail("duplicate tui plugin id", {
@@ -651,12 +623,8 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
}
}
-async function resolveExternalPlugins(
- list: Config.PluginSpec[],
- wait: () => Promise<void>,
- meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined,
-) {
- const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item))))
+async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) {
+ const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item)))
const ready: PluginLoad[] = []
let deps: Promise<void> | undefined
@@ -665,13 +633,12 @@ async function resolveExternalPlugins(
if (!entry) {
const item = list[i]
if (!item) continue
- const spec = Config.pluginSpecifier(item)
- if (pluginSource(spec) !== "file") continue
+ if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
deps ??= wait().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
})
await deps
- entry = await loadExternalPlugin(item, meta(item), true)
+ entry = await loadExternalPlugin(item, true)
}
if (!entry) continue
ready.push(entry)
@@ -713,20 +680,27 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
- for (const plugin of collectPluginEntries(entry, row, themes)) {
- if (!addPluginEntry(state, plugin)) {
- ok = false
- continue
- }
- plugins.push(plugin)
+ const plugin: PluginEntry = {
+ id: entry.id,
+ load: entry,
+ meta: row,
+ themes,
+ plugin: entry.module.tui,
+ enabled: true,
+ }
+ if (!addPluginEntry(state, plugin)) {
+ ok = false
+ continue
}
+ plugins.push(plugin)
}
return { plugins, ok }
}
-function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta {
+function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
return {
+ item: spec,
scope: "local",
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
}
@@ -764,36 +738,28 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
const spec = raw.trim()
if (!spec) return false
- const pending = state.pending.get(spec)
- const item = pending?.item ?? spec
- const nextSpec = Config.pluginSpecifier(item)
- if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) {
+ const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
+ const next = Config.pluginSpecifier(cfg.item)
+ if (state.plugins.some((plugin) => plugin.load.spec === next)) {
state.pending.delete(spec)
return true
}
- const meta = pending?.meta ?? defaultPluginMeta(state)
-
const ready = await Instance.provide({
directory: state.directory,
- fn: () =>
- resolveExternalPlugins(
- [item],
- () => TuiConfig.waitForDependencies(),
- () => meta,
- ),
+ fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
- fail("failed to add tui plugin", { path: nextSpec, error })
+ fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[]
})
if (!ready.length) {
- fail("failed to add tui plugin", { path: nextSpec })
+ fail("failed to add tui plugin", { path: next })
return false
}
const first = ready[0]
if (!first) {
- fail("failed to add tui plugin", { path: nextSpec })
+ fail("failed to add tui plugin", { path: next })
return false
}
if (state.plugins_by_id.has(first.id)) {
@@ -810,7 +776,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
if (ok) state.pending.delete(spec)
if (!ok) {
- fail("failed to add tui plugin", { path: nextSpec })
+ fail("failed to add tui plugin", { path: next })
}
return ok
}
@@ -893,12 +859,11 @@ async function installPluginBySpec(
const tui = manifest.targets.find((item) => item.kind === "tui")
if (tui) {
const file = patch.items.find((item) => item.kind === "tui")?.file
+ const item = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
state.pending.set(spec, {
- item: tui.opts ? [spec, tui.opts] : spec,
- meta: {
- scope: global ? "global" : "local",
- source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
- },
+ item,
+ scope: global ? "global" : "local",
+ source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
})
}
@@ -981,25 +946,26 @@ export namespace TuiPluginRuntime {
directory: cwd,
fn: async () => {
const config = await TuiConfig.get()
- const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? [])
- if (Flag.OPENCODE_PURE && config.plugin?.length) {
- log.info("skipping external tui plugins in pure mode", { count: config.plugin.length })
+ const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? [])
+ if (Flag.OPENCODE_PURE && config.plugin_records?.length) {
+ log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
- for (const plugin of collectPluginEntries(entry, meta)) {
- addPluginEntry(next, plugin)
- }
+ addPluginEntry(next, {
+ id: entry.id,
+ load: entry,
+ meta,
+ themes: {},
+ plugin: entry.module.tui,
+ enabled: true,
+ })
}
- const ready = await resolveExternalPlugins(
- plugins,
- () => TuiConfig.waitForDependencies(),
- (item) => config.plugin_meta?.[Config.pluginSpecifier(item)],
- )
+ const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config)
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 3cbb34162..d02a1b270 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -1,7 +1,6 @@
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL } from "url"
-import { createRequire } from "module"
import os from "os"
import z from "zod"
import { ModelsDev } from "../provider/models"
@@ -366,33 +365,18 @@ export namespace Config {
export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
const spec = pluginSpecifier(plugin)
if (!isPathPluginSpec(spec)) return plugin
- if (spec.startsWith("file://")) {
- const resolved = await resolvePathPluginTarget(spec).catch(() => spec)
- if (Array.isArray(plugin)) return [resolved, plugin[1]]
- return resolved
- }
- if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) {
- const base = pathToFileURL(spec).href
- const resolved = await resolvePathPluginTarget(base).catch(() => base)
- if (Array.isArray(plugin)) return [resolved, plugin[1]]
- return resolved
- }
- try {
- const base = import.meta.resolve!(spec, configFilepath)
- const resolved = await resolvePathPluginTarget(base).catch(() => base)
- if (Array.isArray(plugin)) return [resolved, plugin[1]]
- return resolved
- } catch {
- try {
- const require = createRequire(configFilepath)
- const base = pathToFileURL(require.resolve(spec)).href
- const resolved = await resolvePathPluginTarget(base).catch(() => base)
- if (Array.isArray(plugin)) return [resolved, plugin[1]]
- return resolved
- } catch {
- return plugin
- }
- }
+
+ const base = path.dirname(configFilepath)
+ const file = (() => {
+ if (spec.startsWith("file://")) return spec
+ if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href
+ return pathToFileURL(path.resolve(base, spec)).href
+ })()
+
+ const resolved = await resolvePathPluginTarget(file).catch(() => file)
+
+ if (Array.isArray(plugin)) return [resolved, plugin[1]]
+ return resolved
}
/**
diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts
index 857b67396..7f5d50df5 100644
--- a/packages/opencode/src/config/tui.ts
+++ b/packages/opencode/src/config/tui.ts
@@ -22,6 +22,12 @@ export namespace TuiConfig {
source: string
}
+ export type PluginRecord = {
+ item: Config.PluginSpec
+ scope: PluginMeta["scope"]
+ source: string
+ }
+
type PluginEntry = {
item: Config.PluginSpec
meta: PluginMeta
@@ -33,7 +39,8 @@ export namespace TuiConfig {
}
export type Info = z.output<typeof Info> & {
- plugin_meta?: Record<string, PluginMeta>
+ // Internal resolved plugin list used by runtime loading.
+ plugin_records?: PluginRecord[]
}
function pluginScope(file: string): PluginMeta["scope"] {
@@ -149,10 +156,13 @@ export namespace TuiConfig {
const merged = dedupePlugins(acc.entries)
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
- acc.result.plugin = merged.map((item) => item.item)
- acc.result.plugin_meta = merged.length
- ? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta]))
- : undefined
+ const list = merged.map((item) => ({
+ item: item.item,
+ scope: item.meta.scope,
+ source: item.meta.source,
+ }))
+ acc.result.plugin = list.map((item) => item.item)
+ acc.result.plugin_records = list.length ? list : undefined
const deps: Promise<void>[] = []
if (acc.result.plugin?.length) {
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index a945b4b98..6cecfaac7 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -14,19 +14,8 @@ import { Effect, Layer, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error"
-import { Installation } from "@/installation"
-import {
- checkPluginCompatibility,
- isDeprecatedPlugin,
- parsePluginSpecifier,
- pluginSource,
- readPluginId,
- readV1Plugin,
- resolvePluginEntrypoint,
- resolvePluginId,
- resolvePluginTarget,
- type PluginSource,
-} from "./shared"
+import { PluginLoader } from "./loader"
+import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -36,11 +25,7 @@ export namespace Plugin {
}
type Loaded = {
- item: Config.PluginSpec
- spec: string
- target: string
- source: PluginSource
- mod: Record<string, unknown>
+ row: PluginLoader.Loaded
}
// Hook names that follow the (input, output) => Promise<void> trigger pattern
@@ -93,91 +78,22 @@ export namespace Plugin {
return result
}
- async function resolvePlugin(spec: string) {
- const parsed = parsePluginSpecifier(spec)
- const target = await resolvePluginTarget(spec, parsed).catch((err) => {
- const cause = err instanceof Error ? err.cause : err
- const detail = errorMessage(cause ?? err)
- log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
- Bus.publish(Session.Event.Error, {
- error: new NamedError.Unknown({
- message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
- }).toObject(),
- })
- return ""
- })
- if (!target) return
- return target
- }
-
- async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
- const spec = Config.pluginSpecifier(item)
- if (isDeprecatedPlugin(spec)) return
- log.info("loading plugin", { path: spec })
- const resolved = await resolvePlugin(spec)
- if (!resolved) return
-
- const source = pluginSource(spec)
- if (source === "npm") {
- const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
- .then(() => false)
- .catch((err) => {
- const message = errorMessage(err)
- log.warn("plugin incompatible", { path: spec, error: message })
- Bus.publish(Session.Event.Error, {
- error: new NamedError.Unknown({
- message: `Plugin ${spec} skipped: ${message}`,
- }).toObject(),
- })
- return true
- })
- if (incompatible) return
- }
-
- const target = resolved
- const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
- const message = errorMessage(err)
- log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
- Bus.publish(Session.Event.Error, {
- error: new NamedError.Unknown({
- message: `Failed to load plugin ${spec}: ${message}`,
- }).toObject(),
- })
- return
- })
- if (!entry) return
-
- const mod = await import(entry).catch((err) => {
- const message = errorMessage(err)
- log.error("failed to load plugin", { path: spec, target: entry, error: message })
- Bus.publish(Session.Event.Error, {
- error: new NamedError.Unknown({
- message: `Failed to load plugin ${spec}: ${message}`,
- }).toObject(),
- })
- return
- })
- if (!mod) return
-
- return {
- item,
- spec,
- target,
- source,
- mod,
- }
- }
-
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
- const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
+ const plugin = readV1Plugin(load.row.mod, load.row.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)))
+ await resolvePluginId(
+ load.row.source,
+ load.row.spec,
+ load.row.target,
+ readPluginId(plugin.id, load.row.spec),
+ load.row.pkg,
+ )
+ hooks.push(await (plugin as PluginModule).server(input, load.row.options))
return
}
- for (const server of getLegacyPlugins(load.mod)) {
- hooks.push(await server(input, Config.pluginOptions(load.item)))
+ for (const server of getLegacyPlugins(load.row.mod)) {
+ hooks.push(await server(input, load.row.options))
}
}
@@ -232,7 +148,74 @@ export namespace Plugin {
}
if (plugins.length) yield* config.waitForDependencies()
- const loaded = yield* Effect.promise(() => Promise.all(plugins.map((item) => prepPlugin(item))))
+ const loaded = yield* Effect.promise(() =>
+ Promise.all(
+ plugins.map(async (item) => {
+ const plan = PluginLoader.plan(item)
+ if (plan.deprecated) return
+ log.info("loading plugin", { path: plan.spec })
+
+ const resolved = await PluginLoader.resolve(plan, "server")
+ if (!resolved.ok) {
+ const cause =
+ resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
+ const message = errorMessage(cause)
+
+ if (resolved.stage === "install") {
+ const parsed = parsePluginSpecifier(plan.spec)
+ log.error("failed to install plugin", {
+ pkg: parsed.pkg,
+ version: parsed.version,
+ error: message,
+ })
+ Bus.publish(Session.Event.Error, {
+ error: new NamedError.Unknown({
+ message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`,
+ }).toObject(),
+ })
+ return
+ }
+
+ if (resolved.stage === "compatibility") {
+ log.warn("plugin incompatible", { path: plan.spec, error: message })
+ Bus.publish(Session.Event.Error, {
+ error: new NamedError.Unknown({
+ message: `Plugin ${plan.spec} skipped: ${message}`,
+ }).toObject(),
+ })
+ return
+ }
+
+ log.error("failed to resolve plugin server entry", {
+ path: plan.spec,
+ error: message,
+ })
+ Bus.publish(Session.Event.Error, {
+ error: new NamedError.Unknown({
+ message: `Failed to load plugin ${plan.spec}: ${message}`,
+ }).toObject(),
+ })
+ return
+ }
+
+ const mod = await PluginLoader.load(resolved.value)
+ if (!mod.ok) {
+ const message = errorMessage(mod.error)
+ log.error("failed to load plugin", { path: plan.spec, target: resolved.value.entry, error: message })
+ Bus.publish(Session.Event.Error, {
+ error: new NamedError.Unknown({
+ message: `Failed to load plugin ${plan.spec}: ${message}`,
+ }).toObject(),
+ })
+ return
+ }
+
+ return {
+ row: mod.value,
+ }
+ }),
+ ),
+ )
for (const load of loaded) {
if (!load) continue
@@ -242,14 +225,14 @@ export namespace Plugin {
try: () => applyPlugin(load, input, hooks),
catch: (err) => {
const message = errorMessage(err)
- log.error("failed to load plugin", { path: load.spec, error: message })
+ log.error("failed to load plugin", { path: load.row.spec, error: message })
return message
},
}).pipe(
Effect.catch((message) =>
bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
- message: `Failed to load plugin ${load.spec}: ${message}`,
+ message: `Failed to load plugin ${load.row.spec}: ${message}`,
}).toObject(),
}),
),
diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts
new file mode 100644
index 000000000..63a2ddd11
--- /dev/null
+++ b/packages/opencode/src/plugin/loader.ts
@@ -0,0 +1,135 @@
+import { Config } from "@/config/config"
+import { Installation } from "@/installation"
+import {
+ checkPluginCompatibility,
+ createPluginEntry,
+ isDeprecatedPlugin,
+ resolvePluginTarget,
+ type PluginKind,
+ type PluginPackage,
+ type PluginSource,
+} from "./shared"
+
+export namespace PluginLoader {
+ export type Plan = {
+ item: Config.PluginSpec
+ spec: string
+ options: Config.PluginOptions | undefined
+ deprecated: boolean
+ }
+
+ export type Resolved = Plan & {
+ source: PluginSource
+ target: string
+ entry: string
+ pkg?: PluginPackage
+ }
+
+ export type Loaded = Resolved & {
+ mod: Record<string, unknown>
+ }
+
+ export function plan(item: Config.PluginSpec): Plan {
+ const spec = Config.pluginSpecifier(item)
+ return {
+ item,
+ spec,
+ options: Config.pluginOptions(item),
+ deprecated: isDeprecatedPlugin(spec),
+ }
+ }
+
+ export async function resolve(
+ plan: Plan,
+ kind: PluginKind,
+ ): Promise<
+ { ok: true; value: Resolved } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
+ > {
+ let target = ""
+ try {
+ target = await resolvePluginTarget(plan.spec)
+ } catch (error) {
+ return {
+ ok: false,
+ stage: "install",
+ error,
+ }
+ }
+ if (!target) {
+ return {
+ ok: false,
+ stage: "install",
+ error: new Error(`Plugin ${plan.spec} target is empty`),
+ }
+ }
+
+ let base
+ try {
+ base = await createPluginEntry(plan.spec, target, kind)
+ } catch (error) {
+ return {
+ ok: false,
+ stage: "entry",
+ error,
+ }
+ }
+
+ if (!base.entry) {
+ return {
+ ok: false,
+ stage: "entry",
+ error: new Error(`Plugin ${plan.spec} entry is empty`),
+ }
+ }
+
+ if (base.source === "npm") {
+ try {
+ await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
+ } catch (error) {
+ return {
+ ok: false,
+ stage: "compatibility",
+ error,
+ }
+ }
+ }
+
+ return {
+ ok: true,
+ value: {
+ ...plan,
+ source: base.source,
+ target: base.target,
+ entry: base.entry,
+ pkg: base.pkg,
+ },
+ }
+ }
+
+ export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
+ let mod
+ try {
+ mod = await import(row.entry)
+ } catch (error) {
+ return {
+ ok: false,
+ error,
+ }
+ }
+
+ if (!mod) {
+ return {
+ ok: false,
+ error: new Error(`Plugin ${row.spec} module is empty`),
+ }
+ }
+
+ return {
+ ok: true,
+ value: {
+ ...row,
+ mod,
+ },
+ }
+ }
+}
diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts
index b6b25f89c..190d73301 100644
--- a/packages/opencode/src/plugin/shared.ts
+++ b/packages/opencode/src/plugin/shared.ts
@@ -23,13 +23,25 @@ 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"
+export type PluginPackage = {
+ dir: string
+ pkg: string
+ json: Record<string, unknown>
+}
+
+export type PluginEntry = {
+ spec: string
+ source: PluginSource
+ target: string
+ pkg?: PluginPackage
+ entry: string
}
-function hasEntrypoint(json: Record<string, unknown>, kind: PluginKind) {
- if (!isRecord(json.exports)) return false
- return `./${kind}` in json.exports
+const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
+
+export function pluginSource(spec: string): PluginSource {
+ if (isPathPluginSpec(spec)) return "file"
+ return "npm"
}
function resolveExportPath(raw: string, dir: string) {
@@ -48,26 +60,97 @@ function extractExportValue(value: unknown): string | undefined {
return undefined
}
-export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) {
- const pkg = await readPluginPackage(target).catch(() => undefined)
- if (!pkg) return target
- if (!hasEntrypoint(pkg.json, kind)) return target
-
- const exports = pkg.json.exports
- if (!isRecord(exports)) return target
- const raw = extractExportValue(exports[`./${kind}`])
- if (!raw) return target
+function packageMain(pkg: PluginPackage) {
+ const value = pkg.json.main
+ if (typeof value !== "string") return
+ const next = value.trim()
+ if (!next) return
+ return next
+}
+function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
const resolved = resolveExportPath(raw, pkg.dir)
const root = Filesystem.resolve(pkg.dir)
const next = Filesystem.resolve(resolved)
if (!Filesystem.contains(root, next)) {
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
}
-
return pathToFileURL(next).href
}
+function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
+ const exports = pkg.json.exports
+ if (isRecord(exports)) {
+ const raw = extractExportValue(exports[`./${kind}`])
+ if (raw) return resolvePackagePath(spec, raw, kind, pkg)
+ }
+
+ if (kind !== "server") return
+ const main = packageMain(pkg)
+ if (!main) return
+ return resolvePackagePath(spec, main, kind, pkg)
+}
+
+function targetPath(target: string) {
+ if (target.startsWith("file://")) return fileURLToPath(target)
+ if (path.isAbsolute(target) || /^[A-Za-z]:[\\/]/.test(target)) return target
+}
+
+async function resolveDirectoryIndex(dir: string) {
+ for (const name of INDEX_FILES) {
+ const file = path.join(dir, name)
+ if (await Filesystem.exists(file)) return file
+ }
+}
+
+async function resolveTargetDirectory(target: string) {
+ const file = targetPath(target)
+ if (!file) return
+ const stat = await Filesystem.stat(file)
+ if (!stat?.isDirectory()) return
+ return file
+}
+
+async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind, pkg?: PluginPackage) {
+ const source = pluginSource(spec)
+ const hit =
+ pkg ?? (source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined))
+ if (!hit) return target
+
+ const entry = resolvePackageEntrypoint(spec, kind, hit)
+ if (entry) return entry
+
+ const dir = await resolveTargetDirectory(target)
+
+ if (kind === "tui") {
+ if (source === "file" && dir) {
+ const index = await resolveDirectoryIndex(dir)
+ if (index) return pathToFileURL(index).href
+ }
+
+ if (source === "npm") {
+ throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"]`)
+ }
+
+ if (dir) {
+ throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"] or include index file`)
+ }
+
+ return target
+ }
+
+ if (dir && isRecord(hit.json.exports)) {
+ if (source === "file") {
+ const index = await resolveDirectoryIndex(dir)
+ if (index) return pathToFileURL(index).href
+ }
+
+ throw new TypeError(`Plugin ${spec} must define package.json exports["./server"] or package.json main`)
+ }
+
+ return target
+}
+
export function isPathPluginSpec(spec: string) {
return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
}
@@ -81,19 +164,21 @@ export async function resolvePathPluginTarget(spec: string) {
return pathToFileURL(file).href
}
- const pkg = await Filesystem.readJson<Record<string, unknown>>(path.join(file, "package.json")).catch(() => undefined)
- if (!pkg) throw new Error(`Plugin directory ${file} is missing package.json`)
- if (typeof pkg.main !== "string" || !pkg.main.trim()) {
- throw new Error(`Plugin directory ${file} must define package.json main`)
+ if (await Filesystem.exists(path.join(file, "package.json"))) {
+ return pathToFileURL(file).href
}
- return pathToFileURL(path.resolve(file, pkg.main)).href
+
+ const index = await resolveDirectoryIndex(file)
+ if (index) return pathToFileURL(index).href
+
+ throw new Error(`Plugin directory ${file} is missing package.json or index file`)
}
-export async function checkPluginCompatibility(target: string, opencodeVersion: string) {
+export async function checkPluginCompatibility(target: string, opencodeVersion: string, pkg?: PluginPackage) {
if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return
- const pkg = await readPluginPackage(target).catch(() => undefined)
- if (!pkg) return
- const engines = pkg.json.engines
+ const hit = pkg ?? (await readPluginPackage(target).catch(() => undefined))
+ if (!hit) return
+ const engines = hit.json.engines
if (!isRecord(engines)) return
const range = engines.opencode
if (typeof range !== "string") return
@@ -107,7 +192,7 @@ export async function resolvePluginTarget(spec: string, parsed = parsePluginSpec
return BunProc.install(parsed.pkg, parsed.version)
}
-export async function readPluginPackage(target: string) {
+export async function readPluginPackage(target: string): Promise<PluginPackage> {
const file = target.startsWith("file://") ? fileURLToPath(target) : target
const stat = await Filesystem.stat(file)
const dir = stat?.isDirectory() ? file : path.dirname(file)
@@ -116,6 +201,20 @@ export async function readPluginPackage(target: string) {
return { dir, pkg, json }
}
+export async function createPluginEntry(spec: string, target: string, kind: PluginKind): Promise<PluginEntry> {
+ const source = pluginSource(spec)
+ const pkg =
+ source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined)
+ const entry = await resolvePluginEntrypoint(spec, target, kind, pkg)
+ return {
+ spec,
+ source,
+ target,
+ pkg,
+ entry,
+ }
+}
+
export function readPluginId(id: unknown, spec: string) {
if (id === undefined) return
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
@@ -158,15 +257,21 @@ export function readV1Plugin(
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,
+ pkg?: PluginPackage,
+) {
if (source === "file") {
if (id) return id
throw new TypeError(`Path plugin ${spec} must export id`)
}
if (id) return id
- const pkg = await readPluginPackage(target)
- if (typeof pkg.json.name !== "string" || !pkg.json.name.trim()) {
- throw new TypeError(`Plugin package ${pkg.pkg} is missing name`)
+ const hit = pkg ?? (await readPluginPackage(target))
+ if (typeof hit.json.name !== "string" || !hit.json.name.trim()) {
+ throw new TypeError(`Plugin package ${hit.pkg} is missing name`)
}
- return pkg.json.name.trim()
+ return hit.json.name.trim()
}
diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts
index d6ff4fc6c..f42c52bb8 100644
--- a/packages/opencode/test/cli/tui/plugin-add.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-add.test.ts
@@ -33,7 +33,7 @@ test("adds tui plugin at runtime from spec", async () => {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [],
- plugin_meta: undefined,
+ plugin_records: undefined,
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts
index a2477cc79..b5cafe046 100644
--- a/packages/opencode/test/cli/tui/plugin-install.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-install.test.ts
@@ -48,7 +48,7 @@ test("installs plugin without loading it", async () => {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
let cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
plugin: [],
- plugin_meta: undefined,
+ plugin_records: undefined,
}
const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
@@ -68,12 +68,13 @@ test("installs plugin without loading it", async () => {
await TuiPluginRuntime.init(api)
cfg = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
- plugin_meta: {
- [tmp.extra.spec]: {
+ plugin_records: [
+ {
+ item: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
- },
+ ],
}
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
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 92f7dc170..6a3e679c6 100644
--- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
@@ -1,6 +1,7 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
+import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { TuiConfig } from "../../../src/config/tui"
@@ -45,9 +46,13 @@ test("loads npm tui plugin from package ./tui export", async () => {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
- plugin_meta: {
- [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
- },
+ plugin_records: [
+ {
+ item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ ],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -70,6 +75,65 @@ test("loads npm tui plugin from package ./tui export", async () => {
}
})
+test("does not use npm package exports dot for tui entry", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "acme-plugin")
+ const marker = path.join(dir, "dot-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" },
+ }),
+ )
+ await Bun.write(
+ path.join(mod, "index.js"),
+ `export default {
+ id: "demo.dot",
+ 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_records: [
+ {
+ item: 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
+ }
+})
+
test("rejects npm tui export that resolves outside plugin directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -107,9 +171,13 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
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") },
- },
+ plugin_records: [
+ {
+ item: 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)
@@ -166,10 +234,73 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
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") },
+ plugin_records: [
+ {
+ item: 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
+ }
+})
+
+test("does not use npm package main for tui entry", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "acme-plugin")
+ const marker = path.join(dir, "main-called.txt")
+ await fs.mkdir(mod, { recursive: true })
+
+ await Bun.write(
+ path.join(mod, "package.json"),
+ JSON.stringify({
+ name: "acme-plugin",
+ type: "module",
+ main: "./index.js",
+ }),
+ )
+ await Bun.write(
+ path.join(mod, "index.js"),
+ `export default {
+ id: "demo.main",
+ 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_records: [
+ {
+ item: 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)
@@ -187,3 +318,169 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
+
+test("does not use directory package main for tui entry", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "dir-plugin")
+ const spec = pathToFileURL(mod).href
+ const marker = path.join(dir, "dir-main-called.txt")
+ await fs.mkdir(mod, { recursive: true })
+
+ await Bun.write(
+ path.join(mod, "package.json"),
+ JSON.stringify({
+ name: "dir-plugin",
+ type: "module",
+ main: "./main.js",
+ }),
+ )
+ await Bun.write(
+ path.join(mod, "main.js"),
+ `export default {
+ id: "demo.dir.main",
+ tui: async () => {
+ await Bun.write(${JSON.stringify(marker)}, "called")
+ },
+}
+`,
+ )
+
+ return { marker, spec }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin: [tmp.extra.spec],
+ plugin_records: [
+ {
+ item: 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)
+
+ 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()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+})
+
+test("uses directory index fallback for tui when package.json is missing", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "dir-index")
+ const spec = pathToFileURL(mod).href
+ const marker = path.join(dir, "dir-index-called.txt")
+ await fs.mkdir(mod, { recursive: true })
+ await Bun.write(
+ path.join(mod, "index.ts"),
+ `export default {
+ id: "demo.dir.index",
+ tui: async () => {
+ await Bun.write(${JSON.stringify(marker)}, "called")
+ },
+}
+`,
+ )
+ return { marker, spec }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin: [tmp.extra.spec],
+ plugin_records: [
+ {
+ item: 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)
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
+ expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
+ } finally {
+ await TuiPluginRuntime.dispose()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+})
+
+test("uses npm package name when tui plugin id is omitted", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "acme-plugin")
+ const marker = path.join(dir, "name-id-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 {
+ tui: async (_api, options) => {
+ if (!options?.marker) return
+ await Bun.write(options.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, { marker: tmp.extra.marker }]],
+ plugin_records: [
+ {
+ item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+ 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")).resolves.toBe("called")
+ expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
+ } 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/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
index ef8f05c08..6f1899a05 100644
--- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
@@ -39,12 +39,13 @@ test("skips external tui plugins in pure mode", async () => {
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
- plugin_meta: {
- [tmp.extra.spec]: {
+ plugin_records: [
+ {
+ item: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
- },
+ ],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts
index 143c060e9..7e1f52467 100644
--- a/packages/opencode/test/cli/tui/plugin-loader.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts
@@ -468,10 +468,18 @@ test("continues loading when a plugin is missing config metadata", async () => {
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
tmp.extra.bareSpec,
],
- plugin_meta: {
- [tmp.extra.goodSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
- [tmp.extra.bareSpec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
- },
+ plugin_records: [
+ {
+ item: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ {
+ item: tmp.extra.bareSpec,
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ ],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -493,6 +501,84 @@ test("continues loading when a plugin is missing config metadata", async () => {
}
})
+test("initializes external tui plugins in config order", async () => {
+ const globalJson = path.join(Global.Path.config, "tui.json")
+ const globalJsonc = path.join(Global.Path.config, "tui.jsonc")
+ const backupJson = await Bun.file(globalJson)
+ .text()
+ .catch(() => undefined)
+ const backupJsonc = await Bun.file(globalJsonc)
+ .text()
+ .catch(() => undefined)
+
+ await fs.rm(globalJson, { force: true }).catch(() => {})
+ await fs.rm(globalJsonc, { force: true }).catch(() => {})
+
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const a = path.join(dir, "order-a.ts")
+ const b = path.join(dir, "order-b.ts")
+ const aSpec = pathToFileURL(a).href
+ const bSpec = pathToFileURL(b).href
+ const marker = path.join(dir, "tui-order.txt")
+
+ await Bun.write(
+ a,
+ `import fs from "fs/promises"
+
+export default {
+ id: "demo.tui.order.a",
+ tui: async () => {
+ await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
+ await Bun.sleep(25)
+ await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
+ },
+}
+`,
+ )
+ await Bun.write(
+ b,
+ `import fs from "fs/promises"
+
+export default {
+ id: "demo.tui.order.b",
+ tui: async () => {
+ await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
+ },
+}
+`,
+ )
+ await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
+
+ return { marker }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+ const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
+ expect(lines).toEqual(["a-start", "a-end", "b"])
+ } finally {
+ await TuiPluginRuntime.dispose()
+ cwd.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+
+ if (backupJson === undefined) {
+ await fs.rm(globalJson, { force: true }).catch(() => {})
+ } else {
+ await Bun.write(globalJson, backupJson)
+ }
+ if (backupJsonc === undefined) {
+ await fs.rm(globalJsonc, { force: true }).catch(() => {})
+ } else {
+ await Bun.write(globalJsonc, backupJsonc)
+ }
+ }
+})
+
describe("tui.plugin.loader", () => {
let data: Data
diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts
index c407d1117..14ee198fc 100644
--- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts
@@ -44,12 +44,13 @@ test("toggles plugin runtime state by exported id", async () => {
plugin_enabled: {
"demo.toggle": false,
},
- plugin_meta: {
- [tmp.extra.spec]: {
+ plugin_records: [
+ {
+ item: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
- },
+ ],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -121,12 +122,13 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
plugin_enabled: {
"demo.startup": false,
},
- plugin_meta: {
- [tmp.extra.spec]: {
+ plugin_records: [
+ {
+ item: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
- },
+ ],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index ea0a54520..d06bdf12a 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -1822,6 +1822,22 @@ describe("resolvePluginSpec", () => {
expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
})
+ test("resolves windows-style relative plugin directory specs", async () => {
+ if (process.platform !== "win32") return
+
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const plugin = path.join(dir, "plugin")
+ await fs.mkdir(plugin, { recursive: true })
+ await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
+ },
+ })
+
+ const file = path.join(tmp.path, "opencode.json")
+ const hit = await Config.resolvePluginSpec(".\\plugin", file)
+ expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
+ })
+
test("resolves relative file plugin paths to file urls", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -1834,7 +1850,7 @@ describe("resolvePluginSpec", () => {
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
})
- test("resolves plugin directory paths to package main files", async () => {
+ test("resolves plugin directory paths to directory urls", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const plugin = path.join(dir, "plugin")
@@ -1850,6 +1866,20 @@ describe("resolvePluginSpec", () => {
const file = path.join(tmp.path, "opencode.json")
const hit = await Config.resolvePluginSpec("./plugin", file)
+ expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
+ })
+
+ test("resolves plugin directories without package.json to index.ts", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const plugin = path.join(dir, "plugin")
+ await fs.mkdir(plugin, { recursive: true })
+ await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
+ },
+ })
+
+ const file = path.join(tmp.path, "opencode.json")
+ const hit = await Config.resolvePluginSpec("./plugin", file)
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
})
})
diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts
index 14f02fe30..7fb3704e3 100644
--- a/packages/opencode/test/config/tui.test.ts
+++ b/packages/opencode/test/config/tui.test.ts
@@ -476,12 +476,13 @@ test("loads managed tui config and gives it highest precedence", async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-theme")
expect(config.plugin).toEqual(["[email protected]"])
- expect(config.plugin_meta).toEqual({
+ expect(config.plugin_records).toEqual([
+ {
scope: "global",
source: path.join(managedConfigDir, "tui.json"),
},
- })
+ ])
},
})
})
@@ -539,12 +540,13 @@ test("supports tuple plugin specs with options in tui.json", async () => {
fn: async () => {
const config = await TuiConfig.get()
expect(config.plugin).toEqual([["[email protected]", { enabled: true, label: "demo" }]])
- expect(config.plugin_meta).toEqual({
+ expect(config.plugin_records).toEqual([
+ {
+ item: ["[email protected]", { enabled: true, label: "demo" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
- })
+ ])
},
})
})
@@ -578,16 +580,18 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
["[email protected]", { source: "project" }],
["[email protected]", { source: "project" }],
])
- expect(config.plugin_meta).toEqual({
+ expect(config.plugin_records).toEqual([
+ {
+ item: ["[email protected]", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
+ {
+ item: ["[email protected]", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
- })
+ ])
},
})
})
@@ -615,16 +619,18 @@ test("tracks global and local plugin metadata in merged tui config", async () =>
fn: async () => {
const config = await TuiConfig.get()
expect(config.plugin).toEqual(["[email protected]", "[email protected]"])
- expect(config.plugin_meta).toEqual({
+ expect(config.plugin_records).toEqual([
+ {
scope: "global",
source: path.join(Global.Path.config, "tui.json"),
},
+ {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
- })
+ ])
},
})
})
diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts
index 67ea4b9a4..1e2c0f2a6 100644
--- a/packages/opencode/test/fixture/tui-runtime.ts
+++ b/packages/opencode/test/fixture/tui-runtime.ts
@@ -6,21 +6,14 @@ type PluginSpec = string | [string, Record<string, unknown>]
export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
- const meta = Object.fromEntries(
- plugin.map((item) => {
- const spec = Array.isArray(item) ? item[0] : item
- return [
- spec,
- {
- scope: "local" as const,
- source: path.join(dir, "tui.json"),
- },
- ]
- }),
- )
+ const plugin_records = plugin.map((item) => ({
+ item,
+ scope: "local" as const,
+ source: path.join(dir, "tui.json"),
+ }))
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin,
- plugin_meta: meta,
+ plugin_records,
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts
index a225f66e7..d9ffa3950 100644
--- a/packages/opencode/test/plugin/loader-shared.test.ts
+++ b/packages/opencode/test/plugin/loader-shared.test.ts
@@ -331,6 +331,57 @@ describe("plugin.loader.shared", () => {
}
})
+ test("does not use npm package exports dot for server entry", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "acme-plugin")
+ const mark = path.join(dir, "dot-server.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" },
+ }),
+ )
+ await Bun.write(
+ path.join(mod, "index.js"),
+ [
+ "export default {",
+ ' id: "demo.dot.server",',
+ " server: async () => {",
+ ` await Bun.write(${JSON.stringify(mark)}, "called")`,
+ " return {}",
+ " },",
+ "}",
+ "",
+ ].join("\n"),
+ )
+
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
+
+ return { mod, mark }
+ },
+ })
+
+ const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+
+ try {
+ 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('exports["./server"]') && x.includes("package.json main"))).toBe(true)
+ } finally {
+ install.mockRestore()
+ }
+ })
+
test("rejects npm server export that resolves outside plugin directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -576,6 +627,55 @@ describe("plugin.loader.shared", () => {
})
})
+ test("initializes server plugins in config order", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const a = path.join(dir, "a-plugin.ts")
+ const b = path.join(dir, "b-plugin.ts")
+ const marker = path.join(dir, "server-order.txt")
+ const aSpec = pathToFileURL(a).href
+ const bSpec = pathToFileURL(b).href
+
+ await Bun.write(
+ a,
+ `import fs from "fs/promises"
+
+export default {
+ id: "demo.order.a",
+ server: async () => {
+ await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
+ await Bun.sleep(25)
+ await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
+ return {}
+ },
+}
+`,
+ )
+ await Bun.write(
+ b,
+ `import fs from "fs/promises"
+
+export default {
+ id: "demo.order.b",
+ server: async () => {
+ await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
+ return {}
+ },
+}
+`,
+ )
+
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
+
+ return { marker }
+ },
+ })
+
+ await load(tmp.path)
+ const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
+ expect(lines).toEqual(["a-start", "a-end", "b"])
+ })
+
test("skips external plugins in pure mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {