Skip to content

Commit 8e3ab4a

Browse files
authored
feat(config): deduplicate plugins by name with priority-based resolution (#5957)
1 parent 1330596 commit 8e3ab4a

File tree

2 files changed

+143
-1
lines changed

2 files changed

+143
-1
lines changed

packages/opencode/src/config/config.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ export namespace Config {
178178
result.compaction = { ...result.compaction, prune: false }
179179
}
180180

181+
result.plugin = deduplicatePlugins(result.plugin ?? [])
182+
181183
return {
182184
config: result,
183185
directories,
@@ -332,6 +334,58 @@ export namespace Config {
332334
return plugins
333335
}
334336

337+
/**
338+
* Extracts a canonical plugin name from a plugin specifier.
339+
* - For file:// URLs: extracts filename without extension
340+
* - For npm packages: extracts package name without version
341+
*
342+
* @example
343+
* getPluginName("file:///path/to/plugin/foo.js") // "foo"
344+
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
345+
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
346+
*/
347+
export function getPluginName(plugin: string): string {
348+
if (plugin.startsWith("file://")) {
349+
return path.parse(new URL(plugin).pathname).name
350+
}
351+
const lastAt = plugin.lastIndexOf("@")
352+
if (lastAt > 0) {
353+
return plugin.substring(0, lastAt)
354+
}
355+
return plugin
356+
}
357+
358+
/**
359+
* Deduplicates plugins by name, with later entries (higher priority) winning.
360+
* Priority order (highest to lowest):
361+
* 1. Local plugin/ directory
362+
* 2. Local opencode.json
363+
* 3. Global plugin/ directory
364+
* 4. Global opencode.json
365+
*
366+
* Since plugins are added in low-to-high priority order,
367+
* we reverse, deduplicate (keeping first occurrence), then restore order.
368+
*/
369+
export function deduplicatePlugins(plugins: string[]): string[] {
370+
// seenNames: canonical plugin names for duplicate detection
371+
// e.g., "oh-my-opencode", "@scope/pkg"
372+
const seenNames = new Set<string>()
373+
374+
// uniqueSpecifiers: full plugin specifiers to return
375+
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
376+
const uniqueSpecifiers: string[] = []
377+
378+
for (const specifier of plugins.toReversed()) {
379+
const name = getPluginName(specifier)
380+
if (!seenNames.has(name)) {
381+
seenNames.add(name)
382+
uniqueSpecifiers.push(specifier)
383+
}
384+
}
385+
386+
return uniqueSpecifiers.toReversed()
387+
}
388+
335389
export const McpLocal = z
336390
.object({
337391
type: z.literal("local").describe("Type of MCP server connection"),

packages/opencode/test/config/config.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect, mock, afterEach } from "bun:test"
1+
import { test, expect, describe, mock } from "bun:test"
22
import { Config } from "../../src/config/config"
33
import { Instance } from "../../src/project/instance"
44
import { Auth } from "../../src/auth"
@@ -1145,3 +1145,91 @@ test("project config overrides remote well-known config", async () => {
11451145
Auth.all = originalAuthAll
11461146
}
11471147
})
1148+
1149+
describe("getPluginName", () => {
1150+
test("extracts name from file:// URL", () => {
1151+
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
1152+
expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
1153+
expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
1154+
})
1155+
1156+
test("extracts name from npm package with version", () => {
1157+
expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode")
1158+
expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin")
1159+
expect(Config.getPluginName("plugin@latest")).toBe("plugin")
1160+
})
1161+
1162+
test("extracts name from scoped npm package", () => {
1163+
expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg")
1164+
expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin")
1165+
})
1166+
1167+
test("returns full string for package without version", () => {
1168+
expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
1169+
expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
1170+
})
1171+
})
1172+
1173+
describe("deduplicatePlugins", () => {
1174+
test("removes duplicates keeping higher priority (later entries)", () => {
1175+
const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"]
1176+
1177+
const result = Config.deduplicatePlugins(plugins)
1178+
1179+
expect(result).toContain("global-plugin@1.0.0")
1180+
expect(result).toContain("local-plugin@2.0.0")
1181+
expect(result).toContain("shared-plugin@2.0.0")
1182+
expect(result).not.toContain("shared-plugin@1.0.0")
1183+
expect(result.length).toBe(3)
1184+
})
1185+
1186+
test("prefers local file over npm package with same name", () => {
1187+
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
1188+
1189+
const result = Config.deduplicatePlugins(plugins)
1190+
1191+
expect(result.length).toBe(1)
1192+
expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
1193+
})
1194+
1195+
test("preserves order of remaining plugins", () => {
1196+
const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
1197+
1198+
const result = Config.deduplicatePlugins(plugins)
1199+
1200+
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
1201+
})
1202+
1203+
test("local plugin directory overrides global opencode.json plugin", async () => {
1204+
await using tmp = await tmpdir({
1205+
init: async (dir) => {
1206+
const projectDir = path.join(dir, "project")
1207+
const opencodeDir = path.join(projectDir, ".opencode")
1208+
const pluginDir = path.join(opencodeDir, "plugin")
1209+
await fs.mkdir(pluginDir, { recursive: true })
1210+
1211+
await Bun.write(
1212+
path.join(dir, "opencode.json"),
1213+
JSON.stringify({
1214+
$schema: "https://opencode.ai/config.json",
1215+
plugin: ["my-plugin@1.0.0"],
1216+
}),
1217+
)
1218+
1219+
await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
1220+
},
1221+
})
1222+
1223+
await Instance.provide({
1224+
directory: path.join(tmp.path, "project"),
1225+
fn: async () => {
1226+
const config = await Config.get()
1227+
const plugins = config.plugin ?? []
1228+
1229+
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
1230+
expect(myPlugins.length).toBe(1)
1231+
expect(myPlugins[0].startsWith("file://")).toBe(true)
1232+
},
1233+
})
1234+
})
1235+
})

0 commit comments

Comments
 (0)