Skip to content
Open
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
9 changes: 7 additions & 2 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 || {}
Expand Down
197 changes: 197 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
40 changes: 36 additions & 4 deletions packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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.

---

Expand Down
Loading