summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSebastian <[email protected]>2026-04-02 01:50:22 +0200
committerGitHub <[email protected]>2026-04-01 23:50:22 +0000
commitf6fd43e57423a5d5767bad8894eb7803712f20b1 (patch)
treeb7130b56a68c2d5a9f94bd8a9180c875db9c3160
parent854484babf7a8b60eb01bc7e5f73136f0caf5b18 (diff)
downloadopencode-f6fd43e57423a5d5767bad8894eb7803712f20b1.tar.gz
opencode-f6fd43e57423a5d5767bad8894eb7803712f20b1.zip
Refactor plugin/config loading, add theme-only plugin package support (#20556)
-rw-r--r--packages/opencode/specs/tui-plugins.md25
-rw-r--r--packages/opencode/src/cli/cmd/plug.ts4
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/runtime.ts387
-rw-r--r--packages/opencode/src/config/config.ts146
-rw-r--r--packages/opencode/src/config/paths.ts11
-rw-r--r--packages/opencode/src/config/tui-migrate.ts (renamed from packages/opencode/src/config/migrate-tui-config.ts)0
-rw-r--r--packages/opencode/src/config/tui.ts71
-rw-r--r--packages/opencode/src/plugin/index.ts131
-rw-r--r--packages/opencode/src/plugin/install.ts38
-rw-r--r--packages/opencode/src/plugin/loader.ts161
-rw-r--r--packages/opencode/src/plugin/shared.ts46
-rw-r--r--packages/opencode/src/util/filesystem.ts33
-rw-r--r--packages/opencode/test/cli/tui/plugin-add.test.ts48
-rw-r--r--packages/opencode/test/cli/tui/plugin-install.test.ts2
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts32
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-pure.test.ts4
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader.test.ts6
-rw-r--r--packages/opencode/test/cli/tui/plugin-toggle.test.ts8
-rw-r--r--packages/opencode/test/config/config.test.ts83
-rw-r--r--packages/opencode/test/config/tui.test.ts101
-rw-r--r--packages/opencode/test/fixture/tui-runtime.ts6
-rw-r--r--packages/opencode/test/plugin/install.test.ts39
-rw-r--r--packages/opencode/test/plugin/loader-shared.test.ts300
-rw-r--r--packages/opencode/test/util/filesystem.test.ts89
24 files changed, 1239 insertions, 532 deletions
diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md
index c1c4f5308..632f1e170 100644
--- a/packages/opencode/specs/tui-plugins.md
+++ b/packages/opencode/specs/tui-plugins.md
@@ -10,6 +10,7 @@ Technical reference for the current TUI plugin system.
- 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.
+- npm packages can be TUI theme-only via `package.json["oc-themes"]` without a `./tui` entrypoint.
## TUI config
@@ -88,7 +89,8 @@ export default plugin
- 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 configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure).
+- If a configured TUI package has no `./tui` entrypoint and no valid `oc-themes`, it is skipped with a warning (not a load failure).
+- If a configured TUI package has no `./tui` entrypoint but has valid `oc-themes`, runtime creates a no-op module record and still loads it for theme sync and plugin state.
- 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.
@@ -101,10 +103,18 @@ export default plugin
## Package manifest and install
-Install target detection is inferred from `package.json` entrypoints:
+Install target detection is inferred from `package.json` entrypoints and theme metadata:
- `server` target when `exports["./server"]` exists or `main` is set.
- `tui` target when `exports["./tui"]` exists.
+- `tui` target when `oc-themes` exists and resolves to a non-empty set of valid package-relative theme paths.
+
+`oc-themes` rules:
+
+- `oc-themes` is an array of relative paths.
+- Absolute paths and `file://` paths are rejected.
+- Resolved theme paths must stay inside the package directory.
+- Invalid `oc-themes` causes manifest read failure for install.
Example:
@@ -289,9 +299,12 @@ Theme install behavior:
- Relative theme paths are resolved from the plugin root.
- Theme name is the JSON basename.
+- `api.theme.install(...)` and `oc-themes` auto-sync share the same installer path.
+- Theme copy/write runs under cross-process lock key `tui-theme:<dest>`.
- First install writes only when the destination file is missing.
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
-- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed.
+- On `updated`, host skips rewrite when tracked `mtime`/`size` is unchanged.
+- When a theme already exists and state is not `updated`, host can still persist theme metadata when destination already exists.
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
- Global plugins persist installed themes under the global `themes` dir.
- Invalid or unreadable theme files are ignored.
@@ -328,6 +341,7 @@ Slot notes:
- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
+- `api.plugins.add(spec)` can load theme-only packages (`oc-themes` with no `./tui`) as runtime entries.
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
@@ -357,7 +371,11 @@ Metadata is persisted by plugin id.
- External TUI plugins load from `tuiConfig.plugin`.
- `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
- External plugin resolution and import are parallel.
+- Packages with no `./tui` entrypoint and valid `oc-themes` are loaded as synthetic no-op TUI plugin modules.
+- Theme-only packages loaded this way appear in `api.plugins.list()` and plugin manager rows like other external plugins.
+- Packages with no `./tui` entrypoint and no valid `oc-themes` are skipped with warning.
- External plugin activation is sequential to keep command, route, and side-effect order deterministic.
+- Theme auto-sync from `oc-themes` runs before plugin `tui(...)` execution and only on metadata state `first` or `updated`.
- File plugins that fail initially are retried once after waiting for config dependency installation.
- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
@@ -400,6 +418,7 @@ The plugin manager is exposed as a command with title `Plugins` and value `plugi
- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
- Manager install uses `api.plugins.install(spec, { global })`.
- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
+- `tui` target detection includes `exports["./tui"]` and valid `oc-themes`.
- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
- If runtime add fails, TUI shows a warning and restart remains the fallback.
diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts
index 0e2465423..692c556b2 100644
--- a/packages/opencode/src/cli/cmd/plug.ts
+++ b/packages/opencode/src/cli/cmd/plug.ts
@@ -115,7 +115,9 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
if (manifest.code === "manifest_no_targets") {
inspect.stop("No plugin targets found", 1)
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
- dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.')
+ dep.log.info(
+ 'Expected one of: exports["./tui"], exports["./server"], package.json main for server, or package.json["oc-themes"] for tui themes.',
+ )
return false
}
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
index 9df4e060b..e5bc15d5c 100644
--- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
+++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
@@ -18,7 +18,14 @@ import { Log } from "@/util/log"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
-import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
+import {
+ readPackageThemes,
+ readPluginId,
+ readV1Plugin,
+ resolvePluginId,
+ type PluginPackage,
+ type PluginSource,
+} from "@/plugin/shared"
import { PluginLoader } from "@/plugin/loader"
import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
@@ -26,6 +33,7 @@ import { hasTheme, upsertTheme } from "../context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
+import { Flock } from "@/util/flock"
import { Flag } from "@/flag/flag"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
@@ -39,8 +47,9 @@ type PluginLoad = {
source: PluginSource | "internal"
id: string
module: TuiPluginModule
- theme_meta: TuiConfig.PluginMeta
+ origin: Config.PluginOrigin
theme_root: string
+ theme_files: string[]
}
type Api = HostPluginApi
@@ -67,12 +76,15 @@ type RuntimeState = {
slots: HostSlots
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
- pending: Map<string, TuiConfig.PluginRecord>
+ pending: Map<string, Config.PluginOrigin>
}
const log = Log.create({ service: "tui.plugin" })
const DISPOSE_TIMEOUT_MS = 5000
const KV_KEY = "plugin_enabled"
+const EMPTY_TUI: TuiPluginModule = {
+ tui: async () => {},
+}
function fail(message: string, data: Record<string, unknown>) {
if (!("error" in data)) {
@@ -134,7 +146,7 @@ function resolveRoot(root: string) {
}
function createThemeInstaller(
- meta: TuiConfig.PluginMeta,
+ meta: Config.PluginOrigin,
root: string,
spec: string,
plugin: PluginEntry,
@@ -153,159 +165,70 @@ function createThemeInstaller(
const stat = await Filesystem.statAsync(src)
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
- const exists = hasTheme(name)
- const prev = plugin.themes[name]
-
- if (exists) {
- if (plugin.meta.state !== "updated") return
- if (!prev) {
- if (await Filesystem.exists(dest)) {
- plugin.themes[name] = {
- src,
- dest,
- mtime,
- size,
- }
- await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
- log.warn("failed to track tui plugin theme", {
- path: spec,
- id: plugin.id,
- theme: src,
- dest,
- error,
- })
- })
- }
- return
- }
- if (prev.dest !== dest) return
- if (prev.mtime === mtime && prev.size === size) return
- }
-
- const text = await Filesystem.readText(src).catch((error) => {
- log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
- return
- })
- if (text === undefined) return
-
- const fail = Symbol()
- const data = await Promise.resolve(text)
- .then((x) => JSON.parse(x))
- .catch((error) => {
- log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
- return fail
- })
- if (data === fail) return
-
- if (!isTheme(data)) {
- log.warn("invalid tui plugin theme", { path: spec, theme: src })
- return
- }
-
- if (exists || !(await Filesystem.exists(dest))) {
- await Filesystem.write(dest, text).catch((error) => {
- log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
- })
- }
-
- upsertTheme(name, data)
- plugin.themes[name] = {
+ const info = {
src,
dest,
mtime,
size,
}
- await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
- log.warn("failed to track tui plugin theme", {
- path: spec,
- id: plugin.id,
- theme: src,
- dest,
- error,
- })
- })
- }
-}
-async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
- const plan = PluginLoader.plan(cfg.item)
- if (plan.deprecated) return
+ await Flock.withLock(`tui-theme:${dest}`, async () => {
+ const save = async () => {
+ plugin.themes[name] = info
+ await PluginMeta.setTheme(plugin.id, name, info).catch((error) => {
+ log.warn("failed to track tui plugin theme", {
+ path: spec,
+ id: plugin.id,
+ theme: src,
+ dest,
+ error,
+ })
+ })
+ }
- log.info("loading tui plugin", { path: plan.spec, retry })
- const resolved = await PluginLoader.resolve(plan, "tui")
- if (!resolved.ok) {
- if (resolved.stage === "missing") {
- warn("tui plugin has no entrypoint", {
- path: plan.spec,
- retry,
- message: resolved.message,
+ const exists = hasTheme(name)
+ const prev = plugin.themes[name]
+ if (exists) {
+ if (plugin.meta.state !== "updated") {
+ if (!prev && (await Filesystem.exists(dest))) {
+ await save()
+ }
+ return
+ }
+ if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return
+ }
+
+ const text = await Filesystem.readText(src).catch((error) => {
+ log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
+ return
})
- return
- }
+ if (text === undefined) return
+
+ const fail = Symbol()
+ const data = await Promise.resolve(text)
+ .then((x) => JSON.parse(x))
+ .catch((error) => {
+ log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
+ return fail
+ })
+ if (data === fail) return
- 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
- }
+ if (!isTheme(data)) {
+ log.warn("invalid tui plugin theme", { path: spec, theme: src })
+ return
+ }
- 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
- }
+ if (exists || !(await Filesystem.exists(dest))) {
+ await Filesystem.write(dest, text).catch((error) => {
+ log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
+ })
+ }
- 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: plan.spec,
- target: loaded.value.entry,
- retry,
- error,
- })
- return
+ upsertTheme(name, data)
+ await save()
+ }).catch((error) => {
+ log.warn("failed to lock tui plugin theme install", { path: spec, theme: src, dest, error })
})
- if (!mod) return
-
- 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 {
- options: plan.options,
- spec: plan.spec,
- target: loaded.value.target,
- retry,
- source: loaded.value.source,
- id,
- module: mod,
- theme_meta: {
- scope: cfg.scope,
- source: cfg.source,
- },
- theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
}
}
@@ -350,11 +273,38 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
source: "internal",
id: item.id,
module: item,
- theme_meta: {
+ origin: {
+ spec,
scope: "global",
source: target,
},
theme_root: process.cwd(),
+ theme_files: [],
+ }
+}
+
+async function readThemeFiles(spec: string, pkg?: PluginPackage) {
+ if (!pkg) return [] as string[]
+ return Promise.resolve()
+ .then(() => readPackageThemes(spec, pkg))
+ .catch((error) => {
+ warn("invalid tui plugin oc-themes", {
+ path: spec,
+ pkg: pkg.pkg,
+ error,
+ })
+ return [] as string[]
+ })
+}
+
+async function syncPluginThemes(plugin: PluginEntry) {
+ if (!plugin.load.theme_files.length) return
+ if (plugin.meta.state === "same") return
+ const install = createThemeInstaller(plugin.load.origin, plugin.load.theme_root, plugin.load.spec, plugin)
+ for (const file of plugin.load.theme_files) {
+ await install(file).catch((error) => {
+ warn("failed to sync tui plugin oc-themes", { path: plugin.load.spec, id: plugin.id, theme: file, error })
+ })
}
}
@@ -489,6 +439,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 syncPluginThemes(plugin)
await plugin.plugin(api, plugin.load.options, plugin.meta)
return true
})
@@ -555,7 +506,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
}
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
- install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
+ install: createThemeInstaller(load.origin, load.theme_root, load.spec, plugin),
})
const event: TuiPluginApi["event"] = {
@@ -637,28 +588,108 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
}
}
-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
-
- for (let i = 0; i < list.length; i++) {
- let entry = loaded[i]
- if (!entry) {
- const item = list[i]
- if (!item) continue
- if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
- deps ??= wait().catch((error) => {
+async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
+ return PluginLoader.loadExternal({
+ items: list,
+ kind: "tui",
+ wait: async () => {
+ await wait().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
})
- await deps
- entry = await loadExternalPlugin(item, true)
- }
- if (!entry) continue
- ready.push(entry)
- }
+ },
+ finish: async (loaded, origin, retry) => {
+ const mod = await Promise.resolve()
+ .then(() => readV1Plugin(loaded.mod as Record<string, unknown>, loaded.spec, "tui") as TuiPluginModule)
+ .catch((error) => {
+ fail("failed to load tui plugin", {
+ path: loaded.spec,
+ target: loaded.entry,
+ retry,
+ error,
+ })
+ return
+ })
+ if (!mod) return
+
+ const id = await resolvePluginId(
+ loaded.source,
+ loaded.spec,
+ loaded.target,
+ readPluginId(mod.id, loaded.spec),
+ loaded.pkg,
+ ).catch((error) => {
+ fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
+ return
+ })
+ if (!id) return
- return ready
+ const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
+
+ return {
+ options: loaded.options,
+ spec: loaded.spec,
+ target: loaded.target,
+ retry,
+ source: loaded.source,
+ id,
+ module: mod,
+ origin,
+ theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
+ theme_files,
+ }
+ },
+ missing: async (loaded, origin, retry) => {
+ const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
+ if (!theme_files.length) return
+
+ const name =
+ typeof loaded.pkg?.json.name === "string" && loaded.pkg.json.name.trim().length > 0
+ ? loaded.pkg.json.name.trim()
+ : undefined
+ const id = await resolvePluginId(loaded.source, loaded.spec, loaded.target, name, loaded.pkg).catch((error) => {
+ fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
+ return
+ })
+ if (!id) return
+
+ return {
+ options: loaded.options,
+ spec: loaded.spec,
+ target: loaded.target,
+ retry,
+ source: loaded.source,
+ id,
+ module: EMPTY_TUI,
+ origin,
+ theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
+ theme_files,
+ }
+ },
+ report: {
+ start(candidate, retry) {
+ log.info("loading tui plugin", { path: candidate.plan.spec, retry })
+ },
+ missing(candidate, retry, message) {
+ warn("tui plugin has no entrypoint", { path: candidate.plan.spec, retry, message })
+ },
+ error(candidate, retry, stage, error, resolved) {
+ const spec = candidate.plan.spec
+ if (stage === "install") {
+ fail("failed to resolve tui plugin", { path: spec, retry, error })
+ return
+ }
+ if (stage === "compatibility") {
+ fail("tui plugin incompatible", { path: spec, retry, error })
+ return
+ }
+ if (stage === "entry") {
+ fail("failed to resolve tui plugin entry", { path: spec, retry, error })
+ return
+ }
+ fail("failed to load tui plugin", { path: spec, target: resolved?.entry, retry, error })
+ },
+ },
+ })
}
async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
@@ -692,12 +723,12 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
})
}
- const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
+ const info = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
const plugin: PluginEntry = {
id: entry.id,
load: entry,
- meta: row,
+ meta: info,
themes,
plugin: entry.module.tui,
enabled: true,
@@ -712,9 +743,9 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
return { plugins, ok }
}
-function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
+function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
return {
- item: spec,
+ spec,
scope: "local",
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
}
@@ -752,8 +783,8 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
const spec = raw.trim()
if (!spec) return false
- const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
- const next = Config.pluginSpecifier(cfg.item)
+ const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
+ const next = Config.pluginSpecifier(cfg.spec)
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
state.pending.delete(spec)
return true
@@ -837,7 +868,7 @@ async function installPluginBySpec(
if (manifest.code === "manifest_no_targets") {
return {
ok: false,
- message: `"${spec}" does not expose plugin entrypoints in package.json`,
+ message: `"${spec}" does not expose plugin entrypoints or oc-themes in package.json`,
}
}
@@ -872,9 +903,9 @@ 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
+ const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
state.pending.set(spec, {
- item,
+ spec: next,
scope: global ? "global" : "local",
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
})
@@ -959,9 +990,9 @@ export namespace TuiPluginRuntime {
directory: cwd,
fn: async () => {
const config = await TuiConfig.get()
- 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 })
+ const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
+ if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
+ log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 27618a3c3..3cae1af4b 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -47,6 +47,12 @@ export namespace Config {
export type PluginOptions = z.infer<typeof PluginOptions>
export type PluginSpec = z.infer<typeof PluginSpec>
+ export type PluginScope = "global" | "local"
+ export type PluginOrigin = {
+ spec: PluginSpec
+ source: string
+ scope: PluginScope
+ }
const log = Log.create({ service: "config" })
@@ -72,9 +78,6 @@ export namespace Config {
// Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
- if (target.plugin && source.plugin) {
- merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
- }
if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
}
@@ -297,31 +300,19 @@ export namespace Config {
return resolved
}
- /**
- * Deduplicates plugins by name, with later entries (higher priority) winning.
- * Priority order (highest to lowest):
- * 1. Local plugin/ directory
- * 2. Local opencode.json
- * 3. Global plugin/ directory
- * 4. Global opencode.json
- *
- * Since plugins are added in low-to-high priority order,
- * we reverse, deduplicate (keeping first occurrence), then restore order.
- */
- export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
- const seenNames = new Set<string>()
- const uniqueSpecifiers: PluginSpec[] = []
-
- for (const specifier of plugins.toReversed()) {
- const spec = pluginSpecifier(specifier)
+ export function deduplicatePluginOrigins(plugins: PluginOrigin[]): PluginOrigin[] {
+ const seen = new Set<string>()
+ const list: PluginOrigin[] = []
+
+ for (const plugin of plugins.toReversed()) {
+ const spec = pluginSpecifier(plugin.spec)
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
- if (!seenNames.has(name)) {
- seenNames.add(name)
- uniqueSpecifiers.push(specifier)
- }
+ if (seen.has(name)) continue
+ seen.add(name)
+ list.push(plugin)
}
- return uniqueSpecifiers.toReversed()
+ return list.toReversed()
}
export const McpLocal = z
@@ -997,7 +988,9 @@ export namespace Config {
ref: "Config",
})
- export type Info = z.output<typeof Info>
+ export type Info = z.output<typeof Info> & {
+ plugin_origins?: PluginOrigin[]
+ }
type State = {
config: Info
@@ -1044,6 +1037,11 @@ export namespace Config {
}, input)
}
+ function writable(info: Info) {
+ const { plugin_origins, ...next } = info
+ return next
+ }
+
function parseConfig(text: string, filepath: string): Info {
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
@@ -1208,6 +1206,30 @@ export namespace Config {
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
+
+ const scope = (source: string): PluginScope => {
+ if (source.startsWith("http://") || source.startsWith("https://")) return "global"
+ if (source === "OPENCODE_CONFIG_CONTENT") return "local"
+ if (Instance.containsPath(source)) return "local"
+ return "global"
+ }
+
+ const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => {
+ if (!list?.length) return
+ const hit = kind ?? scope(source)
+ const plugins = deduplicatePluginOrigins([
+ ...(result.plugin_origins ?? []),
+ ...list.map((spec) => ({ spec, source, scope: hit })),
+ ])
+ result.plugin = plugins.map((item) => item.spec)
+ result.plugin_origins = plugins
+ }
+
+ const merge = (source: string, next: Info, kind?: PluginScope) => {
+ result = mergeConfigConcatArrays(result, next)
+ track(source, next.plugin, kind)
+ }
+
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
@@ -1220,21 +1242,21 @@ export namespace Config {
const wellknown = (yield* Effect.promise(() => response.json())) as any
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
- result = mergeConfigConcatArrays(
- result,
- yield* loadConfig(JSON.stringify(remoteConfig), {
- dir: path.dirname(`${url}/.well-known/opencode`),
- source: `${url}/.well-known/opencode`,
- }),
- )
+ const source = `${url}/.well-known/opencode`
+ const next = yield* loadConfig(JSON.stringify(remoteConfig), {
+ dir: path.dirname(source),
+ source,
+ })
+ merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
- result = mergeConfigConcatArrays(result, yield* getGlobal())
+ const global = yield* getGlobal()
+ merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
- result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
+ merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
@@ -1242,7 +1264,7 @@ export namespace Config {
for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) {
- result = mergeConfigConcatArrays(result, yield* loadFile(file))
+ merge(file, yield* loadFile(file), "local")
}
}
@@ -1260,9 +1282,10 @@ export namespace Config {
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
- for (const file of ["opencode.jsonc", "opencode.json"]) {
- log.debug(`loading config from ${path.join(dir, file)}`)
- result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
+ for (const file of ["opencode.json", "opencode.jsonc"]) {
+ const source = path.join(dir, file)
+ log.debug(`loading config from ${source}`)
+ merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
@@ -1280,17 +1303,17 @@ export namespace Config {
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
- result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
+ const list = yield* Effect.promise(() => loadPlugin(dir))
+ track(dir, list)
}
if (process.env.OPENCODE_CONFIG_CONTENT) {
- result = mergeConfigConcatArrays(
- result,
- yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
- dir: ctx.directory,
- source: "OPENCODE_CONFIG_CONTENT",
- }),
- )
+ const source = "OPENCODE_CONFIG_CONTENT"
+ const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
+ dir: ctx.directory,
+ source,
+ })
+ merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
@@ -1309,13 +1332,12 @@ export namespace Config {
const config = Option.getOrUndefined(configOpt)
if (config) {
- result = mergeConfigConcatArrays(
- result,
- yield* loadConfig(JSON.stringify(config), {
- dir: path.dirname(`${active.url}/api/config`),
- source: `${active.url}/api/config`,
- }),
- )
+ const source = `${active.url}/api/config`
+ const next = yield* loadConfig(JSON.stringify(config), {
+ dir: path.dirname(source),
+ source,
+ })
+ merge(source, next, "global")
}
}).pipe(
Effect.catch((err) => {
@@ -1328,8 +1350,9 @@ export namespace Config {
}
if (existsSync(managedDir)) {
- for (const file of ["opencode.jsonc", "opencode.json"]) {
- result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
+ for (const file of ["opencode.json", "opencode.jsonc"]) {
+ const source = path.join(managedDir, file)
+ merge(source, yield* loadFile(source), "global")
}
}
@@ -1372,8 +1395,6 @@ export namespace Config {
result.compaction = { ...result.compaction, prune: false }
}
- result.plugin = deduplicatePlugins(result.plugin ?? [])
-
return {
config: result,
directories,
@@ -1403,7 +1424,9 @@ export namespace Config {
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file)
- yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
+ yield* fs
+ .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
+ .pipe(Effect.orDie)
yield* Effect.promise(() => Instance.dispose())
})
@@ -1427,15 +1450,16 @@ export namespace Config {
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
+ const input = writable(config)
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = parseConfig(before, file)
- const merged = mergeDeep(existing, config)
+ const merged = mergeDeep(writable(existing), input)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
- const updated = patchJsonc(before, config)
+ const updated = patchJsonc(before, input)
next = parseConfig(updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts
index 396417e9a..82ccf3945 100644
--- a/packages/opencode/src/config/paths.ts
+++ b/packages/opencode/src/config/paths.ts
@@ -9,14 +9,7 @@ import { Global } from "@/global"
export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) {
- const files: string[] = []
- for (const file of [`${name}.jsonc`, `${name}.json`]) {
- const found = await Filesystem.findUp(file, directory, worktree)
- for (const resolved of found.toReversed()) {
- files.push(resolved)
- }
- }
- return files
+ return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
}
export async function directories(directory: string, worktree: string) {
@@ -43,7 +36,7 @@ export namespace ConfigPaths {
}
export function fileInDirectory(dir: string, name: string) {
- return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
+ return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
}
export const JsonError = NamedError.create(
diff --git a/packages/opencode/src/config/migrate-tui-config.ts b/packages/opencode/src/config/tui-migrate.ts
index dbe33ffb4..dbe33ffb4 100644
--- a/packages/opencode/src/config/migrate-tui-config.ts
+++ b/packages/opencode/src/config/tui-migrate.ts
diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts
index 7f5d50df5..adfb3c781 100644
--- a/packages/opencode/src/config/tui.ts
+++ b/packages/opencode/src/config/tui.ts
@@ -3,72 +3,33 @@ import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Config } from "./config"
import { ConfigPaths } from "./paths"
-import { migrateTuiConfig } from "./migrate-tui-config"
+import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
-import { parsePluginSpecifier } from "@/plugin/shared"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
- export type PluginMeta = {
- scope: "global" | "local"
- source: string
- }
-
- export type PluginRecord = {
- item: Config.PluginSpec
- scope: PluginMeta["scope"]
- source: string
- }
-
- type PluginEntry = {
- item: Config.PluginSpec
- meta: PluginMeta
- }
-
type Acc = {
result: Info
- entries: PluginEntry[]
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
- plugin_records?: PluginRecord[]
+ plugin_origins?: Config.PluginOrigin[]
}
- function pluginScope(file: string): PluginMeta["scope"] {
+ function pluginScope(file: string): Config.PluginScope {
if (Instance.containsPath(file)) return "local"
return "global"
}
- function dedupePlugins(list: PluginEntry[]) {
- const seen = new Set<string>()
- const result: PluginEntry[] = []
- for (const item of list.toReversed()) {
- const spec = Config.pluginSpecifier(item.item)
- const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
- if (seen.has(name)) continue
- seen.add(name)
- result.push(item)
- }
- return result.toReversed()
- }
-
- function mergeInfo(target: Info, source: Info): Info {
- const merged = mergeDeep(target, source)
- if (target.plugin && source.plugin) {
- merged.plugin = [...target.plugin, ...source.plugin]
- }
- return merged
- }
-
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
@@ -95,19 +56,16 @@ export namespace TuiConfig {
async function mergeFile(acc: Acc, file: string) {
const data = await loadFile(file)
- acc.result = mergeInfo(acc.result, data)
+ acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file)
- for (const item of data.plugin) {
- acc.entries.push({
- item,
- meta: {
- scope,
- source: file,
- },
- })
- }
+ const plugins = Config.deduplicatePluginOrigins([
+ ...(acc.result.plugin_origins ?? []),
+ ...data.plugin.map((spec) => ({ spec, scope, source: file })),
+ ])
+ acc.result.plugin = plugins.map((item) => item.spec)
+ acc.result.plugin_origins = plugins
}
const state = Instance.state(async () => {
@@ -125,7 +83,6 @@ export namespace TuiConfig {
const acc: Acc = {
result: {},
- entries: [],
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
@@ -154,15 +111,7 @@ export namespace TuiConfig {
}
}
- const merged = dedupePlugins(acc.entries)
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
- 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 4f14d4d1f..53a8741ea 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -24,10 +24,6 @@ export namespace Plugin {
hooks: Hooks[]
}
- type Loaded = {
- row: PluginLoader.Loaded
- }
-
// Hook names that follow the (input, output) => Promise<void> trigger pattern
type TriggerName = {
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
@@ -78,22 +74,20 @@ export namespace Plugin {
return result
}
- async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
- const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect")
+ function publishPluginError(message: string) {
+ Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+ }
+
+ async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
+ const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
if (plugin) {
- 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))
+ await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
+ hooks.push(await (plugin as PluginModule).server(input, load.options))
return
}
- for (const server of getLegacyPlugins(load.row.mod)) {
- hooks.push(await server(input, load.row.options))
+ for (const server of getLegacyPlugins(load.mod)) {
+ hooks.push(await server(input, load.options))
}
}
@@ -142,87 +136,52 @@ export namespace Plugin {
if (init._tag === "Some") hooks.push(init.value)
}
- const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
- if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
- log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
+ const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
+ if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
+ log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
}
if (plugins.length) yield* config.waitForDependencies()
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) {
- if (resolved.stage === "missing") {
- log.warn("plugin has no server entrypoint", {
- path: plan.spec,
- message: resolved.message,
- })
+ PluginLoader.loadExternal({
+ items: plugins,
+ kind: "server",
+ report: {
+ start(candidate) {
+ log.info("loading plugin", { path: candidate.plan.spec })
+ },
+ missing(candidate, _retry, message) {
+ log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message })
+ },
+ error(candidate, _retry, stage, error, resolved) {
+ const spec = candidate.plan.spec
+ const cause = error instanceof Error ? (error.cause ?? error) : error
+ const message = stage === "load" ? errorMessage(error) : errorMessage(cause)
+
+ if (stage === "install") {
+ const parsed = parsePluginSpecifier(spec)
+ log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
+ publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
return
}
- 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(),
- })
+ if (stage === "compatibility") {
+ log.warn("plugin incompatible", { path: spec, error: message })
+ publishPluginError(`Plugin ${spec} skipped: ${message}`)
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(),
- })
+ if (stage === "entry") {
+ log.error("failed to resolve plugin server entry", { path: spec, error: message })
+ publishPluginError(`Failed to load plugin ${spec}: ${message}`)
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,
- }
- }),
- ),
+ log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
+ publishPluginError(`Failed to load plugin ${spec}: ${message}`)
+ },
+ },
+ }),
)
for (const load of loaded) {
if (!load) continue
@@ -233,14 +192,14 @@ export namespace Plugin {
try: () => applyPlugin(load, input, hooks),
catch: (err) => {
const message = errorMessage(err)
- log.error("failed to load plugin", { path: load.row.spec, error: message })
+ log.error("failed to load plugin", { path: load.spec, error: message })
return message
},
}).pipe(
Effect.catch((message) =>
bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
- message: `Failed to load plugin ${load.row.spec}: ${message}`,
+ message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(),
}),
),
diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts
index 1eed82624..b6bac42a7 100644
--- a/packages/opencode/src/plugin/install.ts
+++ b/packages/opencode/src/plugin/install.ts
@@ -13,7 +13,7 @@ import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { isRecord } from "@/util/record"
-import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
+import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared"
type Mode = "noop" | "add" | "replace"
type Kind = "server" | "tui"
@@ -142,19 +142,26 @@ function hasMainTarget(pkg: Record<string, unknown>) {
return Boolean(main.trim())
}
-function packageTargets(pkg: Record<string, unknown>) {
+function packageTargets(pkg: { json: Record<string, unknown>; dir: string; pkg: string }) {
+ const spec =
+ typeof pkg.json.name === "string" && pkg.json.name.trim().length > 0 ? pkg.json.name.trim() : path.basename(pkg.dir)
const targets: Target[] = []
- const server = exportTarget(pkg, "server")
+ const server = exportTarget(pkg.json, "server")
if (server) {
targets.push({ kind: "server", opts: server.opts })
- } else if (hasMainTarget(pkg)) {
+ } else if (hasMainTarget(pkg.json)) {
targets.push({ kind: "server" })
}
- const tui = exportTarget(pkg, "tui")
+ const tui = exportTarget(pkg.json, "tui")
if (tui) {
targets.push({ kind: "tui", opts: tui.opts })
}
+
+ if (!targets.some((item) => item.kind === "tui") && readPackageThemes(spec, pkg).length) {
+ targets.push({ kind: "tui" })
+ }
+
return targets
}
@@ -293,8 +300,23 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
}
}
- const targets = packageTargets(pkg.item.json)
- if (!targets.length) {
+ const targets = await Promise.resolve()
+ .then(() => packageTargets(pkg.item))
+ .then(
+ (item) => ({ ok: true as const, item }),
+ (error: unknown) => ({ ok: false as const, error }),
+ )
+
+ if (!targets.ok) {
+ return {
+ ok: false,
+ code: "manifest_read_failed",
+ file: pkg.item.pkg,
+ error: targets.error,
+ }
+ }
+
+ if (!targets.item.length) {
return {
ok: false,
code: "manifest_no_targets",
@@ -304,7 +326,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
return {
ok: true,
- targets,
+ targets: targets.item,
}
}
diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts
index 0f6671ed5..634fe6aad 100644
--- a/packages/opencode/src/plugin/loader.ts
+++ b/packages/opencode/src/plugin/loader.ts
@@ -4,6 +4,7 @@ import {
checkPluginCompatibility,
createPluginEntry,
isDeprecatedPlugin,
+ pluginSource,
resolvePluginTarget,
type PluginKind,
type PluginPackage,
@@ -12,31 +13,42 @@ import {
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 Missing = Plan & {
+ source: PluginSource
+ target: string
+ pkg?: PluginPackage
+ message: string
+ }
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
- export function plan(item: Config.PluginSpec): Plan {
+ type Candidate = { origin: Config.PluginOrigin; plan: Plan }
+ type Report = {
+ start?: (candidate: Candidate, retry: boolean) => void
+ missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
+ error?: (
+ candidate: Candidate,
+ retry: boolean,
+ stage: "install" | "entry" | "compatibility" | "load",
+ error: unknown,
+ resolved?: Resolved,
+ ) => void
+ }
+
+ function plan(item: Config.PluginSpec): Plan {
const spec = Config.pluginSpecifier(item)
- return {
- item,
- spec,
- options: Config.pluginOptions(item),
- deprecated: isDeprecatedPlugin(spec),
- }
+ return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
}
export async function resolve(
@@ -44,68 +56,44 @@ export namespace PluginLoader {
kind: PluginKind,
): Promise<
| { ok: true; value: Resolved }
- | { ok: false; stage: "missing"; message: string }
+ | { ok: false; stage: "missing"; value: Missing }
| { 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`),
- }
+ 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,
- }
+ return { ok: false, stage: "entry", error }
}
-
- if (!base.entry) {
+ if (!base.entry)
return {
ok: false,
stage: "missing",
- message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
+ value: {
+ ...plan,
+ source: base.source,
+ target: base.target,
+ pkg: base.pkg,
+ message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
+ },
}
- }
if (base.source === "npm") {
try {
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
} catch (error) {
- return {
- ok: false,
- stage: "compatibility",
- error,
- }
+ return { ok: false, stage: "compatibility", error }
}
}
-
- return {
- ok: true,
- value: {
- ...plan,
- source: base.source,
- target: base.target,
- entry: base.entry,
- pkg: base.pkg,
- },
- }
+ 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 }> {
@@ -113,25 +101,74 @@ export namespace PluginLoader {
try {
mod = await import(row.entry)
} catch (error) {
- return {
- ok: false,
- 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 } }
+ }
- if (!mod) {
- return {
- ok: false,
- error: new Error(`Plugin ${row.spec} module is empty`),
+ async function attempt<R>(
+ candidate: Candidate,
+ kind: PluginKind,
+ retry: boolean,
+ finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
+ missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
+ report: Report | undefined,
+ ): Promise<R | undefined> {
+ const plan = candidate.plan
+ if (plan.deprecated) return
+ report?.start?.(candidate, retry)
+ const resolved = await resolve(plan, kind)
+ if (!resolved.ok) {
+ if (resolved.stage === "missing") {
+ if (missing) {
+ const value = await missing(resolved.value, candidate.origin, retry)
+ if (value !== undefined) return value
+ }
+ report?.missing?.(candidate, retry, resolved.value.message, resolved.value)
+ return
}
+ report?.error?.(candidate, retry, resolved.stage, resolved.error)
+ return
}
+ const loaded = await load(resolved.value)
+ if (!loaded.ok) {
+ report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
+ return
+ }
+ if (!finish) return loaded.value as R
+ return finish(loaded.value, candidate.origin, retry)
+ }
- return {
- ok: true,
- value: {
- ...row,
- mod,
- },
+ type Input<R> = {
+ items: Config.PluginOrigin[]
+ kind: PluginKind
+ wait?: () => Promise<void>
+ finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
+ missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
+ report?: Report
+ }
+
+ export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
+ const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
+ const list: Array<Promise<R | undefined>> = []
+ for (const candidate of candidates) {
+ list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
+ }
+ const out = await Promise.all(list)
+ if (input.wait) {
+ let deps: Promise<void> | undefined
+ for (let i = 0; i < candidates.length; i++) {
+ if (out[i] !== undefined) continue
+ const candidate = candidates[i]
+ if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
+ deps ??= input.wait()
+ await deps
+ out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
+ }
}
+ const ready: R[] = []
+ for (const item of out) if (item !== undefined) ready.push(item)
+ return ready
}
}
diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts
index e8cbd3ae9..f92520d05 100644
--- a/packages/opencode/src/plugin/shared.ts
+++ b/packages/opencode/src/plugin/shared.ts
@@ -50,6 +50,10 @@ function resolveExportPath(raw: string, dir: string) {
return path.resolve(dir, raw)
}
+function isAbsolutePath(raw: string) {
+ return path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)
+}
+
function extractExportValue(value: unknown): string | undefined {
if (typeof value === "string") return value
if (!isRecord(value)) return undefined
@@ -68,14 +72,18 @@ function packageMain(pkg: PluginPackage) {
return next
}
-function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
+function resolvePackageFile(spec: string, raw: string, kind: string, 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
+ return next
+}
+
+function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
+ return pathToFileURL(resolvePackageFile(spec, raw, kind, pkg)).href
}
function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
@@ -106,7 +114,7 @@ async function resolveDirectoryIndex(dir: string) {
async function resolveTargetDirectory(target: string) {
const file = targetPath(target)
if (!file) return
- const stat = Filesystem.stat(file)
+ const stat = await Filesystem.statAsync(file)
if (!stat?.isDirectory()) return
return file
}
@@ -147,13 +155,13 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
}
export function isPathPluginSpec(spec: string) {
- return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
+ return spec.startsWith("file://") || spec.startsWith(".") || isAbsolutePath(spec)
}
export async function resolvePathPluginTarget(spec: string) {
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
- const stat = Filesystem.stat(file)
+ const stat = await Filesystem.statAsync(file)
if (!stat?.isDirectory()) {
if (spec.startsWith("file://")) return spec
return pathToFileURL(file).href
@@ -190,7 +198,7 @@ export async function resolvePluginTarget(spec: string, parsed = parsePluginSpec
export async function readPluginPackage(target: string): Promise<PluginPackage> {
const file = target.startsWith("file://") ? fileURLToPath(target) : target
- const stat = Filesystem.stat(file)
+ const stat = await Filesystem.statAsync(file)
const dir = stat?.isDirectory() ? file : path.dirname(file)
const pkg = path.join(dir, "package.json")
const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
@@ -211,6 +219,32 @@ export async function createPluginEntry(spec: string, target: string, kind: Plug
}
}
+export function readPackageThemes(spec: string, pkg: PluginPackage) {
+ const field = pkg.json["oc-themes"]
+ if (field === undefined) return []
+ if (!Array.isArray(field)) {
+ throw new TypeError(`Plugin ${spec} has invalid oc-themes field`)
+ }
+
+ const list = field.map((item) => {
+ if (typeof item !== "string") {
+ throw new TypeError(`Plugin ${spec} has invalid oc-themes entry`)
+ }
+
+ const raw = item.trim()
+ if (!raw) {
+ throw new TypeError(`Plugin ${spec} has empty oc-themes entry`)
+ }
+ if (raw.startsWith("file://") || isAbsolutePath(raw)) {
+ throw new TypeError(`Plugin ${spec} oc-themes entry must be relative: ${item}`)
+ }
+
+ return resolvePackageFile(spec, raw, "oc-themes", pkg)
+ })
+
+ return Array.from(new Set(list))
+}
+
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}`)
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index 29f79e958..5f50231b0 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -166,17 +166,42 @@ export namespace Filesystem {
return !relative(parent, child).startsWith("..")
}
- export async function findUp(target: string, start: string, stop?: string) {
+ export async function findUp(
+ target: string,
+ start: string,
+ stop?: string,
+ options?: { rootFirst?: boolean },
+ ): Promise<string[]>
+ export async function findUp(
+ target: string[],
+ start: string,
+ stop?: string,
+ options?: { rootFirst?: boolean },
+ ): Promise<string[]>
+ export async function findUp(
+ target: string | string[],
+ start: string,
+ stop?: string,
+ options?: { rootFirst?: boolean },
+ ) {
+ const dirs = [start]
let current = start
- const result = []
while (true) {
- const search = join(current, target)
- if (await exists(search)) result.push(search)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
+ dirs.push(parent)
current = parent
}
+
+ const targets = Array.isArray(target) ? target : [target]
+ const result = []
+ for (const dir of options?.rootFirst ? dirs.toReversed() : dirs) {
+ for (const item of targets) {
+ const search = join(dir, item)
+ if (await exists(search)) result.push(search)
+ }
+ }
return result
}
diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts
index f42c52bb8..748f29172 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_records: undefined,
+ plugin_origins: undefined,
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -59,3 +59,49 @@ test("adds tui plugin at runtime from spec", async () => {
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
+
+test("retries runtime add for file plugins after dependency wait", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "retry-plugin")
+ const spec = pathToFileURL(mod).href
+ const marker = path.join(dir, "retry-add.txt")
+ await fs.mkdir(mod, { recursive: true })
+ return { mod, spec, marker }
+ },
+ })
+
+ process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+ const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ plugin: [],
+ plugin_origins: undefined,
+ })
+ const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
+ await Bun.write(
+ path.join(tmp.extra.mod, "index.ts"),
+ `export default {
+ id: "demo.add.retry",
+ tui: async () => {
+ await Bun.write(${JSON.stringify(tmp.extra.marker)}, "called")
+ },
+}
+`,
+ )
+ })
+ const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+ try {
+ await TuiPluginRuntime.init(createTuiPluginApi())
+
+ await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
+ await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
+ expect(wait).toHaveBeenCalledTimes(1)
+ expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add.retry")?.active).toBe(true)
+ } finally {
+ await TuiPluginRuntime.dispose()
+ cwd.mockRestore()
+ get.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ }
+})
diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts
index c7f3615c6..290a7eea1 100644
--- a/packages/opencode/test/cli/tui/plugin-install.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-install.test.ts
@@ -52,7 +52,7 @@ test("installs plugin without loading it", async () => {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
plugin: [],
- plugin_records: undefined,
+ plugin_origins: undefined,
}
const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
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 1e6da5913..68c3df447 100644
--- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
@@ -46,9 +46,9 @@ 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_records: [
+ plugin_origins: [
{
- item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+ spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
@@ -108,9 +108,9 @@ test("does not use npm package exports dot for tui entry", 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_records: [
+ plugin_origins: [
{
- item: tmp.extra.spec,
+ spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
@@ -171,9 +171,9 @@ 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_records: [
+ plugin_origins: [
{
- item: tmp.extra.spec,
+ spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
@@ -234,9 +234,9 @@ 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_records: [
+ plugin_origins: [
{
- item: tmp.extra.spec,
+ spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
@@ -293,9 +293,9 @@ test("does not use npm package main for tui entry", 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_records: [
+ plugin_origins: [
{
- item: tmp.extra.spec,
+ spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
@@ -359,9 +359,9 @@ test("does not use directory package main for tui entry", 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_records: [
+ plugin_origins: [
{
- item: tmp.extra.spec,
+ spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
@@ -407,9 +407,9 @@ test("uses directory index fallback for tui when package.json is missing", 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_records: [
+ plugin_origins: [
{
- item: tmp.extra.spec,
+ spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
@@ -465,9 +465,9 @@ test("uses npm package name when tui plugin id is omitted", 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_records: [
+ plugin_origins: [
{
- item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+ spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
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 6f1899a05..f92d74292 100644
--- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
@@ -39,9 +39,9 @@ test("skips external tui plugins in pure mode", async () => {
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
- plugin_records: [
+ plugin_origins: [
{
- item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+ spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts
index 7e1f52467..e9e62d2a7 100644
--- a/packages/opencode/test/cli/tui/plugin-loader.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts
@@ -468,14 +468,14 @@ test("continues loading when a plugin is missing config metadata", async () => {
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
tmp.extra.bareSpec,
],
- plugin_records: [
+ plugin_origins: [
{
- item: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
+ spec: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
{
- item: tmp.extra.bareSpec,
+ spec: tmp.extra.bareSpec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts
index 14ee198fc..10ddfe8e1 100644
--- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts
@@ -44,9 +44,9 @@ test("toggles plugin runtime state by exported id", async () => {
plugin_enabled: {
"demo.toggle": false,
},
- plugin_records: [
+ plugin_origins: [
{
- item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+ spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
@@ -122,9 +122,9 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
plugin_enabled: {
"demo.startup": false,
},
- plugin_records: [
+ plugin_origins: [
{
- item: [tmp.extra.spec, { marker: tmp.extra.marker }],
+ spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 6369ab5ce..be2a6b11b 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -1,4 +1,4 @@
-import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
+import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config/config"
@@ -34,8 +34,13 @@ const emptyAuth = Layer.mock(Auth.Service)({
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
+beforeEach(async () => {
+ await Config.invalidate(true)
+})
+
afterEach(async () => {
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
+ await Config.invalidate(true)
})
async function writeManagedSettings(settings: object, filename = "opencode.json") {
@@ -169,7 +174,7 @@ test("loads JSONC config file", async () => {
})
})
-test("merges multiple config files with correct precedence", async () => {
+test("jsonc overrides json in the same directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(
@@ -191,7 +196,7 @@ test("merges multiple config files with correct precedence", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.model).toBe("override")
+ expect(config.model).toBe("base")
expect(config.username).toBe("base")
},
})
@@ -1174,6 +1179,51 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
})
})
+test("keeps plugin origins aligned with merged plugin list", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const project = path.join(dir, "project")
+ const local = path.join(project, ".opencode")
+ await fs.mkdir(local, { recursive: true })
+
+ await Filesystem.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ plugin: [["[email protected]", { source: "global" }], "[email protected]"],
+ }),
+ )
+
+ await Filesystem.write(
+ path.join(local, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ plugin: [["[email protected]", { source: "local" }], "[email protected]"],
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: path.join(tmp.path, "project"),
+ fn: async () => {
+ const cfg = await Config.get()
+ const plugins = cfg.plugin ?? []
+ const origins = cfg.plugin_origins ?? []
+ const names = plugins.map((item) => Config.pluginSpecifier(item))
+
+ expect(names).toContain("[email protected]")
+ expect(names).not.toContain("[email protected]")
+ expect(names).toContain("[email protected]")
+ expect(names).toContain("[email protected]")
+
+ expect(origins.map((item) => item.spec)).toEqual(plugins)
+ const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "[email protected]")
+ expect(hit?.scope).toBe("local")
+ },
+ })
+})
+
// Legacy tools migration tests
test("migrates legacy tools config to permissions - allow", async () => {
@@ -1550,7 +1600,7 @@ test("project config can override MCP server enabled status", async () => {
init: async (dir) => {
// Simulates a base config (like from remote .well-known) with disabled MCP
await Filesystem.write(
- path.join(dir, "opencode.jsonc"),
+ path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
@@ -1569,7 +1619,7 @@ test("project config can override MCP server enabled status", async () => {
)
// Project config enables just jira
await Filesystem.write(
- path.join(dir, "opencode.json"),
+ path.join(dir, "opencode.jsonc"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
@@ -1608,7 +1658,7 @@ test("MCP config deep merges preserving base config properties", async () => {
init: async (dir) => {
// Base config with full MCP definition
await Filesystem.write(
- path.join(dir, "opencode.jsonc"),
+ path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
@@ -1625,7 +1675,7 @@ test("MCP config deep merges preserving base config properties", async () => {
)
// Override just enables it, should preserve other properties
await Filesystem.write(
- path.join(dir, "opencode.json"),
+ path.join(dir, "opencode.jsonc"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
@@ -1875,11 +1925,20 @@ describe("resolvePluginSpec", () => {
})
})
-describe("deduplicatePlugins", () => {
+describe("deduplicatePluginOrigins", () => {
+ const dedupe = (plugins: Config.PluginSpec[]) =>
+ Config.deduplicatePluginOrigins(
+ plugins.map((spec) => ({
+ spec,
+ source: "",
+ scope: "global" as const,
+ })),
+ ).map((item) => item.spec)
+
test("removes duplicates keeping higher priority (later entries)", () => {
- const result = Config.deduplicatePlugins(plugins)
+ const result = dedupe(plugins)
expect(result).toContain("[email protected]")
expect(result).toContain("[email protected]")
@@ -1891,7 +1950,7 @@ describe("deduplicatePlugins", () => {
test("keeps path plugins separate from package plugins", () => {
const plugins = ["[email protected]", "file:///project/.opencode/plugin/oh-my-opencode.js"]
- const result = Config.deduplicatePlugins(plugins)
+ const result = dedupe(plugins)
expect(result).toEqual(plugins)
})
@@ -1899,7 +1958,7 @@ describe("deduplicatePlugins", () => {
test("deduplicates direct path plugins by exact spec", () => {
const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"]
- const result = Config.deduplicatePlugins(plugins)
+ const result = dedupe(plugins)
expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"])
})
@@ -1907,7 +1966,7 @@ describe("deduplicatePlugins", () => {
test("preserves order of remaining plugins", () => {
- const result = Config.deduplicatePlugins(plugins)
+ const result = dedupe(plugins)
expect(result).toEqual(["[email protected]", "[email protected]", "[email protected]"])
})
diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts
index 7fb3704e3..a8d98b66c 100644
--- a/packages/opencode/test/config/tui.test.ts
+++ b/packages/opencode/test/config/tui.test.ts
@@ -1,20 +1,99 @@
-import { afterEach, expect, test } from "bun:test"
+import { afterEach, beforeEach, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
+import { Config } from "../../src/config/config"
import { TuiConfig } from "../../src/config/tui"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem"
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
+beforeEach(async () => {
+ await Config.invalidate(true)
+})
+
afterEach(async () => {
delete process.env.OPENCODE_CONFIG
delete process.env.OPENCODE_TUI_CONFIG
+ await fs.rm(path.join(Global.Path.config, "opencode.json"), { force: true }).catch(() => {})
+ await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
+ await Config.invalidate(true)
+})
+
+test("keeps server and tui plugin merge semantics aligned", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const local = path.join(dir, ".opencode")
+ await fs.mkdir(local, { recursive: true })
+
+ await Bun.write(
+ path.join(Global.Path.config, "opencode.json"),
+ JSON.stringify(
+ {
+ plugin: [["[email protected]", { source: "global" }], "[email protected]"],
+ },
+ null,
+ 2,
+ ),
+ )
+ await Bun.write(
+ path.join(Global.Path.config, "tui.json"),
+ JSON.stringify(
+ {
+ plugin: [["[email protected]", { source: "global" }], "[email protected]"],
+ },
+ null,
+ 2,
+ ),
+ )
+
+ await Bun.write(
+ path.join(local, "opencode.json"),
+ JSON.stringify(
+ {
+ plugin: [["[email protected]", { source: "local" }], "[email protected]"],
+ },
+ null,
+ 2,
+ ),
+ )
+ await Bun.write(
+ path.join(local, "tui.json"),
+ JSON.stringify(
+ {
+ plugin: [["[email protected]", { source: "local" }], "[email protected]"],
+ },
+ null,
+ 2,
+ ),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const server = await Config.get()
+ const tui = await TuiConfig.get()
+ const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
+ const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
+
+ expect(serverPlugins).toEqual(tuiPlugins)
+ expect(serverPlugins).toContain("[email protected]")
+ expect(serverPlugins).not.toContain("[email protected]")
+
+ const serverOrigins = server.plugin_origins ?? []
+ const tuiOrigins = tui.plugin_origins ?? []
+ expect(serverOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(serverPlugins)
+ expect(tuiOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
+ expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
+ },
+ })
})
test("loads tui config with the same precedence order as server config paths", async () => {
@@ -476,9 +555,9 @@ 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_records).toEqual([
+ expect(config.plugin_origins).toEqual([
{
scope: "global",
source: path.join(managedConfigDir, "tui.json"),
},
@@ -540,9 +619,9 @@ 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_records).toEqual([
+ expect(config.plugin_origins).toEqual([
{
- item: ["[email protected]", { enabled: true, label: "demo" }],
+ spec: ["[email protected]", { enabled: true, label: "demo" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
@@ -580,14 +659,14 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
["[email protected]", { source: "project" }],
["[email protected]", { source: "project" }],
])
- expect(config.plugin_records).toEqual([
+ expect(config.plugin_origins).toEqual([
{
- item: ["[email protected]", { source: "project" }],
+ spec: ["[email protected]", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
{
- item: ["[email protected]", { source: "project" }],
+ spec: ["[email protected]", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
@@ -619,14 +698,14 @@ 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_records).toEqual([
+ expect(config.plugin_origins).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 1e2c0f2a6..fdd3b6cff 100644
--- a/packages/opencode/test/fixture/tui-runtime.ts
+++ b/packages/opencode/test/fixture/tui-runtime.ts
@@ -6,14 +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 plugin_records = plugin.map((item) => ({
- item,
+ const plugin_origins = plugin.map((spec) => ({
+ spec,
scope: "local" as const,
source: path.join(dir, "tui.json"),
}))
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin,
- plugin_records,
+ plugin_origins,
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts
index 20d71d3e1..5ce21c4cf 100644
--- a/packages/opencode/test/plugin/install.test.ts
+++ b/packages/opencode/test/plugin/install.test.ts
@@ -62,6 +62,7 @@ async function plugin(
server?: Record<string, unknown>
tui?: Record<string, unknown>
},
+ themes?: string[],
) {
const p = path.join(dir, "plugin")
const server = kinds?.includes("server") ?? false
@@ -92,6 +93,7 @@ async function plugin(
version: "1.0.0",
...(server ? { main: "./server.js" } : {}),
...(Object.keys(exports).length ? { exports } : {}),
+ ...(themes?.length ? { "oc-themes": themes } : {}),
},
null,
2,
@@ -438,6 +440,43 @@ describe("plugin.install.task", () => {
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
})
+ test("writes tui config for oc-themes-only packages", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, undefined, undefined, ["themes/forest.json"])
+ await fs.mkdir(path.join(target, "themes"), { recursive: true })
+ await Bun.write(path.join(target, "themes", "forest.json"), JSON.stringify({ theme: { text: "#fff" } }, null, 2))
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(true)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+
+ const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
+ expect(tui.plugin).toEqual(["[email protected]"])
+ })
+
+ test("returns false for oc-themes outside plugin directory", async () => {
+ await using tmp = await tmpdir()
+ const target = await plugin(tmp.path, undefined, undefined, ["../outside.json"])
+ const run = createPlugTask(
+ {
+ },
+ deps(path.join(tmp.path, "global"), target),
+ )
+
+ const ok = await run(ctx(tmp.path))
+ expect(ok).toBe(false)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
+ expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
+ })
+
test("force replaces version in both server and tui configs", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts
index 7830ac0da..c01a02ef4 100644
--- a/packages/opencode/test/plugin/loader-shared.test.ts
+++ b/packages/opencode/test/plugin/loader-shared.test.ts
@@ -9,6 +9,8 @@ const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
+const { PluginLoader } = await import("../../src/plugin/loader")
+const { readPackageThemes } = await import("../../src/plugin/shared")
const { Instance } = await import("../../src/project/instance")
const { Npm } = await import("../../src/npm")
const { Bus } = await import("../../src/bus")
@@ -833,4 +835,302 @@ export default {
}
}
})
+
+ test("reads oc-themes from package manifest", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mod")
+ await fs.mkdir(path.join(mod, "themes"), { recursive: true })
+ await Bun.write(
+ path.join(mod, "package.json"),
+ JSON.stringify(
+ {
+ name: "acme-plugin",
+ version: "1.0.0",
+ "oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"],
+ },
+ null,
+ 2,
+ ),
+ )
+
+ return { mod }
+ },
+ })
+
+ const file = path.join(tmp.extra.mod, "package.json")
+ const json = await Filesystem.readJson<Record<string, unknown>>(file)
+ const list = readPackageThemes("acme-plugin", {
+ dir: tmp.extra.mod,
+ pkg: file,
+ json,
+ })
+
+ expect(list).toEqual([
+ Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
+ Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
+ ])
+ })
+
+ test("handles no-entrypoint tui packages via missing callback", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "acme-plugin")
+ await fs.mkdir(path.join(mod, "themes"), { recursive: true })
+ await Bun.write(
+ path.join(mod, "package.json"),
+ JSON.stringify(
+ {
+ name: "acme-plugin",
+ version: "1.0.0",
+ "oc-themes": ["themes/night.json"],
+ },
+ null,
+ 2,
+ ),
+ )
+ await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
+ return { mod }
+ },
+ })
+
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const missing: string[] = []
+
+ try {
+ const loaded = await PluginLoader.loadExternal({
+ items: [
+ {
+ scope: "local" as const,
+ source: tmp.path,
+ },
+ ],
+ kind: "tui",
+ missing: async (item) => {
+ if (!item.pkg) return
+ const themes = readPackageThemes(item.spec, item.pkg)
+ if (!themes.length) return
+ return {
+ spec: item.spec,
+ target: item.target,
+ themes,
+ }
+ },
+ report: {
+ missing(_candidate, _retry, message) {
+ missing.push(message)
+ },
+ },
+ })
+
+ expect(loaded).toEqual([
+ {
+ target: tmp.extra.mod,
+ themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
+ },
+ ])
+ expect(missing).toHaveLength(0)
+ } finally {
+ install.mockRestore()
+ }
+ })
+
+ test("passes package metadata for entrypoint tui plugins", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mods", "acme-plugin")
+ await fs.mkdir(path.join(mod, "themes"), { recursive: true })
+ await Bun.write(
+ path.join(mod, "package.json"),
+ JSON.stringify(
+ {
+ name: "acme-plugin",
+ version: "1.0.0",
+ exports: {
+ "./tui": "./tui.js",
+ },
+ "oc-themes": ["themes/night.json"],
+ },
+ null,
+ 2,
+ ),
+ )
+ await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n')
+ await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
+ return { mod }
+ },
+ })
+
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+
+ try {
+ const loaded = await PluginLoader.loadExternal({
+ items: [
+ {
+ scope: "local" as const,
+ source: tmp.path,
+ },
+ ],
+ kind: "tui",
+ finish: async (item) => {
+ if (!item.pkg) return
+ return {
+ spec: item.spec,
+ themes: readPackageThemes(item.spec, item.pkg),
+ }
+ },
+ })
+
+ expect(loaded).toEqual([
+ {
+ themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
+ },
+ ])
+ } finally {
+ install.mockRestore()
+ }
+ })
+
+ test("rejects oc-themes path traversal", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const mod = path.join(dir, "mod")
+ await fs.mkdir(mod, { recursive: true })
+ const file = path.join(mod, "package.json")
+ await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
+ return { mod, file }
+ },
+ })
+
+ const json = await Filesystem.readJson<Record<string, unknown>>(tmp.extra.file)
+ expect(() =>
+ readPackageThemes("acme", {
+ dir: tmp.extra.mod,
+ pkg: tmp.extra.file,
+ json,
+ }),
+ ).toThrow("outside plugin directory")
+ })
+
+ test("retries failed file plugins once after wait and keeps order", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const a = path.join(dir, "a")
+ const b = path.join(dir, "b")
+ const aSpec = pathToFileURL(a).href
+ const bSpec = pathToFileURL(b).href
+ await fs.mkdir(a, { recursive: true })
+ await fs.mkdir(b, { recursive: true })
+ return { a, b, aSpec, bSpec }
+ },
+ })
+
+ let wait = 0
+ const calls: Array<[string, boolean]> = []
+
+ const loaded = await PluginLoader.loadExternal({
+ items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
+ spec,
+ scope: "local" as const,
+ source: tmp.path,
+ })),
+ kind: "tui",
+ wait: async () => {
+ wait += 1
+ await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n")
+ await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n")
+ },
+ report: {
+ start(candidate, retry) {
+ calls.push([candidate.plan.spec, retry])
+ },
+ },
+ })
+
+ expect(wait).toBe(1)
+ expect(calls).toEqual([
+ [tmp.extra.aSpec, false],
+ [tmp.extra.bSpec, false],
+ [tmp.extra.aSpec, true],
+ [tmp.extra.bSpec, true],
+ ])
+ expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
+ })
+
+ test("retries file plugins when finish returns undefined", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const file = path.join(dir, "plugin.ts")
+ const spec = pathToFileURL(file).href
+ await Bun.write(file, "export default {}\n")
+ return { spec }
+ },
+ })
+
+ let wait = 0
+ let count = 0
+
+ const loaded = await PluginLoader.loadExternal({
+ items: [
+ {
+ spec: tmp.extra.spec,
+ scope: "local" as const,
+ source: tmp.path,
+ },
+ ],
+ kind: "tui",
+ wait: async () => {
+ wait += 1
+ },
+ finish: async (load, _item, retry) => {
+ count += 1
+ if (!retry) return
+ return {
+ retry,
+ spec: load.spec,
+ }
+ },
+ })
+
+ expect(wait).toBe(1)
+ expect(count).toBe(2)
+ expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }])
+ })
+
+ test("does not wait or retry npm plugin failures", async () => {
+ const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
+ let wait = 0
+ const errors: Array<[string, boolean]> = []
+
+ try {
+ const loaded = await PluginLoader.loadExternal({
+ items: [
+ {
+ scope: "local" as const,
+ source: "test",
+ },
+ ],
+ kind: "tui",
+ wait: async () => {
+ wait += 1
+ },
+ report: {
+ error(_candidate, retry, stage) {
+ errors.push([stage, retry])
+ },
+ },
+ })
+
+ expect(loaded).toEqual([])
+ expect(wait).toBe(0)
+ expect(errors).toEqual([["install", false]])
+ } finally {
+ install.mockRestore()
+ }
+ })
})
diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts
index e6ace9c72..3abcf011b 100644
--- a/packages/opencode/test/util/filesystem.test.ts
+++ b/packages/opencode/test/util/filesystem.test.ts
@@ -83,6 +83,95 @@ describe("filesystem", () => {
})
})
+ describe("findUp()", () => {
+ test("keeps previous nearest-first behavior for single target", async () => {
+ await using tmp = await tmpdir()
+ const parent = path.join(tmp.path, "parent")
+ const child = path.join(parent, "child")
+ await fs.mkdir(child, { recursive: true })
+ await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8")
+ await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8")
+
+ const result = await Filesystem.findUp("marker", child, tmp.path)
+
+ expect(result).toEqual([path.join(parent, "marker"), path.join(tmp.path, "marker")])
+ })
+
+ test("respects stop boundary", async () => {
+ await using tmp = await tmpdir()
+ const parent = path.join(tmp.path, "parent")
+ const child = path.join(parent, "child")
+ await fs.mkdir(child, { recursive: true })
+ await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8")
+ await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8")
+
+ const result = await Filesystem.findUp("marker", child, parent)
+
+ expect(result).toEqual([path.join(parent, "marker")])
+ })
+
+ test("supports multiple targets with nearest-first default ordering", async () => {
+ await using tmp = await tmpdir()
+ const parent = path.join(tmp.path, "parent")
+ const child = path.join(parent, "child")
+ await fs.mkdir(child, { recursive: true })
+
+ await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8")
+ await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8")
+ await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8")
+
+ const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path)
+
+ expect(result).toEqual([
+ path.join(parent, "cfg.jsonc"),
+ path.join(tmp.path, "cfg.json"),
+ path.join(tmp.path, "cfg.jsonc"),
+ ])
+ })
+
+ test("supports rootFirst ordering for multiple targets", async () => {
+ await using tmp = await tmpdir()
+ const parent = path.join(tmp.path, "parent")
+ const child = path.join(parent, "child")
+ await fs.mkdir(child, { recursive: true })
+
+ await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8")
+ await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8")
+ await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8")
+
+ const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path, { rootFirst: true })
+
+ expect(result).toEqual([
+ path.join(tmp.path, "cfg.json"),
+ path.join(tmp.path, "cfg.jsonc"),
+ path.join(parent, "cfg.jsonc"),
+ ])
+ })
+
+ test("rootFirst preserves json then jsonc order per directory", async () => {
+ await using tmp = await tmpdir()
+ const project = path.join(tmp.path, "project")
+ const nested = path.join(project, "nested")
+ await fs.mkdir(nested, { recursive: true })
+
+ await fs.writeFile(path.join(tmp.path, "opencode.json"), "{}", "utf-8")
+ await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{}", "utf-8")
+ await fs.writeFile(path.join(project, "opencode.json"), "{}", "utf-8")
+ await fs.writeFile(path.join(project, "opencode.jsonc"), "{}", "utf-8")
+
+ const result = await Filesystem.findUp(["opencode.json", "opencode.jsonc"], nested, tmp.path, {
+ rootFirst: true,
+ })
+
+ expect(result).toEqual([
+ path.join(tmp.path, "opencode.json"),
+ path.join(tmp.path, "opencode.jsonc"),
+ path.join(project, "opencode.json"),
+ path.join(project, "opencode.jsonc"),
+ ])
+ })
+ })
+
describe("readText()", () => {
test("reads file content", async () => {
await using tmp = await tmpdir()