summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJeon Suyeol <[email protected]>2026-01-09 17:11:24 +0900
committerGitHub <[email protected]>2026-01-09 02:11:24 -0600
commit8e3ab4afa7687abf7cbf937b680a36352ca9421e (patch)
treee5871d90ba259e10b78ae398f61587738c9ae6da
parent13305966e5ce259a2379fa88d0d124c159ad9d40 (diff)
downloadopencode-8e3ab4afa7687abf7cbf937b680a36352ca9421e.tar.gz
opencode-8e3ab4afa7687abf7cbf937b680a36352ca9421e.zip
feat(config): deduplicate plugins by name with priority-based resolution (#5957)
-rw-r--r--packages/opencode/src/config/config.ts54
-rw-r--r--packages/opencode/test/config/config.test.ts90
2 files changed, 143 insertions, 1 deletions
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index be2349484..ead3a0149 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("[email protected]") // "oh-my-opencode"
+ * getPluginName("@scope/[email protected]") // "@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<string>()
+
+ // uniqueSpecifiers: full plugin specifiers to return
+ // e.g., "[email protected]", "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 b52f3ef7f..087eb0c62 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("[email protected]")).toBe("oh-my-opencode")
+ expect(Config.getPluginName("[email protected]")).toBe("some-plugin")
+ expect(Config.getPluginName("plugin@latest")).toBe("plugin")
+ })
+
+ test("extracts name from scoped npm package", () => {
+ expect(Config.getPluginName("@scope/[email protected]")).toBe("@scope/pkg")
+ expect(Config.getPluginName("@opencode/[email protected]")).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 result = Config.deduplicatePlugins(plugins)
+
+ expect(result).toContain("[email protected]")
+ expect(result).toContain("[email protected]")
+ expect(result).toContain("[email protected]")
+ expect(result).not.toContain("[email protected]")
+ expect(result.length).toBe(3)
+ })
+
+ test("prefers local file over npm package with same name", () => {
+ const plugins = ["[email protected]", "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 result = Config.deduplicatePlugins(plugins)
+
+ expect(result).toEqual(["[email protected]", "[email protected]", "[email protected]"])
+ })
+
+ 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: ["[email protected]"],
+ }),
+ )
+
+ 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)
+ },
+ })
+ })
+})