summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAriane Emory <[email protected]>2025-11-25 12:34:21 -0500
committerGitHub <[email protected]>2025-11-25 11:34:21 -0600
commit4273fa9ccf5eec0ce32af4e88ace9a5ee74111c2 (patch)
tree19fec104e6e39541a331334e2099b04706455c9b
parent01bcb9dff91379e8d8257c26f09c6bc451e1bf21 (diff)
downloadopencode-4273fa9ccf5eec0ce32af4e88ace9a5ee74111c2.tar.gz
opencode-4273fa9ccf5eec0ce32af4e88ace9a5ee74111c2.zip
fix: merge plugin selections (resolves #4565) (#4724)
Co-authored-by: Dax Raad <[email protected]> Co-authored-by: GitHub Action <[email protected]> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: rekram1-node <[email protected]>
-rw-r--r--packages/opencode/src/config/config.ts21
-rw-r--r--packages/opencode/test/config/config.test.ts98
2 files changed, 114 insertions, 5 deletions
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index a63b1537a..28b8ca3b2 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -21,25 +21,36 @@ import { ConfigMarkdown } from "./markdown"
export namespace Config {
const log = Log.create({ service: "config" })
+ // Custom merge function that concatenates plugin arrays instead of replacing them
+ function mergeConfigWithPlugins(target: Info, source: Info): Info {
+ const merged = mergeDeep(target, source)
+ // If both configs have plugin arrays, concatenate them instead of replacing
+ if (target.plugin && source.plugin) {
+ const pluginSet = new Set([...target.plugin, ...source.plugin])
+ merged.plugin = Array.from(pluginSet)
+ }
+ return merged
+ }
+
export const state = Instance.state(async () => {
const auth = await Auth.all()
let result = await global()
// Override with custom config if provided
if (Flag.OPENCODE_CONFIG) {
- result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG))
+ result = mergeConfigWithPlugins(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) {
- result = mergeDeep(result, await loadFile(resolved))
+ result = mergeConfigWithPlugins(result, await loadFile(resolved))
}
}
if (Flag.OPENCODE_CONFIG_CONTENT) {
- result = mergeDeep(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
+ result = mergeConfigWithPlugins(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
@@ -47,7 +58,7 @@ export namespace Config {
if (value.type === "wellknown") {
process.env[value.key] = value.token
const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
- result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
+ result = mergeConfigWithPlugins(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
}
}
@@ -78,7 +89,7 @@ export namespace Config {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
- result = mergeDeep(result, await loadFile(path.join(dir, file)))
+ result = mergeConfigWithPlugins(result, await loadFile(path.join(dir, file)))
// to satisy the type checker
result.agent ??= {}
result.mode ??= {}
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 967972842..2ff8c01cd 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -403,3 +403,101 @@ test("resolves scoped npm plugins in config", async () => {
},
})
})
+
+test("merges plugin arrays from global and local configs", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ // Create a nested project structure with local .opencode config
+ const projectDir = path.join(dir, "project")
+ const opencodeDir = path.join(projectDir, ".opencode")
+ await fs.mkdir(opencodeDir, { recursive: true })
+
+ // Global config with plugins
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ plugin: ["global-plugin-1", "global-plugin-2"],
+ }),
+ )
+
+ // Local .opencode config with different plugins
+ await Bun.write(
+ path.join(opencodeDir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ plugin: ["local-plugin-1"],
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: path.join(tmp.path, "project"),
+ fn: async () => {
+ const config = await Config.get()
+ const plugins = config.plugin ?? []
+
+ // Should contain both global and local plugins
+ expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
+ expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
+ expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
+
+ // Should have all 3 plugins (not replaced, but merged)
+ const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
+ expect(pluginNames.length).toBeGreaterThanOrEqual(3)
+ },
+ })
+})
+
+test("deduplicates duplicate plugins from global and local configs", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ // Create a nested project structure with local .opencode config
+ const projectDir = path.join(dir, "project")
+ const opencodeDir = path.join(projectDir, ".opencode")
+ await fs.mkdir(opencodeDir, { recursive: true })
+
+ // Global config with plugins
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ plugin: ["duplicate-plugin", "global-plugin-1"],
+ }),
+ )
+
+ // Local .opencode config with some overlapping plugins
+ await Bun.write(
+ path.join(opencodeDir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ plugin: ["duplicate-plugin", "local-plugin-1"],
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: path.join(tmp.path, "project"),
+ fn: async () => {
+ const config = await Config.get()
+ const plugins = config.plugin ?? []
+
+ // Should contain all unique plugins
+ expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
+ expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
+ expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
+
+ // Should deduplicate the duplicate plugin
+ const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
+ expect(duplicatePlugins.length).toBe(1)
+
+ // Should have exactly 3 unique plugins
+ const pluginNames = plugins.filter(
+ (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
+ )
+ expect(pluginNames.length).toBe(3)
+ },
+ })
+})