Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
ee7b734
feat: add opencode expand command for shell expansion in markdown
ariane-emory Dec 27, 2025
66ccb79
fix: replace unsupplied argument placeholders with empty string
ariane-emory Dec 27, 2025
da7b219
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 27, 2025
34ddc1c
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 28, 2025
9cb84c9
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 28, 2025
45212fb
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 28, 2025
447d863
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 28, 2025
e04bf98
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 29, 2025
742c60b
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 29, 2025
36c076a
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 29, 2025
20adfe8
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 29, 2025
43d57a3
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 29, 2025
e470c6b
Merge branch 'feat/opencode-expand' of github.com:ariane-emory/openco…
ariane-emory Dec 29, 2025
fe38013
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 30, 2025
c42255a
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 30, 2025
b73cd4f
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 30, 2025
24cd3e1
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 30, 2025
a793cd1
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 30, 2025
2737982
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 30, 2025
4d0e04e
Merge branch 'dev' into feat/opencode-expand
ariane-emory Dec 31, 2025
0c04979
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 1, 2026
895bec3
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 1, 2026
72fddfe
Fix variable substitution order in expand command
ariane-emory Jan 1, 2026
eb08d79
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 1, 2026
acf8bde
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 2, 2026
37841f1
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 3, 2026
42afdbc
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 3, 2026
f17a1ed
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 4, 2026
2cda3dd
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 4, 2026
c9389f2
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 4, 2026
8ae4bcb
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 5, 2026
00ba5e7
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 5, 2026
7f5a25b
Merge remote-tracking branch 'origin/dev' into feat/opencode-expand
ariane-emory Jan 5, 2026
c2c533b
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 5, 2026
b99c702
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 6, 2026
fe5f8e5
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 6, 2026
f68c38c
Merge branch 'feat/opencode-expand' of github.com:ariane-emory/openco…
ariane-emory Jan 6, 2026
c8c4975
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 6, 2026
938fb09
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 6, 2026
303fcee
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 6, 2026
f692e5f
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 6, 2026
54dd4ff
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 7, 2026
68f59f6
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 7, 2026
ccf618f
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 7, 2026
382818a
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 7, 2026
4d95df8
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 8, 2026
58cca0c
fix: add timeout to realpath resolution in bash tool to prevent test …
ariane-emory Jan 8, 2026
0213c72
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 11, 2026
ac8a3fc
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 13, 2026
88926a4
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 13, 2026
af2081b
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 13, 2026
d31ad8b
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 15, 2026
a0c6b69
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 16, 2026
4b2e2de
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 17, 2026
7c51ed8
Merge branch 'dev' into feat/opencode-expand
ariane-emory Jan 17, 2026
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
5 changes: 5 additions & 0 deletions expand-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
The command output:
!`ls -l | head -5`
The first argument: $1
All the arguments: $ARGUMENTS
The end!
67 changes: 67 additions & 0 deletions packages/opencode/src/cli/cmd/expand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Argv } from "yargs"
import path from "path"
import { cmd } from "./cmd"
import { UI } from "../ui"
import { MarkdownExpand } from "../../config/expand"
import { EOL } from "os"

export const ExpandCommand = cmd({
command: "expand [file] [args..]",
describe: "expand shell commands in a markdown file",
builder: (yargs: Argv) => {
return yargs
.positional("file", {
describe: 'path to markdown file, or "-" for stdin',
type: "string",
})
.positional("args", {
describe: "arguments available as $1, $2, ... and $ARGUMENTS in shell commands",
type: "string",
array: true,
})
.option("output", {
alias: ["o"],
describe: "write to file instead of stdout",
type: "string",
})
.option("cwd", {
describe: "working directory for shell commands",
type: "string",
})
},
handler: async (args) => {
const baseCwd = process.env.PWD ?? process.cwd()

const content = await (async () => {
// Treat missing file, "-", or empty string as stdin
if (!args.file || args.file === "-") {
return await Bun.stdin.text()
}

const filePath = path.resolve(baseCwd, args.file)
const file = Bun.file(filePath)

if (!(await file.exists())) {
UI.error(`File not found: ${args.file}`)
process.exit(1)
}

return await file.text()
})()

const cwd = args.cwd ? path.resolve(baseCwd, args.cwd) : baseCwd

const result = await MarkdownExpand.expand(content, {
cwd,
stripFrontmatter: true,
args: args.args ?? [],
})

if (args.output) {
const outputPath = path.resolve(baseCwd, args.output)
await Bun.write(outputPath, result + EOL)
} else {
process.stdout.write(result + EOL)
}
},
})
91 changes: 91 additions & 0 deletions packages/opencode/src/config/expand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { $ } from "bun"
import matter from "gray-matter"
import { ConfigMarkdown } from "./markdown"

