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: 11 additions & 10 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ 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 {
// Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(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)
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
}
if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
}
return merged
}
Expand All @@ -39,27 +40,27 @@ export namespace Config {

// Override with custom config if provided
if (Flag.OPENCODE_CONFIG) {
result = mergeConfigWithPlugins(result, await loadFile(Flag.OPENCODE_CONFIG))
result = mergeConfigConcatArrays(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 = mergeConfigWithPlugins(result, await loadFile(resolved))
result = mergeConfigConcatArrays(result, await loadFile(resolved))
}
}

if (Flag.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigWithPlugins(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
result = mergeConfigConcatArrays(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 = mergeConfigWithPlugins(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
result = mergeConfigConcatArrays(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
}
}

Expand Down Expand Up @@ -95,7 +96,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 = mergeConfigWithPlugins(result, await loadFile(path.join(dir, file)))
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
Expand Down
81 changes: 81 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,87 @@ Helper subagent prompt`,
})
})

test("merges instructions arrays from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })

await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["global-instructions.md", "shared-rules.md"],
}),
)

await Bun.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["local-instructions.md"],
}),
)
},
})

await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const instructions = config.instructions ?? []

expect(instructions).toContain("global-instructions.md")
expect(instructions).toContain("shared-rules.md")
expect(instructions).toContain("local-instructions.md")
expect(instructions.length).toBe(3)
},
})
})

test("deduplicates duplicate instructions from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })

await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["duplicate.md", "global-only.md"],
}),
)

await Bun.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["duplicate.md", "local-only.md"],
}),
)
},
})

await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const instructions = config.instructions ?? []

expect(instructions).toContain("global-only.md")
expect(instructions).toContain("local-only.md")
expect(instructions).toContain("duplicate.md")

const duplicates = instructions.filter((i) => i === "duplicate.md")
expect(duplicates.length).toBe(1)
expect(instructions.length).toBe(3)
},
})
})

test("deduplicates duplicate plugins from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
Expand Down