Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
import { constants, existsSync, readFileSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
Expand Down Expand Up @@ -462,23 +462,69 @@ export namespace Config {
return plugins
}

function findPackageName(fp: string): string | undefined {
let dir = path.dirname(fp)
const root = path.parse(dir).root
for (let i = 0; i < 5 && dir !== root; i++) {
// Don't escape .opencode boundary — host project's package.json is irrelevant
if (path.basename(dir) === ".opencode") return undefined
const pkg = path.join(dir, "package.json")
if (existsSync(pkg)) {
try {
const data = JSON.parse(readFileSync(pkg, "utf-8"))
if (typeof data.name === "string") return data.name
} catch {}
}
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:
* 1. Reads nearest package.json `name` field (walks up max 5 levels)
* 2. Falls back to filename if not "index"
* 3. For "index" entry points, walks up directories skipping
* src/dist/lib/build/out/esm/cjs to find a meaningful name
* - For npm packages: extracts package name without version
*
* @example
* getPluginName("file:///path/to/plugin/foo.js") // "foo"
* getPluginName("file:///path/to/my-plugin/src/index.ts") // "my-plugin" (via package.json or dir heuristic)
* 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://")) {
return path.parse(new URL(plugin).pathname).name
const fp = fileURLToPath(plugin)

// Best: use package.json name
const pkg = findPackageName(fp)
if (pkg) return pkg

// Fallback: use filename, skip generic names
// Use URL pathname for name extraction (always posix-style /)
const pathname = new URL(plugin).pathname
const parsed = path.posix.parse(pathname)
if (parsed.name !== "index") return parsed.name

// Walk up to find a meaningful directory name
const skip = new Set(["src", "dist", "lib", "build", "out", "esm", "cjs"])
let dir = parsed.dir
const root = path.posix.parse(dir).root
for (let i = 0; i < 5 && dir !== root; i++) {
const name = path.posix.basename(dir)
if (!skip.has(name)) return name
dir = path.posix.dirname(dir)
}

return parsed.name
}
const lastAt = plugin.lastIndexOf("@")
if (lastAt > 0) {
return plugin.substring(0, lastAt)
const last = plugin.lastIndexOf("@")
if (last > 0) {
return plugin.substring(0, last)
}
return plugin
}
Expand Down
85 changes: 79 additions & 6 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@ import fs from "fs/promises"
import { pathToFileURL } from "url"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem"

// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!

// Generate a platform-correct file:// URL for testing
// On Windows: file:///C:/path/to/... On Unix: file:///path/to/...
function testFileURL(...segments: string[]): string {
return pathToFileURL(path.resolve(path.sep, ...segments)).href
}



afterEach(async () => {
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
})
Expand Down Expand Up @@ -1602,9 +1609,9 @@ test("wellknown URL with trailing slash is normalized", async () => {

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")
expect(Config.getPluginName(testFileURL("path", "to", "plugin", "foo.js"))).toBe("foo")
expect(Config.getPluginName(testFileURL("path", "to", "plugin", "bar.ts"))).toBe("bar")
expect(Config.getPluginName(testFileURL("some", "path", "my-plugin.js"))).toBe("my-plugin")
})

test("extracts name from npm package with version", () => {
Expand All @@ -1622,6 +1629,59 @@ describe("getPluginName", () => {
expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
})

test("uses directory heuristic for index.ts in src/", () => {
expect(Config.getPluginName(testFileURL("path", "to", "my-plugin", "src", "index.ts"))).toBe("my-plugin")
})

test("skips dist/ for index.js entry points", () => {
expect(Config.getPluginName(testFileURL("path", "to", "plugin", "dist", "index.js"))).toBe("plugin")
})

test("skips build/out/esm/cjs for index entry points", () => {
expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "build", "index.js"))).toBe("mypkg")
expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "out", "index.js"))).toBe("mypkg")
expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "esm", "index.js"))).toBe("mypkg")
expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "cjs", "index.js"))).toBe("mypkg")
})

test(".opencode/plugin scripts use filename, not host package.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "package.json"), JSON.stringify({ name: "my-app" }))
const pluginDir = path.join(dir, ".opencode", "plugin")
await fs.mkdir(pluginDir, { recursive: true })
await Filesystem.write(path.join(pluginDir, "a.js"), "export default {}")
await Filesystem.write(path.join(pluginDir, "b.js"), "export default {}")
},
})

const urlA = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "a.js")).href
const urlB = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "b.js")).href

expect(Config.getPluginName(urlA)).toBe("a")
expect(Config.getPluginName(urlB)).toBe("b")

const result = Config.deduplicatePlugins([urlA, urlB])
expect(result.length).toBe(2)
})

test("real plugin with package.json resolves to package name", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginDir = path.join(dir, "my-plugin", "src")
await fs.mkdir(pluginDir, { recursive: true })
await Filesystem.write(
path.join(dir, "my-plugin", "package.json"),
JSON.stringify({ name: "my-plugin" }),
)
await Filesystem.write(path.join(pluginDir, "index.ts"), "export default {}")
},
})

const url = pathToFileURL(path.join(tmp.path, "my-plugin", "src", "index.ts")).href
expect(Config.getPluginName(url)).toBe("my-plugin")
})
})

describe("deduplicatePlugins", () => {
Expand All @@ -1638,12 +1698,25 @@ describe("deduplicatePlugins", () => {
})

test("prefers local file over npm package with same name", () => {
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
const plugins = ["oh-my-opencode@2.4.3", testFileURL("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")
expect(result[0]).toBe(testFileURL("project", ".opencode", "plugin", "oh-my-opencode.js"))
})

test("keeps all index.js plugins from different directories", () => {
const plugins = [
testFileURL("path", "to", "alpha", "src", "index.ts"),
testFileURL("path", "to", "beta", "dist", "index.js"),
testFileURL("path", "to", "gamma", "lib", "index.js"),
]

const result = Config.deduplicatePlugins(plugins)

// Each has a distinct directory name, so all 3 should survive
expect(result.length).toBe(3)
})

test("preserves order of remaining plugins", () => {
Expand Down
Loading