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
17 changes: 17 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ export namespace Config {
return merged
}

function applyEnv(env: Record<string, string | null> | undefined) {
if (!env) return
for (const [key, value] of Object.entries(env)) {
if (value === null) {
delete process.env[key]
continue
}
process.env[key] = value
}
}

export const state = Instance.state(async () => {
const auth = await Auth.all()
let result = await global()
Expand Down Expand Up @@ -159,6 +170,8 @@ export namespace Config {
result.compaction = { ...result.compaction, prune: false }
}

applyEnv(result.env)

return {
config: result,
directories,
Expand Down Expand Up @@ -898,6 +911,10 @@ export namespace Config {
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
})
.optional(),
env: z
.record(z.string(), z.string().nullable())
.optional()
.describe("Environment variable overrides. Set to null to unset."),
experimental: z
.object({
hook: z
Expand Down
150 changes: 150 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -868,3 +868,153 @@ test("merges legacy tools with existing permission config", async () => {
},
})
})

// env field tests

test("env field sets process.env variables", async () => {
const originalValue = process.env["CONFIG_TEST_VAR"]
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
env: {
CONFIG_TEST_VAR: "test_value",
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
expect(process.env["CONFIG_TEST_VAR"]).toBe("test_value")
},
})
} finally {
if (originalValue !== undefined) {
process.env["CONFIG_TEST_VAR"] = originalValue
} else {
delete process.env["CONFIG_TEST_VAR"]
}
}
})

test("env field with null unsets process.env variable", async () => {
const originalValue = process.env["CONFIG_UNSET_VAR"]
process.env["CONFIG_UNSET_VAR"] = "should_be_removed"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
env: {
CONFIG_UNSET_VAR: null,
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Config.get()
expect(process.env["CONFIG_UNSET_VAR"]).toBeUndefined()
},
})
} finally {
if (originalValue !== undefined) {
process.env["CONFIG_UNSET_VAR"] = originalValue
} else {
delete process.env["CONFIG_UNSET_VAR"]
}
}
})

test("env field merges with later config winning", async () => {
const originalValue = process.env["CONFIG_MERGE_VAR"]
try {
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",
env: {
CONFIG_MERGE_VAR: "global_value",
},
}),
)

await Bun.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
env: {
CONFIG_MERGE_VAR: "local_value",
},
}),
)
},
})
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
await Config.get()
expect(process.env["CONFIG_MERGE_VAR"]).toBe("local_value")
},
})
} finally {
if (originalValue !== undefined) {
process.env["CONFIG_MERGE_VAR"] = originalValue
} else {
delete process.env["CONFIG_MERGE_VAR"]
}
}
})

test("env template does not see config env vars", async () => {
const originalValue = process.env["CONFIG_TEMPLATE_VAR"]
delete process.env["CONFIG_TEMPLATE_VAR"]
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
env: {
CONFIG_TEMPLATE_VAR: "should_not_appear",
},
theme: "{env:CONFIG_TEMPLATE_VAR}",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
// Template substitution happens before env is applied, so theme should be empty
expect(config.theme).toBe("")
// But process.env should have the value after config is loaded
expect(process.env["CONFIG_TEMPLATE_VAR"]).toBe("should_not_appear")
},
})
} finally {
if (originalValue !== undefined) {
process.env["CONFIG_TEMPLATE_VAR"] = originalValue
} else {
delete process.env["CONFIG_TEMPLATE_VAR"]
}
}
})
42 changes: 42 additions & 0 deletions packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,44 @@ You can control context compaction behavior through the `compaction` option.

---

### Environment Variables

You can set environment variables through the `env` option. These are applied to `process.env` at runtime and are available to providers, MCP servers, and tools.

```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-...",
"AWS_PROFILE": "profile-name",
"CUSTOM_VAR": "value"
}
}
```

Set a value to `null` to unset an environment variable:

```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"env": {
"UNWANTED_VAR": null
}
}
```

:::note
Environment variables from config are merged across config files. Later configs override earlier ones for the same key.
:::

This is useful for:

- Setting provider variables or API keys in config instead of shell environment
- Configuring MCP server environment
- Project-specific environment overrides

---

### Watcher

You can configure file watcher ignore patterns through the `watcher` option.
Expand Down Expand Up @@ -558,6 +596,10 @@ Use `{env:VARIABLE_NAME}` to substitute environment variables:

If the environment variable is not set, it will be replaced with an empty string.

:::note
The `{env:VAR}` syntax reads from your shell environment only. It does not see variables set via the `env` config option.
:::

---

### Files
Expand Down
Loading