diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 1c756f805574..31c4f4cea0b0 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -29,7 +29,7 @@ import { useData } from "../context/data" import { errorMessage } from "../util/error" import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api" import { createSourceWatcher } from "./watch" -import { discoverPluginTargets, localSource } from "./discovery" +import { discoverPluginTargets, localSource, mergePluginTargets } from "./discovery" import { createPluginSources } from "./source" import { isMissingPath } from "../util/config-directories" import { createMarkdownRenderer } from "./markdown" @@ -285,23 +285,28 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir await trace("watch", { reconciliation: id, directories: props.directories }, () => Promise.all(props.directories.map(watcher.wait)).then(() => undefined), ) - const entries = [ - ...( - await trace("discover", { reconciliation: id, directories: props.directories }, () => - discoverPluginTargets(props.directories), - ) - ).map((entry) => ({ - entry, - install: true, - optional: true, - })), - ...serverTuiPlugins().map((plugin) => ({ - entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path), - install: false, - optional: true, - })), - ...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, optional: false })), - ] + // Discovery admits TUI-only plugins while server inventory carries combined plugins. + // Their overlap is intentional; explicit configuration remains the final authority. + const entries = mergePluginTargets( + [ + ...( + await trace("discover", { reconciliation: id, directories: props.directories }, () => + discoverPluginTargets(props.directories), + ) + ).map((entry) => ({ + entry, + install: true, + optional: true, + })), + ...serverTuiPlugins().map((plugin) => ({ + entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path), + install: false, + optional: true, + })), + ...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, optional: false })), + ], + directory, + ) // Resolve: fold entries into one desired generation. A source that fails // to import keeps its running previous version and only reports failure. diff --git a/packages/tui/src/plugin/discovery.ts b/packages/tui/src/plugin/discovery.ts index 6fd42bc208c3..9116a3e556a1 100644 --- a/packages/tui/src/plugin/discovery.ts +++ b/packages/tui/src/plugin/discovery.ts @@ -1,8 +1,10 @@ import { readdir, stat } from "node:fs/promises" import path from "node:path" -export { localSource } from "@opencode/plugin/source" +import { localSource } from "@opencode/plugin/source" import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories" +export { localSource } + export async function localPluginDirectories(cwd: string, configDirectory: string) { const projectDirectory = await localProjectDirectory(cwd) const projectConfig = path.join(projectDirectory, ".opencode") @@ -49,3 +51,17 @@ export async function discoverPluginTargets(directories: string[]) { ) ).flat() } + +export function mergePluginTargets( + targets: readonly Target[], + directory: string, +) { + return [ + ...targets + .reduce((result, target) => { + const value = typeof target.entry === "string" ? target.entry : target.entry.package + return result.set(localSource(value, directory)?.href ?? value, target) + }, new Map()) + .values(), + ] +} diff --git a/packages/tui/test/plugin-discovery.test.ts b/packages/tui/test/plugin-discovery.test.ts new file mode 100644 index 000000000000..709fe1d3106b --- /dev/null +++ b/packages/tui/test/plugin-discovery.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { mergePluginTargets } from "../src/plugin/discovery" + +test("deduplicates equivalent local plugin targets while retaining the final source", () => { + const directory = "/project" + const discovered = { entry: "/project/.opencode/plugins/example", install: true, optional: true } + const server = { entry: "./.opencode/plugins/example", install: false, optional: true } + + expect(mergePluginTargets([discovered, server], directory)).toEqual([server]) +}) + +test("preserves the first target position when a later source overrides it", () => { + const first = { entry: "example", install: true, optional: true } + const other = { entry: "other", install: true, optional: true } + const configured = { entry: { package: "example", options: { enabled: true } }, install: true, optional: false } + + expect(mergePluginTargets([first, other, configured], "/project")).toEqual([configured, other]) +})