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..9c46a8ea093 --- /dev/null +++ b/packages/opencode/src/config/expand.ts @@ -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 { + 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 + } + } + + // 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 { + 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 6dc5e99e91e..b375cfdf728 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" @@ -93,6 +94,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/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f3a1b04d431..1599b1a1246 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/... diff --git a/packages/opencode/test/config/expand.test.ts b/packages/opencode/test/config/expand.test.ts new file mode 100644 index 00000000000..b537b4f45d6 --- /dev/null +++ b/packages/opencode/test/config/expand.test.ts @@ -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:") + }) + }) +})