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
127 changes: 17 additions & 110 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { ListTool } from "../tool/ls"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $, fileURLToPath } from "bun"
import { ConfigMarkdown } from "../config/markdown"
Expand Down Expand Up @@ -1290,121 +1289,29 @@ export namespace SessionPrompt {
},
}
await Session.updatePart(part)
const shell = Shell.preferred()
const shellName = (
process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
).toLowerCase()

const invocations: Record<string, { args: string[] }> = {
nu: {
args: ["-c", input.command],
},
fish: {
args: ["-c", input.command],
},
zsh: {
args: [
"-c",
"-l",
`
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
eval ${JSON.stringify(input.command)}
`,
],
},
bash: {
args: [
"-c",
"-l",
`
shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
eval ${JSON.stringify(input.command)}
`,
],
},
// Windows cmd
cmd: {
args: ["/c", input.command],
},
// Windows PowerShell
powershell: {
args: ["-NoProfile", "-Command", input.command],
},
pwsh: {
args: ["-NoProfile", "-Command", input.command],
},
// Fallback: any shell that doesn't match those above
// - No -l, for max compatibility
"": {
args: ["-c", `${input.command}`],
},
}

const matchingInvocation = invocations[shellName] ?? invocations[""]
const args = matchingInvocation?.args
let currentOutput = ""

const proc = spawn(shell, args, {
const result = await Shell.execute({
command: input.command,
cwd: Instance.directory,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
TERM: "dumb",
},
})

let output = ""

proc.stdout?.on("data", (chunk) => {
output += chunk.toString()
if (part.state.status === "running") {
part.state.metadata = {
output: output,
description: "",
}
Session.updatePart(part)
}
})

proc.stderr?.on("data", (chunk) => {
output += chunk.toString()
if (part.state.status === "running") {
part.state.metadata = {
output: output,
description: "",
shell: Shell.preferred(),
loadRcFiles: true,
abort,
onOutput: (output) => {
currentOutput = output
if (part.state.status === "running") {
part.state.metadata = {
output,
description: "",
}
Session.updatePart(part)
}
Session.updatePart(part)
}
})

let aborted = false
let exited = false

const kill = () => Shell.killTree(proc, { exited: () => exited })

if (abort.aborted) {
aborted = true
await kill()
}

const abortHandler = () => {
aborted = true
void kill()
}

abort.addEventListener("abort", abortHandler, { once: true })

await new Promise<void>((resolve) => {
proc.on("close", () => {
exited = true
abort.removeEventListener("abort", abortHandler)
resolve()
})
},
})

if (aborted) {
let output = currentOutput
if (result.aborted) {
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
}
msg.time.completed = Date.now()
Expand Down
128 changes: 128 additions & 0 deletions packages/opencode/src/shell/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,132 @@ export namespace Shell {
if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s
return fallback()
})

function getInvocationArgs(shell: string, command: string): string[] {
const shellName = (
process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
).toLowerCase()

const invocations: Record<string, string[]> = {
nu: ["-c", command],
fish: ["-c", command],
zsh: [
"-c",
"-l",
`[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
eval ${JSON.stringify(command)}`,
],
bash: [
"-c",
"-l",
`shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
eval ${JSON.stringify(command)}`,
],
cmd: ["/c", command],
powershell: ["-NoProfile", "-Command", command],
pwsh: ["-NoProfile", "-Command", command],
}

return invocations[shellName] ?? ["-c", command]
}

// ============ Unified Execution ============

export interface ExecuteOptions {
command: string
cwd: string
shell?: string
loadRcFiles?: boolean
timeout?: number
abort: AbortSignal
env?: Record<string, string>
onOutput?: (output: string) => void
}

export interface ExecuteResult {
output: string
exitCode: number | null
timedOut: boolean
aborted: boolean
}

export async function execute(options: ExecuteOptions): Promise<ExecuteResult> {
const { command, cwd, shell = acceptable(), loadRcFiles = false, timeout, abort, env = {}, onOutput } = options

const proc = loadRcFiles
? spawn(shell, getInvocationArgs(shell, command), {
cwd,
env: { ...process.env, TERM: "dumb", ...env },
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
})
: spawn(command, {
shell,
cwd,
env: { ...process.env, TERM: "dumb", ...env },
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
})

let output = ""
let timedOut = false
let aborted = false
let exited = false

const append = (chunk: Buffer) => {
output += chunk.toString()
onOutput?.(output)
}

proc.stdout?.on("data", append)
proc.stderr?.on("data", append)

const kill = () => killTree(proc, { exited: () => exited })

if (abort.aborted) {
aborted = true
await kill()
}

const abortHandler = () => {
aborted = true
void kill()
}
abort.addEventListener("abort", abortHandler, { once: true })

const timeoutTimer = timeout
? setTimeout(() => {
timedOut = true
void kill()
}, timeout + 100)
: undefined

await new Promise<void>((resolve, reject) => {
const cleanup = () => {
if (timeoutTimer) clearTimeout(timeoutTimer)
abort.removeEventListener("abort", abortHandler)
}

proc.once("exit", () => {
exited = true
cleanup()
resolve()
})

proc.once("error", (error) => {
exited = true
cleanup()
reject(error)
})
})

return {
output,
exitCode: proc.exitCode,
timedOut,
aborted,
}
}
}
Loading
Loading