diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f77fb854bed..68c14018d18 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 } from "fs" +import { existsSync, readFileSync } from "fs" export namespace Config { const log = Log.create({ service: "config" }) @@ -329,19 +329,67 @@ 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 without extension + * - 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/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://")) { - return path.parse(new URL(plugin).pathname).name + 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"]) + + if (!genericNames.has(filename)) { + return filename + } + + 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 } const lastAt = plugin.lastIndexOf("@") if (lastAt > 0) { @@ -1169,6 +1217,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) {} 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) => {