export namespace MarkdownExpand {
const MAX_ITERATIONS = 100

export interface Options {
cwd?: string
stripFrontmatter?: boolean
args?: string[]
}

export async function expand(content: string, options: Options = {}): Promise<string> {
const { cwd = process.cwd(), stripFrontmatter = true, args = [] } = options

// Build environment variables for arguments
const env: Record<string, string> = {
...process.env,
ARGUMENTS: args.join(" "),
}

// Build the positional args string for shell wrapper
const quotedArgs = args.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`).join(" ")

let result = content

if (stripFrontmatter) {
try {
const parsed = matter(content)
result = parsed.content
} catch {
// If frontmatter parsing fails, use content as-is
}
}

// Substitute $1, $2, ... and $ARGUMENTS in the content BEFORE running shell commands
// Replace $ARGUMENTS with all arguments joined (or empty string if none)
result = result.replace(/\$ARGUMENTS\b/g, args.join(" "))

// Replace $1, $2, ... with positional arguments
for (let i = 0; i < args.length; i++) {
const pattern = new RegExp(`\\$${i + 1}\\b`, "g")
result = result.replace(pattern, args[i])
}

// Replace any remaining $N patterns with empty string
result = result.replace(/\$\d+\b/g, "")

let iteration = 0
while (iteration < MAX_ITERATIONS) {
const matches = ConfigMarkdown.shell(result)
if (matches.length === 0) break

const replacements = await Promise.all(
matches.map(async (match) => {
const cmd = match[1]
try {
// Wrap command in bash with positional parameters
const wrappedCmd =
args.length > 0 ? `bash -c '${cmd.replace(/'/g, "'\\''")}' -- ${quotedArgs}` : cmd
const output = await $`${{ raw: wrappedCmd }}`
.quiet()
.nothrow()
.cwd(cwd)
.env(env)
.text()
return { match: match[0], output: output.trimEnd() }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { match: match[0], output: `Error executing command: ${message}` }
}
}),
)

for (const { match, output } of replacements) {
result = result.replace(match, output)
}

iteration++
}

return result.trim()
}

