diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7969e307957..e763578dfe2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1152,6 +1152,33 @@ export namespace Config { return process.env[varName] || "" }) + const cmdMatches = text.match(/\{cmd:[^}]+\}/g) + if (cmdMatches) { + const lines = text.split("\n") + + for (const match of cmdMatches) { + const lineIndex = lines.findIndex((line) => line.includes(match)) + if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) { + continue // Skip if line is commented + } + const command = match.replace(/^\{cmd:/, "").replace(/\}$/, "") + try { + const result = await Bun.$`sh -c ${command}`.text() + const trimmedResult = result.trim() + text = text.replace(match, JSON.stringify(trimmedResult).slice(1, -1)) + } catch (error) { + const errMsg = `bad cmd reference: "${match}"` + throw new InvalidError( + { + path: configFilepath, + message: errMsg + ` command failed: ${error.message}`, + }, + { cause: error }, + ) + } + } + } + const fileMatches = text.match(/\{file:[^}]+\}/g) if (fileMatches) { const configDir = path.dirname(configFilepath) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 1752e22e01f..de61eaea260 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -193,6 +193,47 @@ test("handles file inclusion substitution", async () => { }) }) +test("handles cmd command substitution", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + theme: "{cmd:echo test_theme}", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("test_theme") + }, + }) +}) + +test("handles cmd command substitution with error", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + theme: "{cmd:nonexistent-command}", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(Config.get()).rejects.toThrow() + }, + }) +}) + test("validates config schema and throws on invalid fields", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 1474cb91558..57129479f35 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -681,3 +681,31 @@ These are useful for: - Keeping sensitive data like API keys in separate files. - Including large instruction files without cluttering your config. - Sharing common configuration snippets across multiple config files. + +--- + +### Commands + +Use `{cmd:command}` to substitute the output of a shell command: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "anthropic": { + "models": {}, + "options": { + "apiKey": "{cmd:op read 'op://Vault/Provider/token'}" + } + } + } +} +``` + +The command is executed using your system shell and the output is substituted after trimming whitespace. If the command fails or doesn't exist, OpenCode will throw an error with details about the failure. + +This is useful for: + +- Retrieving secrets from password managers like 1Password CLI (`op`), Bitwarden CLI (`bw`), etc. +- Generating dynamic configuration values at runtime +- Integrating with system tools and scripts