diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 27ba4e186712..0194ddf1cf86 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -510,17 +510,31 @@ export namespace Config { /** * Extracts a canonical plugin name from a plugin specifier. - * - For file:// URLs: extracts filename without extension + * - For file:// URLs pointing into node_modules: extracts npm package name + * - For other file:// URLs: extracts filename without extension * - For npm packages: extracts package name without version * * @example + * getPluginName("file:///path/to/node_modules/oh-my-opencode/dist/index.js") // "oh-my-opencode" + * getPluginName("file:///path/to/node_modules/@scope/pkg/index.js") // "@scope/pkg" * 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 parsed = new URL(plugin).pathname + const nm = parsed.lastIndexOf("/node_modules/") + if (nm !== -1) { + const after = parsed.substring(nm + "/node_modules/".length) + // scoped packages: @scope/pkg/dist/index.js → @scope/pkg + if (after.startsWith("@")) { + const parts = after.split("/") + return parts.slice(0, 2).join("/") + } + return after.split("/")[0] + } + return path.parse(parsed).name } const lastAt = plugin.lastIndexOf("@") if (lastAt > 0) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index baf209d86079..611a215a4887 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1734,6 +1734,19 @@ describe("getPluginName", () => { expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin") }) + test("extracts package name from file:// URL inside node_modules", () => { + expect(Config.getPluginName("file:///home/user/.config/opencode/node_modules/oh-my-opencode/dist/index.js")).toBe( + "oh-my-opencode", + ) + expect( + Config.getPluginName("file:///home/user/.config/opencode/node_modules/opencode-openai-codex-auth/index.ts"), + ).toBe("opencode-openai-codex-auth") + expect(Config.getPluginName("file:///project/node_modules/@scope/plugin/dist/index.js")).toBe("@scope/plugin") + expect(Config.getPluginName("file:///project/node_modules/@different-ai/opencode-browser/index.js")).toBe( + "@different-ai/opencode-browser", + ) + }) + 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") @@ -1781,6 +1794,18 @@ describe("deduplicatePlugins", () => { expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]) }) + test("does not treat resolved node_modules index.js files as duplicates", () => { + const plugins = [ + "file:///config/node_modules/oh-my-opencode/dist/index.js", + "file:///config/node_modules/opencode-openai-codex-auth/index.ts", + "file:///config/node_modules/@scope/plugin/dist/index.js", + ] + + const result = Config.deduplicatePlugins(plugins) + + expect(result.length).toBe(3) + }) + test("local plugin directory overrides global opencode.json plugin", async () => { await using tmp = await tmpdir({ init: async (dir) => {