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
27 changes: 27 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
28 changes: 28 additions & 0 deletions packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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