diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index be234948424..ead3a0149b4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -178,6 +178,8 @@ export namespace Config { result.compaction = { ...result.compaction, prune: false } } + result.plugin = deduplicatePlugins(result.plugin ?? []) + return { config: result, directories, @@ -332,6 +334,58 @@ export namespace Config { return plugins } + /** + * Extracts a canonical plugin name from a plugin specifier. + * - For file:// URLs: extracts filename without extension + * - For npm packages: extracts package name without version + * + * @example + * getPluginName("file:///path/to/plugin/foo.js") // "foo" + * getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode" + * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" + */ + export function getPluginName(plugin: string): string { + if (plugin.startsWith("file://")) { + return path.parse(new URL(plugin).pathname).name + } + const lastAt = plugin.lastIndexOf("@") + if (lastAt > 0) { + return plugin.substring(0, lastAt) + } + return plugin + } + + /** + * 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: string[]): string[] { + // seenNames: canonical plugin names for duplicate detection + // e.g., "oh-my-opencode", "@scope/pkg" + const seenNames = new Set() + + // uniqueSpecifiers: full plugin specifiers to return + // e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js" + const uniqueSpecifiers: string[] = [] + + for (const specifier of plugins.toReversed()) { + const name = getPluginName(specifier) + if (!seenNames.has(name)) { + seenNames.add(name) + uniqueSpecifiers.push(specifier) + } + } + + return uniqueSpecifiers.toReversed() + } + export const McpLocal = z .object({ type: z.literal("local").describe("Type of MCP server connection"), diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index b52f3ef7f77..087eb0c628c 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,4 +1,4 @@ -import { test, expect, mock, afterEach } from "bun:test" +import { test, expect, describe, mock } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" @@ -1145,3 +1145,91 @@ test("project config overrides remote well-known config", async () => { Auth.all = originalAuthAll } }) + +describe("getPluginName", () => { + test("extracts name from file:// URL", () => { + expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo") + expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar") + expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin") + }) + + test("extracts name from npm package with version", () => { + expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode") + expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin") + expect(Config.getPluginName("plugin@latest")).toBe("plugin") + }) + + test("extracts name from scoped npm package", () => { + expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg") + expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin") + }) + + test("returns full string for package without version", () => { + expect(Config.getPluginName("some-plugin")).toBe("some-plugin") + expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg") + }) +}) + +describe("deduplicatePlugins", () => { + test("removes duplicates keeping higher priority (later entries)", () => { + const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"] + + const result = Config.deduplicatePlugins(plugins) + + expect(result).toContain("global-plugin@1.0.0") + expect(result).toContain("local-plugin@2.0.0") + expect(result).toContain("shared-plugin@2.0.0") + expect(result).not.toContain("shared-plugin@1.0.0") + expect(result.length).toBe(3) + }) + + test("prefers local file over npm package with same name", () => { + const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"] + + const result = Config.deduplicatePlugins(plugins) + + expect(result.length).toBe(1) + expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js") + }) + + test("preserves order of remaining plugins", () => { + const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"] + + const result = Config.deduplicatePlugins(plugins) + + expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]) + }) + + test("local plugin directory overrides global opencode.json plugin", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const projectDir = path.join(dir, "project") + const opencodeDir = path.join(projectDir, ".opencode") + const pluginDir = path.join(opencodeDir, "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: ["my-plugin@1.0.0"], + }), + ) + + await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}") + }, + }) + + await Instance.provide({ + directory: path.join(tmp.path, "project"), + fn: async () => { + const config = await Config.get() + const plugins = config.plugin ?? [] + + const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin") + expect(myPlugins.length).toBe(1) + expect(myPlugins[0].startsWith("file://")).toBe(true) + }, + }) + }) +})