Skip to content
Merged
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
21 changes: 16 additions & 5 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,33 +21,44 @@ 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")
}

for (const [key, value] of Object.entries(auth)) {
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()))
}
}

Expand Down Expand Up @@ -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 ??= {}
Expand Down
98 changes: 98 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
})
})