diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 09c80d2f301..419d33589e8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -35,6 +35,17 @@ export namespace Config { return merged } + function applyEnv(env: Record | 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() @@ -159,6 +170,8 @@ export namespace Config { result.compaction = { ...result.compaction, prune: false } } + applyEnv(result.env) + return { config: result, directories, @@ -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 diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index c35a391f838..01bdc1ce2f7 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -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"] + } + } +}) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 24b822cc423..41ab5671aa1 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -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. @@ -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