From c14a2ec3badb332db7ad12b4a17e66218b165cf3 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Thu, 15 Jan 2026 16:22:50 -0700 Subject: [PATCH 1/6] fix: use full URL for file:// plugin deduplication Previously, file:// plugins were deduplicated by filename only, causing issues when multiple plugins used standard entry points like index.js. Example problem: file:///path/to/plugin-a/dist/index.js -> 'index' file:///path/to/plugin-b/dist/index.js -> 'index' Result: Only last plugin loaded (seen as duplicates) This change uses the full URL as the identity for file:// plugins, allowing multiple plugins in the same or different directories with the same filename. Fixes deduplication logic to support: - Multiple plugins in same directory (github-tools.js, db-tools.js) - Multiple plugins with standard entry points (all named index.js) - Same file:// URL appearing twice still deduplicates correctly npm packages continue to deduplicate by package name as expected. --- packages/opencode/src/config/config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f77fb854bed..f8865cda731 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -331,17 +331,17 @@ export namespace Config { /** * Extracts a canonical plugin name from a plugin specifier. - * - For file:// URLs: extracts filename without extension + * - For file:// URLs: uses full URL as identity (to support multiple plugins in same directory) * - For npm packages: extracts package name without version * * @example - * getPluginName("file:///path/to/plugin/foo.js") // "foo" + * getPluginName("file:///path/to/plugin/foo.js") // "file:///path/to/plugin/foo.js" * 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 + return plugin } const lastAt = plugin.lastIndexOf("@") if (lastAt > 0) { From 7cbd1f8d12a5196be269d5b0f33c46684afd4349 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Fri, 16 Jan 2026 08:33:26 -0700 Subject: [PATCH 2/6] fix: extract parent directory name for generic file:// plugin filenames Fixes plugin deduplication issue where multiple file:// plugins with generic filenames (index.js, main.js) would collide. Now extracts the parent directory name when filename is generic (index, main, plugin, dist, build, out, lib), allowing both proper deduplication of npm vs file:// versions of the same plugin AND multiple generic-named plugins in different directories. Examples: - file:///.../plugin-a/dist/index.js -> 'plugin-a' - file:///.../plugin-b/dist/index.js -> 'plugin-b' - file:///.../oh-my-opencode/dist/index.js -> 'oh-my-opencode' - oh-my-opencode@2.4.3 -> 'oh-my-opencode' (deduplicates correctly) Addresses reviewer concern about oh-my-opencode npm + file:// loading twice during local development. --- packages/opencode/src/config/config.ts | 25 ++++++++++++++++++-- packages/opencode/test/config/config.test.ts | 22 +++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f8865cda731..edd812d6934 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -331,16 +331,37 @@ export namespace Config { /** * Extracts a canonical plugin name from a plugin specifier. - * - For file:// URLs: uses full URL as identity (to support multiple plugins in same directory) + * - For file:// URLs: extracts filename, or parent directory if filename is generic (index, main, plugin) * - For npm packages: extracts package name without version * * @example - * getPluginName("file:///path/to/plugin/foo.js") // "file:///path/to/plugin/foo.js" + * getPluginName("file:///path/to/plugin-a/index.js") // "plugin-a" + * getPluginName("file:///path/to/plugin-b/dist/index.js") // "plugin-b" + * getPluginName("file:///path/to/oh-my-opencode.js") // "oh-my-opencode" * 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://")) { + const pathname = new URL(plugin).pathname + const parts = pathname.split("/").filter(Boolean) + const filename = path.parse(pathname).name + + // Generic names that don't uniquely identify a plugin + const genericNames = new Set(["index", "main", "plugin", "dist", "build", "out", "lib"]) + + if (!genericNames.has(filename)) { + return filename + } + + // Filename is generic, walk up directories to find meaningful name + for (let i = parts.length - 2; i >= 0; i--) { + const dirName = parts[i] + if (!genericNames.has(dirName) && dirName !== ".opencode") { + return dirName + } + } + return plugin } const lastAt = plugin.lastIndexOf("@") diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 86cadca5d81..a6ae4655299 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1294,6 +1294,13 @@ describe("getPluginName", () => { expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin") }) + test("extracts parent directory name for generic filenames", () => { + expect(Config.getPluginName("file:///path/to/plugin-a/index.js")).toBe("plugin-a") + expect(Config.getPluginName("file:///path/to/plugin-b/dist/index.js")).toBe("plugin-b") + expect(Config.getPluginName("file:///path/to/oh-my-opencode/dist/index.js")).toBe("oh-my-opencode") + expect(Config.getPluginName("file:///path/to/my-plugin/build/main.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") @@ -1341,6 +1348,21 @@ describe("deduplicatePlugins", () => { expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]) }) + test("allows multiple plugins with same generic filename in different directories", () => { + const plugins = [ + "file:///path/to/plugin-a/dist/index.js", + "file:///path/to/plugin-b/dist/index.js", + "file:///path/to/plugin-c/index.js", + ] + + const result = Config.deduplicatePlugins(plugins) + + expect(result.length).toBe(3) + expect(result).toContain("file:///path/to/plugin-a/dist/index.js") + expect(result).toContain("file:///path/to/plugin-b/dist/index.js") + expect(result).toContain("file:///path/to/plugin-c/index.js") + }) + test("local plugin directory overrides global opencode.json plugin", async () => { await using tmp = await tmpdir({ init: async (dir) => { From 1624878a6f90cbd6147a82e1a7b4ee211d197e5d Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Fri, 16 Jan 2026 08:43:23 -0700 Subject: [PATCH 3/6] refactor: use package.json for file:// plugin name resolution Primary method now looks for package.json in parent directories (up to 5 levels) and extracts the 'name' field. Falls back to the heuristic approach (filename or parent directory) when package.json is not found. This is more robust because: - Uses canonical package name from package.json - Handles scoped packages correctly (@scope/name) - Self-maintaining (no hardcoded list to update) - Works for real npm package development workflows Added tests for: - Extracting name from package.json - Preferring package.json name over directory name --- packages/opencode/src/config/config.ts | 40 ++++++++++++++++---- packages/opencode/test/config/config.test.ts | 36 +++++++++++++++++- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index edd812d6934..61c06f3e961 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -18,7 +18,7 @@ import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" -import { existsSync } from "fs" +import { existsSync, readFileSync } from "fs" export namespace Config { const log = Log.create({ service: "config" }) @@ -329,32 +329,56 @@ export namespace Config { return plugins } + function findPackageJsonName(filePath: string): string | undefined { + let dir = path.dirname(filePath) + const root = path.parse(dir).root + + for (let i = 0; i < 5 && dir !== root; i++) { + const pkgPath = path.join(dir, "package.json") + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) + if (pkg.name && typeof pkg.name === "string") { + return pkg.name + } + } catch { + // Invalid JSON, continue searching up + } + } + dir = path.dirname(dir) + } + + return undefined + } + /** * Extracts a canonical plugin name from a plugin specifier. - * - For file:// URLs: extracts filename, or parent directory if filename is generic (index, main, plugin) + * - For file:// URLs: looks for package.json name, falls back to filename/parent directory * - For npm packages: extracts package name without version * * @example - * getPluginName("file:///path/to/plugin-a/index.js") // "plugin-a" - * getPluginName("file:///path/to/plugin-b/dist/index.js") // "plugin-b" - * getPluginName("file:///path/to/oh-my-opencode.js") // "oh-my-opencode" + * getPluginName("file:///path/to/oh-my-opencode/dist/index.js") // "oh-my-opencode" (from package.json) + * getPluginName("file:///path/to/plugin/foo.js") // "foo" (no package.json, uses filename) * 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://")) { const pathname = new URL(plugin).pathname + + const packageName = findPackageJsonName(pathname) + if (packageName) { + return packageName + } + const parts = pathname.split("/").filter(Boolean) const filename = path.parse(pathname).name - - // Generic names that don't uniquely identify a plugin const genericNames = new Set(["index", "main", "plugin", "dist", "build", "out", "lib"]) if (!genericNames.has(filename)) { return filename } - // Filename is generic, walk up directories to find meaningful name for (let i = parts.length - 2; i >= 0; i--) { const dirName = parts[i] if (!genericNames.has(dirName) && dirName !== ".opencode") { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index a6ae4655299..f46ec38aadd 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1294,13 +1294,47 @@ describe("getPluginName", () => { expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin") }) - test("extracts parent directory name for generic filenames", () => { + test("extracts parent directory name for generic filenames (fallback when no package.json)", () => { expect(Config.getPluginName("file:///path/to/plugin-a/index.js")).toBe("plugin-a") expect(Config.getPluginName("file:///path/to/plugin-b/dist/index.js")).toBe("plugin-b") expect(Config.getPluginName("file:///path/to/oh-my-opencode/dist/index.js")).toBe("oh-my-opencode") expect(Config.getPluginName("file:///path/to/my-plugin/build/main.js")).toBe("my-plugin") }) + test("extracts name from package.json when present", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, "my-awesome-plugin", "dist") + await fs.mkdir(pluginDir, { recursive: true }) + await Bun.write( + path.join(dir, "my-awesome-plugin", "package.json"), + JSON.stringify({ name: "my-awesome-plugin", version: "1.0.0" }), + ) + await Bun.write(path.join(pluginDir, "index.js"), "export default {}") + }, + }) + + const pluginPath = `file://${path.join(tmp.path, "my-awesome-plugin", "dist", "index.js")}` + expect(Config.getPluginName(pluginPath)).toBe("my-awesome-plugin") + }) + + test("prefers package.json name over directory name", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, "wrong-name", "dist") + await fs.mkdir(pluginDir, { recursive: true }) + await Bun.write( + path.join(dir, "wrong-name", "package.json"), + JSON.stringify({ name: "@scope/correct-name", version: "1.0.0" }), + ) + await Bun.write(path.join(pluginDir, "index.js"), "export default {}") + }, + }) + + const pluginPath = `file://${path.join(tmp.path, "wrong-name", "dist", "index.js")}` + expect(Config.getPluginName(pluginPath)).toBe("@scope/correct-name") + }) + 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") From 76e34de3a3bb2209a2d2daf480a659be0819f65e Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Fri, 16 Jan 2026 08:58:06 -0700 Subject: [PATCH 4/6] simplify: use fileURLToPath for cross-OS support, remove package.json lookup - Use fileURLToPath() instead of URL.pathname for proper Windows support - Use path.sep for platform-specific path separators - Remove package.json lookup (overcomplicated per maintainer feedback) - Keep simple heuristic: skip generic names (index, dist, etc.) to find meaningful parent dir --- packages/opencode/src/config/config.ts | 46 +++++--------------- packages/opencode/test/config/config.test.ts | 36 +-------------- 2 files changed, 11 insertions(+), 71 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 61c06f3e961..c5befbf8598 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,6 +1,6 @@ import { Log } from "../util/log" import path from "path" -import { pathToFileURL } from "url" +import { fileURLToPath, pathToFileURL } from "url" import os from "os" import z from "zod" import { Filesystem } from "../util/filesystem" @@ -18,7 +18,7 @@ import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" -import { existsSync, readFileSync } from "fs" +import { existsSync } from "fs" export namespace Config { const log = Log.create({ service: "config" }) @@ -329,56 +329,30 @@ export namespace Config { return plugins } - function findPackageJsonName(filePath: string): string | undefined { - let dir = path.dirname(filePath) - const root = path.parse(dir).root - - for (let i = 0; i < 5 && dir !== root; i++) { - const pkgPath = path.join(dir, "package.json") - if (existsSync(pkgPath)) { - try { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) - if (pkg.name && typeof pkg.name === "string") { - return pkg.name - } - } catch { - // Invalid JSON, continue searching up - } - } - dir = path.dirname(dir) - } - - return undefined - } - /** * Extracts a canonical plugin name from a plugin specifier. - * - For file:// URLs: looks for package.json name, falls back to filename/parent directory + * - For file:// URLs: extracts filename, or walks up to find non-generic parent directory * - For npm packages: extracts package name without version * * @example - * getPluginName("file:///path/to/oh-my-opencode/dist/index.js") // "oh-my-opencode" (from package.json) - * getPluginName("file:///path/to/plugin/foo.js") // "foo" (no package.json, uses filename) + * getPluginName("file:///path/to/plugin/foo.js") // "foo" + * getPluginName("file:///path/to/plugin-a/index.js") // "plugin-a" + * getPluginName("file:///path/to/oh-my-opencode/dist/index.js") // "oh-my-opencode" * 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://")) { - const pathname = new URL(plugin).pathname - - const packageName = findPackageJsonName(pathname) - if (packageName) { - return packageName - } - - const parts = pathname.split("/").filter(Boolean) - const filename = path.parse(pathname).name + const filePath = fileURLToPath(plugin) + const parsed = path.parse(filePath) + const filename = parsed.name const genericNames = new Set(["index", "main", "plugin", "dist", "build", "out", "lib"]) if (!genericNames.has(filename)) { return filename } + const parts = filePath.split(path.sep).filter(Boolean) for (let i = parts.length - 2; i >= 0; i--) { const dirName = parts[i] if (!genericNames.has(dirName) && dirName !== ".opencode") { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f46ec38aadd..a6ae4655299 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1294,47 +1294,13 @@ describe("getPluginName", () => { expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin") }) - test("extracts parent directory name for generic filenames (fallback when no package.json)", () => { + test("extracts parent directory name for generic filenames", () => { expect(Config.getPluginName("file:///path/to/plugin-a/index.js")).toBe("plugin-a") expect(Config.getPluginName("file:///path/to/plugin-b/dist/index.js")).toBe("plugin-b") expect(Config.getPluginName("file:///path/to/oh-my-opencode/dist/index.js")).toBe("oh-my-opencode") expect(Config.getPluginName("file:///path/to/my-plugin/build/main.js")).toBe("my-plugin") }) - test("extracts name from package.json when present", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const pluginDir = path.join(dir, "my-awesome-plugin", "dist") - await fs.mkdir(pluginDir, { recursive: true }) - await Bun.write( - path.join(dir, "my-awesome-plugin", "package.json"), - JSON.stringify({ name: "my-awesome-plugin", version: "1.0.0" }), - ) - await Bun.write(path.join(pluginDir, "index.js"), "export default {}") - }, - }) - - const pluginPath = `file://${path.join(tmp.path, "my-awesome-plugin", "dist", "index.js")}` - expect(Config.getPluginName(pluginPath)).toBe("my-awesome-plugin") - }) - - test("prefers package.json name over directory name", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const pluginDir = path.join(dir, "wrong-name", "dist") - await fs.mkdir(pluginDir, { recursive: true }) - await Bun.write( - path.join(dir, "wrong-name", "package.json"), - JSON.stringify({ name: "@scope/correct-name", version: "1.0.0" }), - ) - await Bun.write(path.join(pluginDir, "index.js"), "export default {}") - }, - }) - - const pluginPath = `file://${path.join(tmp.path, "wrong-name", "dist", "index.js")}` - expect(Config.getPluginName(pluginPath)).toBe("@scope/correct-name") - }) - 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") From 262ed87155f06ee746fbd55e6d3014751d0fbcc1 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Fri, 16 Jan 2026 09:03:08 -0700 Subject: [PATCH 5/6] perf: skip import.meta.resolve for file:// URLs --- packages/opencode/src/config/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c5befbf8598..c0c5fae902c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1188,6 +1188,7 @@ export namespace Config { if (data.plugin) { for (let i = 0; i < data.plugin.length; i++) { const plugin = data.plugin[i] + if (plugin.startsWith("file://")) continue // Already absolute try { data.plugin[i] = import.meta.resolve!(plugin, configFilepath) } catch (err) {} From c48d726438852399e67def5dfef1c94242597381 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Fri, 16 Jan 2026 13:19:17 -0700 Subject: [PATCH 6/6] feat: restore package.json lookup for cross-platform file:// plugin names The package.json approach is superior to path heuristics because: 1. Cross-platform: Avoids path.sep issues that break on Windows 2. Canonical: Uses the actual package name, not guessed from paths 3. Deduplication: Fixes duplicate registration when same plugin loaded from different paths (e.g., global config + local repo) 4. Proper scoped package support: Handles @scope/name correctly Windows users report 40+ test failures with file:// plugins due to path.sep usage. Reading package.json solves this fundamental issue. Fallback to path heuristic when package.json not found. --- packages/opencode/src/config/config.ts | 45 +++++++++++++++++++++----- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c0c5fae902c..68c14018d18 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -18,7 +18,7 @@ import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" -import { existsSync } from "fs" +import { existsSync, readFileSync } from "fs" export namespace Config { const log = Log.create({ service: "config" }) @@ -329,21 +329,48 @@ export namespace Config { return plugins } + function findPackageJsonName(filePath: string): string | undefined { + let dir = path.dirname(filePath) + const root = path.parse(dir).root + + for (let i = 0; i < 5 && dir !== root; i++) { + const pkgPath = path.join(dir, "package.json") + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) + if (pkg.name && typeof pkg.name === "string") { + return pkg.name + } + } catch { + // Invalid JSON, continue searching up + } + } + dir = path.dirname(dir) + } + + return undefined + } + /** * Extracts a canonical plugin name from a plugin specifier. - * - For file:// URLs: extracts filename, or walks up to find non-generic parent directory + * - For file:// URLs: reads package.json name (cross-platform, canonical), falls back to path heuristic * - For npm packages: extracts package name without version * * @example - * getPluginName("file:///path/to/plugin/foo.js") // "foo" - * getPluginName("file:///path/to/plugin-a/index.js") // "plugin-a" - * getPluginName("file:///path/to/oh-my-opencode/dist/index.js") // "oh-my-opencode" + * getPluginName("file:///path/to/oh-my-opencode/dist/index.js") // "oh-my-opencode" (from package.json) + * getPluginName("file:///path/to/plugin/foo.js") // "foo" (no package.json, uses filename) * 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://")) { const filePath = fileURLToPath(plugin) + + const packageName = findPackageJsonName(filePath) + if (packageName) { + return packageName + } + const parsed = path.parse(filePath) const filename = parsed.name const genericNames = new Set(["index", "main", "plugin", "dist", "build", "out", "lib"]) @@ -352,12 +379,14 @@ export namespace Config { return filename } - const parts = filePath.split(path.sep).filter(Boolean) - for (let i = parts.length - 2; i >= 0; i--) { - const dirName = parts[i] + let dir = path.dirname(filePath) + const root = path.parse(dir).root + for (let i = 0; i < 5 && dir !== root; i++) { + const dirName = path.basename(dir) if (!genericNames.has(dirName) && dirName !== ".opencode") { return dirName } + dir = path.dirname(dir) } return plugin