diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 396417e9a5b..a0d0d02f9f9 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -6,6 +6,8 @@ import { NamedError } from "@opencode-ai/util/error" import { Filesystem } from "@/util/filesystem" import { Flag } from "@/flag/flag" import { Global } from "@/global" +import { $ } from "bun" +import { withTimeout } from "@/util/timeout" export namespace ConfigPaths { export async function projectFiles(name: string, directory: string, worktree: string) { @@ -81,12 +83,53 @@ export namespace ConfigPaths { return typeof input === "string" ? path.dirname(input) : input.dir } - /** Apply {env:VAR} and {file:path} substitutions to config text. */ + /** Apply {env:VAR}, {file:path}, and {cmd:command} substitutions to config text. */ async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") { text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) + const cmdMatches = Array.from(text.matchAll(/\{cmd:[^}]+\}/g)) + if (cmdMatches.length) { + const configSource = source(input) + let out = "" + let cursor = 0 + + for (const match of cmdMatches) { + const token = match[0] + const index = match.index! + out += text.slice(cursor, index) + + const lineStart = text.lastIndexOf("\n", index - 1) + 1 + const prefix = text.slice(lineStart, index).trimStart() + if (prefix.startsWith("//")) { + out += token + cursor = index + token.length + continue + } + + const command = token.replace(/^\{cmd:/, "").replace(/\}$/, "") + const parts = command.trim().split(/\s+/) + const cmd = parts[0] + const args = parts.slice(1) + const cmdResult = await withTimeout($`${cmd} ${args}`.quiet().nothrow(), 5_000) + + if (cmdResult.exitCode !== 0) { + throw new InvalidError({ + path: configSource, + message: `bad command reference: "${token}" command failed with exit code ${cmdResult.exitCode}: ${cmdResult.stderr}`, + }) + } + + const cmdOutput = cmdResult.stdout.toString().trim() + out += JSON.stringify(cmdOutput).slice(1, -1) + cursor = index + token.length + } + + out += text.slice(cursor) + text = out + } + const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) if (!fileMatches.length) return text diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f245dc3493d..4749292a27d 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -102,6 +102,37 @@ test("loads JSONC config file", async () => { }) }) +test("substitutes {cmd:} tokens in config file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + test: { + type: "remote", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer {cmd:echo test_token}", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const mcpConfig = config.mcp?.test as { type: "remote"; headers?: Record } | undefined + expect(mcpConfig?.headers?.Authorization).toBe("Bearer test_token") + }, + }) +}) + test("merges multiple config files with correct precedence", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -1883,4 +1914,39 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { } } }) + + test("substitutes {cmd:} tokens", async () => { + const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"] + + try { + process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + github: { + type: "remote", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer {cmd:echo test_token}", + }, + }, + }, + }) + + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const mcpConfig = config.mcp?.github as { type: "remote"; headers?: Record } | undefined + expect(mcpConfig?.headers?.Authorization).toBe("Bearer test_token") + }, + }) + } finally { + if (originalEnv !== undefined) { + process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv + } else { + delete process.env["OPENCODE_CONFIG_CONTENT"] + } + } + }) }) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 038f253274e..26285874c18 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -633,7 +633,7 @@ Experimental options are not stable. They may change or be removed without notic ## Variables -You can use variable substitution in your config files to reference environment variables and file contents. +You can use variable substitution in your config files to reference environment variables, file contents, and command output. --- @@ -688,3 +688,30 @@ 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": { + "github": { + "options": { + "token": "{cmd:gh auth token}" + } + } + } +} +``` + +The command is executed at config load time and its stdout is embedded into the config. Commands are useful for: + +- Retrieving secrets from password managers (e.g., `op read`, `bw get password`) +- Getting dynamic values from CLI tools (e.g., `gh auth token`, `aws configure get`) +- Integrating with external credential providers + +The command must exit with code 0. If it fails, opencode will throw an error with the command's stderr.