fix: merge plugin selections (resolves #4565) (#4724)

Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
This commit is contained in:
Ariane Emory 2025-11-25 12:34:21 -05:00 committed by GitHub
parent 01bcb9dff9
commit 4273fa9ccf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 114 additions and 5 deletions

View file

@ -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 ??= {}

View file

@ -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)
},
})
})