From ee7b734d24e6b5e0fd40b56e3f3d015c8fc6a184 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sat, 27 Dec 2025 13:07:23 -0500 Subject: [PATCH 1/4] feat: add opencode expand command for shell expansion in markdown - Add 'opencode expand' CLI command to expand shell commands in markdown files - Support positional arguments ($1, $2, ...) and $ARGUMENTS substitution - Support reading from stdin or file, writing to stdout or file - Strip YAML frontmatter by default - Add comprehensive test suite --- expand-test.md | 5 + packages/opencode/src/cli/cmd/expand.ts | 67 +++++++++ packages/opencode/src/config/expand.ts | 90 ++++++++++++ packages/opencode/src/index.ts | 2 + packages/opencode/test/config/expand.test.ts | 139 +++++++++++++++++++ 5 files changed, 303 insertions(+) create mode 100644 expand-test.md create mode 100644 packages/opencode/src/cli/cmd/expand.ts create mode 100644 packages/opencode/src/config/expand.ts create mode 100644 packages/opencode/test/config/expand.test.ts diff --git a/expand-test.md b/expand-test.md new file mode 100644 index 00000000000..a4e93a67913 --- /dev/null +++ b/expand-test.md @@ -0,0 +1,5 @@ +The command output: +!`ls -l | head -5` +The first argument: $1 +All the arguments: $ARGUMENTS +The end! diff --git a/packages/opencode/src/cli/cmd/expand.ts b/packages/opencode/src/cli/cmd/expand.ts new file mode 100644 index 00000000000..f50a4cfa443 --- /dev/null +++ b/packages/opencode/src/cli/cmd/expand.ts @@ -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) + } + }, +}) diff --git a/packages/opencode/src/config/expand.ts b/packages/opencode/src/config/expand.ts new file mode 100644 index 00000000000..ee544ef59e8 --- /dev/null +++ b/packages/opencode/src/config/expand.ts @@ -0,0 +1,90 @@ +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 { + const { cwd = process.cwd(), stripFrontmatter = true, args = [] } = options + + // Build environment variables for arguments + const env: Record = { + ...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 + } + } + + 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++ + } + + // Substitute $1, $2, ... and $ARGUMENTS in the content + if (args.length > 0) { + // Replace $ARGUMENTS with all arguments joined + 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]) + } + } + + return result.trim() + } + + export async function expandFile(filePath: string, options: Options = {}): Promise { + const file = Bun.file(filePath) + const content = await file.text() + return expand(content, options) + } +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 638ee7347db..40e858561ef 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -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" @@ -94,6 +95,7 @@ const cli = yargs(hideBin(process.argv)) .command(ModelsCommand) .command(StatsCommand) .command(ExportCommand) + .command(ExpandCommand) .command(ImportCommand) .command(GithubCommand) .command(PrCommand) diff --git a/packages/opencode/test/config/expand.test.ts b/packages/opencode/test/config/expand.test.ts new file mode 100644 index 00000000000..ed41442ee47 --- /dev/null +++ b/packages/opencode/test/config/expand.test.ts @@ -0,0 +1,139 @@ +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") + }) + }) +}) From 66ccb791a14e6632862c863dee0bc74736e35970 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sat, 27 Dec 2025 13:13:16 -0500 Subject: [PATCH 2/4] fix: replace unsupplied argument placeholders with empty string --- packages/opencode/src/config/expand.ts | 19 ++++++++++--------- packages/opencode/test/config/expand.test.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/config/expand.ts b/packages/opencode/src/config/expand.ts index ee544ef59e8..a5ee8fd2a38 100644 --- a/packages/opencode/src/config/expand.ts +++ b/packages/opencode/src/config/expand.ts @@ -68,17 +68,18 @@ export namespace MarkdownExpand { } // Substitute $1, $2, ... and $ARGUMENTS in the content - if (args.length > 0) { - // Replace $ARGUMENTS with all arguments joined - 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 $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, "") + return result.trim() } diff --git a/packages/opencode/test/config/expand.test.ts b/packages/opencode/test/config/expand.test.ts index ed41442ee47..b537b4f45d6 100644 --- a/packages/opencode/test/config/expand.test.ts +++ b/packages/opencode/test/config/expand.test.ts @@ -135,5 +135,17 @@ title: Only frontmatter 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:") + }) }) }) From 72fddfe0f8f4d17cf5cd5ed0137fd4a5f4dcaf94 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Thu, 1 Jan 2026 16:16:47 -0500 Subject: [PATCH 3/4] Fix variable substitution order in expand command Swap order of operations to substitute and variables before executing shell commands. This ensures nested opencode expand calls can pass arguments through correctly when using shell command expansion. --- packages/opencode/src/config/expand.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/config/expand.ts b/packages/opencode/src/config/expand.ts index a5ee8fd2a38..9c46a8ea093 100644 --- a/packages/opencode/src/config/expand.ts +++ b/packages/opencode/src/config/expand.ts @@ -34,6 +34,19 @@ export namespace MarkdownExpand { } } + // 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) @@ -67,19 +80,6 @@ export namespace MarkdownExpand { iteration++ } - // Substitute $1, $2, ... and $ARGUMENTS in the content - // 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, "") - return result.trim() } From 58cca0c9ff56df900a01af0ff5ca1d3a2c4c1777 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 7 Jan 2026 19:26:39 -0500 Subject: [PATCH 4/4] fix: add timeout to realpath resolution in bash tool to prevent test hangs --- packages/opencode/src/tool/bash.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index e06a3f157cb..5b02dba16f8 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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((resolve) => setTimeout(() => resolve(""), 3000)) + ]) log.info("resolved path", { arg, resolved }) if (resolved) { // Git Bash on Windows returns Unix-style paths like /c/Users/...