diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2b8aa9e0301..545f4828205 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -81,8 +81,9 @@ export namespace Config { // 2) Global config (~/.config/opencode/opencode.json{,c}) // 3) Custom config (OPENCODE_CONFIG) // 4) Project config (opencode.json{,c}) - // 5) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c}) - // 6) Inline config (OPENCODE_CONFIG_CONTENT) + // 5) Project local config (opencode.local.json{,c}) — intended for .gitignore + // 6) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c}) + // 7) Inline config (OPENCODE_CONFIG_CONTENT) // Managed config directory is enterprise-only and always overrides everything above. let result: Info = {} for (const [key, value] of Object.entries(auth)) { @@ -123,6 +124,10 @@ export namespace Config { for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) { result = mergeConfigConcatArrays(result, await loadFile(file)) } + // Project local config overrides project config (intended for .gitignore). + for (const file of await ConfigPaths.projectFiles("opencode.local", Instance.directory, Instance.worktree)) { + result = mergeConfigConcatArrays(result, await loadFile(file)) + } } result.agent = result.agent || {} diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 90727cf8a08..d9302b35983 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1781,6 +1781,203 @@ describe("deduplicatePlugins", () => { }) }) +describe("opencode.local.json", () => { + test("loads local JSON config file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + model: "local/model", + username: "local-user", + }, + "opencode.local.json", + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("local/model") + expect(config.username).toBe("local-user") + }, + }) + }) + + test("loads local JSONC config file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.local.jsonc"), + `{ + // local override with comments + "$schema": "https://opencode.ai/config.json", + "model": "local/model" + }`, + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("local/model") + }, + }) + }) + + test("local config overrides project config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "project/model", + username: "project-user", + }) + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + model: "local/model", + }, + "opencode.local.json", + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("local/model") + expect(config.username).toBe("project-user") + }, + }) + }) + + test(".opencode directory overrides local config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "project/model", + }) + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + model: "local/model", + }, + "opencode.local.json", + ) + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await writeConfig(opencodeDir, { + $schema: "https://opencode.ai/config.json", + model: "dotdir/model", + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("dotdir/model") + }, + }) + }) + + test("skipped when OPENCODE_DISABLE_PROJECT_CONFIG is set", async () => { + const prev = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + model: "local/model", + }, + "opencode.local.json", + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).not.toBe("local/model") + }, + }) + } finally { + if (prev === undefined) delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + else process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = prev + } + }) + + test("merges plugin and instructions arrays with project config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + plugin: ["project-plugin"], + instructions: ["project-instruction"], + }) + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + plugin: ["local-plugin"], + instructions: ["local-instruction"], + }, + "opencode.local.json", + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.plugin).toContain("local-plugin") + expect(config.plugin).toContain("project-plugin") + expect(config.instructions).toContain("local-instruction") + expect(config.instructions).toContain("project-instruction") + }, + }) + }) + test("loads even when ignored in .gitignore", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "project/model", + }) + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + model: "local/model", + username: "local-user", + }, + "opencode.local.json", + ) + await Filesystem.write(path.join(dir, ".gitignore"), "opencode.local.json\nopencode.local.jsonc\n") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("local/model") + expect(config.username).toBe("local-user") + }, + }) + }) +}) + describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { test("skips project config files when flag is set", async () => { const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index d2770ee2094..f7b9238605b 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -47,8 +47,9 @@ Config sources are loaded in this order (later sources override earlier ones): 2. **Global config** (`~/.config/opencode/opencode.json`) - user preferences 3. **Custom config** (`OPENCODE_CONFIG` env var) - custom overrides 4. **Project config** (`opencode.json` in project) - project-specific settings -5. **`.opencode` directories** - agents, commands, plugins -6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides +5. **Project local config** (`opencode.local.json` in project) - local overrides (gitignored) +6. **`.opencode` directories** - agents, commands, plugins +7. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides This means project configs can override global defaults, and global configs can override remote organizational defaults. @@ -106,7 +107,7 @@ Global config overrides remote organizational defaults. ### Per project -Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs. +Add `opencode.json` in your project root. This is safe to check into Git and is shared with your team. For project-specific TUI settings, add `tui.json` alongside it. @@ -116,7 +117,38 @@ Place project specific config in the root of your project. When OpenCode starts up, it looks for a config file in the current directory or traverse up to the nearest Git directory. -This is also safe to be checked into Git and uses the same schema as the global one. +--- + +### Local overrides + +Add `opencode.local.json` (or `opencode.local.jsonc`) alongside your project config for machine-specific overrides that shouldn't be committed to Git. + +```json title="opencode.local.json" +{ + "$schema": "https://opencode.ai/config.json", + "model": "anthropic/claude-sonnet-4-5", + "provider": { + "anthropic": { + "options": { + "apiKey": "sk-ant-..." + } + } + } +} +``` + +Local config is merged on top of the project config, so you only need to specify the keys you want to override. Add `opencode.local.json` to your `.gitignore`: + +```txt title=".gitignore" +opencode.local.json +opencode.local.jsonc +``` + +This is useful for: + +- API keys or secrets you don't want in version control. +- Machine-specific model or provider preferences. +- Overriding team defaults without modifying the shared config. ---