export async function expandFile(filePath: string, options: Options = {}): Promise<string> {
const file = Bun.file(filePath)
const content = await file.text()
return expand(content, options)
}
}
2 changes: 2 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp"
import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
import { ExpandCommand } from "./cli/cmd/expand"
import { ImportCommand } from "./cli/cmd/import"
import { AttachCommand } from "./cli/cmd/tui/attach"
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
Expand Down Expand Up @@ -93,6 +94,7 @@ const cli = yargs(hideBin(process.argv))
.command(ModelsCommand)
.command(StatsCommand)
.command(ExportCommand)
.command(ExpandCommand)
.command(ImportCommand)
.command(GithubCommand)
.command(PrCommand)
Expand Down
15 changes: 9 additions & 6 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,15 @@ export const BashTool = Tool.define("bash", async () => {
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
const resolved = await $`realpath ${arg}`
.cwd(cwd)
.quiet()
.nothrow()
.text()
.then((x) => x.trim())
const resolved = await Promise.race([
$`realpath ${arg}`
.cwd(cwd)
.quiet()
.nothrow()
.text()
.then((x) => x.trim()),
new Promise<string>((resolve) => setTimeout(() => resolve(""), 3000))
])
log.info("resolved path", { arg, resolved })
if (resolved) {
// Git Bash on Windows returns Unix-style paths like /c/Users/...
Expand Down
151 changes: 151 additions & 0 deletions packages/opencode/test/config/expand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { expect, test, describe } from "bun:test"
import { MarkdownExpand } from "../../src/config/expand"

describe("MarkdownExpand", () => {
describe("expand", () => {
test("should expand a simple shell command", async () => {
const content = "Hello !`echo world`"
const result = await MarkdownExpand.expand(content)
expect(result).toBe("Hello world")
})

test("should expand multiple shell commands", async () => {
const content = "!`echo hello` !`echo world`"
const result = await MarkdownExpand.expand(content)
expect(result).toBe("hello world")
})

test("should handle commands with no output", async () => {
const content = "before!`true`after"
const result = await MarkdownExpand.expand(content)
expect(result).toBe("beforeafter")
})

test("should preserve content without shell commands", async () => {
const content = "This is plain text\nwith multiple lines"
const result = await MarkdownExpand.expand(content)
expect(result).toBe("This is plain text\nwith multiple lines")
})

test("should handle multiline command output", async () => {
const content = "Lines: !`printf 'a\\nb\\nc'`"
const result = await MarkdownExpand.expand(content)
expect(result).toBe("Lines: a\nb\nc")
})

test("should strip YAML frontmatter", async () => {
const content = `---
title: Test
description: A test file
---

Content here`
const result = await MarkdownExpand.expand(content)
expect(result).toBe("Content here")
})

test("should strip frontmatter and expand commands", async () => {
const content = `---
title: Test
---

Hello !`+"`echo world`"
const result = await MarkdownExpand.expand(content)
expect(result).toBe("Hello world")
})

test("should expand recursively when output contains shell syntax", async () => {
// First command outputs shell syntax via cat, which should be expanded in second pass
const content = "!`cat /tmp/test-expand.txt`"
// Create the temp file with shell syntax using Bun.write to avoid escaping issues
await Bun.write("/tmp/test-expand.txt", "!`echo inner`")
const result = await MarkdownExpand.expand(content)
expect(result).toBe("inner")
})

test("should handle failed commands gracefully", async () => {
const content = "Result: !`exit 1`"
const result = await MarkdownExpand.expand(content)
// Failed command should return empty string (no stderr captured)
expect(result).toBe("Result:")
})

test("should handle non-existent command", async () => {
const content = "Result: !`nonexistent_command_12345`"
const result = await MarkdownExpand.expand(content)
// Should not throw, result may contain error or be empty
expect(typeof result).toBe("string")
})

test("should respect cwd option", async () => {
const content = "Dir: !`pwd`"
const result = await MarkdownExpand.expand(content, { cwd: "/tmp" })
expect(result).toBe("Dir: /tmp")
})

test("should handle empty content", async () => {
const result = await MarkdownExpand.expand("")
expect(result).toBe("")
})

test("should handle content with only frontmatter", async () => {
const content = `---
title: Only frontmatter
---
`
const result = await MarkdownExpand.expand(content)
expect(result.trim()).toBe("")
})

test("should prevent infinite loops with max iterations", async () => {
// This would cause infinite expansion if not guarded
// The command outputs itself
const content = "!`echo '!\\`echo infinite\\`'`"
const result = await MarkdownExpand.expand(content)
// Should eventually stop and return something
expect(typeof result).toBe("string")
})

test("should expand positional arguments as $1, $2, etc.", async () => {
const content = "First: !`echo $1`, Second: !`echo $2`"
const result = await MarkdownExpand.expand(content, { args: ["hello", "world"] })
expect(result).toBe("First: hello, Second: world")
})

test("should expand $ARGUMENTS as all arguments joined", async () => {
const content = "Args: !`echo $ARGUMENTS`"
const result = await MarkdownExpand.expand(content, { args: ["one", "two", "three"] })
expect(result).toBe("Args: one two three")
})

test("should handle empty arguments gracefully", async () => {
const content = "Value: !`echo hello`"
const result = await MarkdownExpand.expand(content, { args: [] })
expect(result).toBe("Value: hello")
})

test("should substitute $1, $2 in plain text", async () => {
const content = "Hello $1, welcome to $2!"
const result = await MarkdownExpand.expand(content, { args: ["Alice", "Wonderland"] })
expect(result).toBe("Hello Alice, welcome to Wonderland!")
})

test("should substitute $ARGUMENTS in plain text", async () => {
const content = "You said: $ARGUMENTS"
const result = await MarkdownExpand.expand(content, { args: ["hello", "world"] })
expect(result).toBe("You said: hello world")
})

test("should replace $1 and $ARGUMENTS with empty string when no args provided", async () => {
const content = "First: $1, All: $ARGUMENTS, End"
const result = await MarkdownExpand.expand(content, { args: [] })
expect(result).toBe("First: , All: , End")
})

test("should replace unsupplied positional args with empty string", async () => {
const content = "First: $1, Second: $2, Third: $3"
const result = await MarkdownExpand.expand(content, { args: ["only-one"] })
expect(result).toBe("First: only-one, Second: , Third:")
})
})
})