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
45 changes: 44 additions & 1 deletion packages/opencode/src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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

Expand Down
66 changes: 66 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> } | 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) => {
Expand Down Expand Up @@ -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<string, string> } | 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"]
}
}
})
})
29 changes: 28 additions & 1 deletion packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

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