feat: powershell

This commit is contained in:
Gab
2026-04-09 22:23:04 +10:00
parent c5af4f99e1
commit fbe07343c8
10 changed files with 505 additions and 223 deletions

View File

@@ -463,6 +463,7 @@
"solid-js": "catalog:", "solid-js": "catalog:",
"strip-ansi": "7.1.2", "strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
"ulid": "catalog:", "ulid": "catalog:",
"vscode-jsonrpc": "8.2.1", "vscode-jsonrpc": "8.2.1",
@@ -4580,6 +4581,8 @@
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="], "tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],

View File

@@ -146,6 +146,7 @@
"solid-js": "catalog:", "solid-js": "catalog:",
"strip-ansi": "7.1.2", "strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
"ulid": "catalog:", "ulid": "catalog:",
"vscode-jsonrpc": "8.2.1", "vscode-jsonrpc": "8.2.1",

View File

@@ -176,7 +176,7 @@ export namespace Pty {
const id = PtyID.ascending() const id = PtyID.ascending()
const command = input.command || Shell.preferred() const command = input.command || Shell.preferred()
const args = input.args || [] const args = input.args || []
if (command.endsWith("sh")) { if (Shell.login(command)) {
args.push("-l") args.push("-l")
} }

View File

@@ -1600,9 +1600,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
} }
await Session.updatePart(part) await Session.updatePart(part)
const shell = Shell.preferred() const shell = Shell.preferred()
const shellName = ( const shellName = Shell.name(shell)
process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
).toLowerCase()
const invocations: Record<string, { args: string[] }> = { const invocations: Record<string, { args: string[] }> = {
nu: { nu: {

View File

@@ -9,6 +9,10 @@ import { setTimeout as sleep } from "node:timers/promises"
const SIGKILL_TIMEOUT_MS = 200 const SIGKILL_TIMEOUT_MS = 200
export namespace Shell { export namespace Shell {
const BLACKLIST = new Set(["fish", "nu"])
const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"])
const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"])
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> { export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid const pid = proc.pid
if (!pid || opts?.exited?.()) return if (!pid || opts?.exited?.()) return
@@ -39,18 +43,46 @@ export namespace Shell {
} }
} }
} }
const BLACKLIST = new Set(["fish", "nu"])
function full(file: string) {
if (process.platform !== "win32") return file
const shell = Filesystem.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
}
return Bun.which(shell) || shell
}
function pick() {
const pwsh = Bun.which("pwsh")
if (pwsh) return pwsh
const powershell = Bun.which("powershell")
if (powershell) return powershell
}
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
if (process.platform === "win32") {
const shell = pick()
if (shell) return shell
}
return fallback()
}
export function gitbash() {
if (process.platform !== "win32") return
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (Filesystem.stat(file)?.size) return file
}
function fallback() { function fallback() {
if (process.platform === "win32") { if (process.platform === "win32") {
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH const file = gitbash()
const git = which("git") if (file) return file
if (git) {
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
const bash = path.join(git, "..", "..", "bin", "bash.exe")
if (Filesystem.stat(bash)?.size) return bash
}
return process.env.COMSPEC || "cmd.exe" return process.env.COMSPEC || "cmd.exe"
} }
if (process.platform === "darwin") return "/bin/zsh" if (process.platform === "darwin") return "/bin/zsh"
@@ -59,15 +91,20 @@ export namespace Shell {
return "/bin/sh" return "/bin/sh"
} }
export const preferred = lazy(() => { export function name(file: string) {
const s = process.env.SHELL if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
if (s) return s return path.basename(file).toLowerCase()
return fallback() }
})
export const acceptable = lazy(() => { export function login(file: string) {
const s = process.env.SHELL return LOGIN.has(name(file))
if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s }
return fallback()
}) export function posix(file: string) {
return POSIX.has(name(file))
}
export const preferred = lazy(() => select(process.env.SHELL))
export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
} }

View File

@@ -1,4 +1,5 @@
import z from "zod" import z from "zod"
import os from "os"
import { spawn } from "child_process" import { spawn } from "child_process"
import { Tool } from "./tool" import { Tool } from "./tool"
import path from "path" import path from "path"
@@ -6,12 +7,12 @@ import DESCRIPTION from "./bash.txt"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter" import { Language, type Node } from "web-tree-sitter"
import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts" import { Flag } from "@/flag/flag"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity" import { BashArity } from "@/permission/arity"
@@ -20,6 +21,40 @@ import { Plugin } from "@/plugin"
const MAX_METADATA_LENGTH = 30_000 const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
const PS = new Set(["powershell", "pwsh"])
const CWD = new Set(["cd", "push-location", "set-location"])
const FILES = new Set([
...CWD,
"rm",
"cp",
"mv",
"mkdir",
"touch",
"chmod",
"chown",
"cat",
"get-content",
"set-content",
"add-content",
"copy-item",
"move-item",
"remove-item",
"new-item",
"rename-item",
])
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
type Part = {
type: string
text: string
}
type Scan = {
dirs: Set<string>
patterns: Set<string>
always: Set<string>
}
export const log = Log.create({ service: "bash-tool" }) export const log = Log.create({ service: "bash-tool" })
@@ -30,6 +65,350 @@ const resolveWasm = (asset: string) => {
return fileURLToPath(url) return fileURLToPath(url)
} }
function parts(node: Node) {
const out: Part[] = []
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
if (!child) continue
if (child.type === "command_elements") {
for (let j = 0; j < child.childCount; j++) {
const item = child.child(j)
if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue
out.push({ type: item.type, text: item.text })
}
continue
}
if (
child.type !== "command_name" &&
child.type !== "command_name_expr" &&
child.type !== "word" &&
child.type !== "string" &&
child.type !== "raw_string" &&
child.type !== "concatenation"
) {
continue
}
out.push({ type: child.type, text: child.text })
}
return out
}
function source(node: Node) {
return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim()
}
function commands(node: Node) {
return node.descendantsOfType("command").filter((child): child is Node => Boolean(child))
}
function unquote(text: string) {
if (text.length < 2) return text
const first = text[0]
const last = text[text.length - 1]
if ((first === '"' || first === "'") && first === last) return text.slice(1, -1)
return text
}
function home(text: string) {
if (text === "~") return os.homedir()
if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2))
return text
}
function envValue(key: string) {
if (process.platform !== "win32") return process.env[key]
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
return name ? process.env[name] : undefined
}
function auto(key: string, cwd: string, shell: string) {
const name = key.toUpperCase()
if (name === "HOME") return os.homedir()
if (name === "PWD") return cwd
if (name === "PSHOME") return path.dirname(shell)
}
function expand(text: string, cwd: string, shell: string) {
const out = unquote(text)
.replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "")
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "")
.replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "")
return home(out)
}
function provider(text: string) {
const match = text.match(/^([A-Za-z]+)::(.*)$/)
if (match) {
if (match[1].toLowerCase() !== "filesystem") return
return match[2]
}
const prefix = text.match(/^([A-Za-z]+):(.*)$/)
if (!prefix) return text
if (prefix[1].length === 1) return text
return
}
function dynamic(text: string, ps: boolean) {
if (text.startsWith("(") || text.startsWith("@(")) return true
if (text.includes("$(") || text.includes("${") || text.includes("`")) return true
if (ps) return /\$(?!env:)/i.test(text)
return text.includes("$")
}
function prefix(text: string) {
const match = /[?*\[]/.exec(text)
if (!match) return text
if (match.index === 0) return
return text.slice(0, match.index)
}
async function cygpath(shell: string, text: string) {
const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true })
if (out.code !== 0) return
const file = out.text.trim()
if (!file) return
return Filesystem.normalizePath(file)
}
async function resolvePath(text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) {
const file = await cygpath(shell, text)
if (file) return file
}
return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text)))
}
return path.resolve(root, text)
}
async function argPath(arg: string, cwd: string, ps: boolean, shell: string) {
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
const file = text && prefix(text)
if (!file || dynamic(file, ps)) return
const next = ps ? provider(file) : file
if (!next) return
return resolvePath(next, cwd, shell)
}
function pathArgs(list: Part[], ps: boolean) {
if (!ps) {
return list
.slice(1)
.filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+")))
.map((item) => item.text)
}
const out: string[] = []
let want = false
for (const item of list.slice(1)) {
if (want) {
out.push(item.text)
want = false
continue
}
if (item.type === "command_parameter") {
const flag = item.text.toLowerCase()
if (SWITCHES.has(flag)) continue
want = FLAGS.has(flag)
continue
}
out.push(item.text)
}
return out
}
async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise<Scan> {
const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
}
for (const node of commands(root)) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && FILES.has(cmd)) {
for (const arg of pathArgs(command, ps)) {
const resolved = await argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || Instance.containsPath(resolved)) continue
const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
}
}
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
}
return scan
}
function preview(text: string) {
if (text.length <= MAX_METADATA_LENGTH) return text
return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
}
async function parse(command: string, ps: boolean) {
const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command))
if (!tree) throw new Error("Failed to parse command")
return tree.rootNode
}
async function ask(ctx: Tool.Context, scan: Scan) {
if (scan.dirs.size > 0) {
const globs = Array.from(scan.dirs).map((dir) => {
if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
return path.join(dir, "*")
})
await ctx.ask({
permission: "external_directory",
patterns: globs,
always: globs,
metadata: {},
})
}
if (scan.patterns.size === 0) return
await ctx.ask({
permission: "bash",
patterns: Array.from(scan.patterns),
always: Array.from(scan.always),
metadata: {},
})
}
async function shellEnv(ctx: Tool.Context, cwd: string) {
const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
return {
...process.env,
...extra.env,
}
}
function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && PS.has(name)) {
return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
cwd,
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
windowsHide: true,
})
}
return spawn(command, {
shell,
cwd,
env,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
windowsHide: process.platform === "win32",
})
}
async function run(
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
timeout: number
description: string
},
ctx: Tool.Context,
) {
const proc = launch(input.shell, input.name, input.command, input.cwd, input.env)
let output = ""
ctx.metadata({
metadata: {
output: "",
description: input.description,
},
})
const append = (chunk: Buffer) => {
output += chunk.toString()
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}
proc.stdout?.on("data", append)
proc.stderr?.on("data", append)
let expired = false
let aborted = false
let exited = false
const kill = () => Shell.killTree(proc, { exited: () => exited })
if (ctx.abort.aborted) {
aborted = true
await kill()
}
const abort = () => {
aborted = true
void kill()
}
ctx.abort.addEventListener("abort", abort, { once: true })
const timer = setTimeout(() => {
expired = true
void kill()
}, input.timeout + 100)
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer)
ctx.abort.removeEventListener("abort", abort)
}
proc.once("exit", () => {
exited = true
})
proc.once("close", () => {
exited = true
cleanup()
resolve()
})
proc.once("error", (error) => {
exited = true
cleanup()
reject(error)
})
})
const metadata: string[] = []
if (expired) metadata.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
if (aborted) metadata.push("User aborted the command")
if (metadata.length > 0) {
output += "\n\n<bash_metadata>\n" + metadata.join("\n") + "\n</bash_metadata>"
}
return {
title: input.description,
metadata: {
output: preview(output),
exit: proc.exitCode,
description: input.description,
},
output,
}
}
const parser = lazy(async () => { const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter") const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
@@ -44,20 +423,33 @@ const parser = lazy(async () => {
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, { const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" }, with: { type: "wasm" },
}) })
const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, {
with: { type: "wasm" },
})
const bashPath = resolveWasm(bashWasm) const bashPath = resolveWasm(bashWasm)
const bashLanguage = await Language.load(bashPath) const psPath = resolveWasm(psWasm)
const p = new Parser() const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
p.setLanguage(bashLanguage) const bash = new Parser()
return p bash.setLanguage(bashLanguage)
const ps = new Parser()
ps.setLanguage(psLanguage)
return { bash, ps }
}) })
// TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.define("bash", async () => { export const BashTool = Tool.define("bash", async () => {
const shell = Shell.acceptable() const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
name === "powershell"
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell }) log.info("bash tool using shell", { shell })
return { return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory) description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: z.object({ parameters: z.object({
@@ -76,195 +468,29 @@ export const BashTool = Tool.define("bash", async () => {
), ),
}), }),
async execute(params, ctx) { async execute(params, ctx) {
const cwd = params.workdir || Instance.directory const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) { if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
} }
const timeout = params.timeout ?? DEFAULT_TIMEOUT const timeout = params.timeout ?? DEFAULT_TIMEOUT
const tree = await parser().then((p) => p.parse(params.command)) const ps = PS.has(name)
if (!tree) { const root = await parse(params.command, ps)
throw new Error("Failed to parse command") const scan = await collect(root, cwd, ps, shell)
} if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
const directories = new Set<string>() await ask(ctx, scan)
if (!Instance.containsPath(cwd)) directories.add(cwd)
const patterns = new Set<string>()
const always = new Set<string>()
for (const node of tree.rootNode.descendantsOfType("command")) { return run(
if (!node) continue {
shell,
// Get full command text including redirects if present name,
let commandText = node.parent?.type === "redirected_statement" ? node.parent.text : node.text command: params.command,
cwd,
const command = [] env: await shellEnv(ctx, cwd),
for (let i = 0; i < node.childCount; i++) { timeout,
const child = node.child(i) description: params.description,
if (!child) continue },
if ( ctx,
child.type !== "command_name" &&
child.type !== "word" &&
child.type !== "string" &&
child.type !== "raw_string" &&
child.type !== "concatenation"
) {
continue
}
command.push(child.text)
}
// not an exhaustive list, but covers most common cases
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
log.info("resolved path", { arg, resolved })
if (resolved) {
const normalized =
process.platform === "win32" ? Filesystem.windowsPath(resolved).replace(/\//g, "\\") : resolved
if (!Instance.containsPath(normalized)) {
const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
directories.add(dir)
}
}
}
}
// cd covered by above check
if (command.length && command[0] !== "cd") {
patterns.add(commandText)
always.add(BashArity.prefix(command).join(" ") + " *")
}
}
if (directories.size > 0) {
const globs = Array.from(directories).map((dir) => {
// Preserve POSIX-looking paths with /s, even on Windows
if (dir.startsWith("/")) return `${dir.replace(/[\\/]+$/, "")}/*`
return path.join(dir, "*")
})
await ctx.ask({
permission: "external_directory",
patterns: globs,
always: globs,
metadata: {},
})
}
if (patterns.size > 0) {
await ctx.ask({
permission: "bash",
patterns: Array.from(patterns),
always: Array.from(always),
metadata: {},
})
}
const shellEnv = await Plugin.trigger(
"shell.env",
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
{ env: {} },
) )
const proc = spawn(params.command, {
shell,
cwd,
env: {
...process.env,
...shellEnv.env,
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
windowsHide: process.platform === "win32",
})
let output = ""
// Initialize metadata with empty output
ctx.metadata({
metadata: {
output: "",
description: params.description,
},
})
const append = (chunk: Buffer) => {
output += chunk.toString()
ctx.metadata({
metadata: {
// truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
description: params.description,
},
})
}
proc.stdout?.on("data", append)
proc.stderr?.on("data", append)
let timedOut = false
let aborted = false
let exited = false
const kill = () => Shell.killTree(proc, { exited: () => exited })
if (ctx.abort.aborted) {
aborted = true
await kill()
}
const abortHandler = () => {
aborted = true
void kill()
}
ctx.abort.addEventListener("abort", abortHandler, { once: true })
const timeoutTimer = setTimeout(() => {
timedOut = true
void kill()
}, timeout + 100)
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
clearTimeout(timeoutTimer)
ctx.abort.removeEventListener("abort", abortHandler)
}
proc.once("exit", () => {
exited = true
cleanup()
resolve()
})
proc.once("error", (error) => {
exited = true
cleanup()
reject(error)
})
})
const resultMetadata: string[] = []
if (timedOut) {
resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
}
if (aborted) {
resultMetadata.push("User aborted the command")
}
if (resultMetadata.length > 0) {
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
}
return {
title: params.description,
metadata: {
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
exit: proc.exitCode,
description: params.description,
},
output,
}
}, },
} }
}) })

View File

@@ -1,5 +1,7 @@
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
Be aware: OS: ${os}, Shell: ${shell}
All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead. All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
@@ -35,7 +37,7 @@ Usage notes:
- Communication: Output text directly (NOT echo/printf) - Communication: Output text directly (NOT echo/printf)
- When issuing multiple commands: - When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel. - If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
- If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead. - ${chaining}
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
- DO NOT use newlines to separate commands (newlines are ok in quoted strings) - DO NOT use newlines to separate commands (newlines are ok in quoted strings)
- AVOID using `cd <directory> && <command>`. Use the `workdir` parameter to change directories instead. - AVOID using `cd <directory> && <command>`. Use the `workdir` parameter to change directories instead.

View File

@@ -1,6 +1,7 @@
import path from "path" import path from "path"
import type { Tool } from "./tool" import type { Tool } from "./tool"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "@/util/filesystem"
type Kind = "file" | "directory" type Kind = "file" | "directory"
@@ -14,19 +15,20 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
if (options?.bypass) return if (options?.bypass) return
if (Instance.containsPath(target)) return const full = process.platform === "win32" ? Filesystem.normalizePath(target) : target
if (Instance.containsPath(full)) return
const kind = options?.kind ?? "file" const kind = options?.kind ?? "file"
const parentDir = kind === "directory" ? target : path.dirname(target) const dir = kind === "directory" ? full : path.dirname(full)
const glob = path.join(parentDir, "*").replaceAll("\\", "/") const glob = process.platform === "win32" ? Filesystem.normalizePathPattern(path.join(dir, "*")) : path.join(dir, "*")
await ctx.ask({ await ctx.ask({
permission: "external_directory", permission: "external_directory",
patterns: [glob], patterns: [glob],
always: [glob], always: [glob],
metadata: { metadata: {
filepath: target, filepath: full,
parentDir, parentDir: dir,
}, },
}) })
} }

View File

@@ -35,6 +35,9 @@ export const ReadTool = Tool.define("read", {
if (!path.isAbsolute(filepath)) { if (!path.isAbsolute(filepath)) {
filepath = path.resolve(Instance.directory, filepath) filepath = path.resolve(Instance.directory, filepath)
} }
if (process.platform === "win32") {
filepath = Filesystem.normalizePath(filepath)
}
const title = path.relative(Instance.worktree, filepath) const title = path.relative(Instance.worktree, filepath)
const stat = Filesystem.stat(filepath) const stat = Filesystem.stat(filepath)

View File

@@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs" import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types" import { lookup } from "mime-types"
import { realpathSync } from "fs" import { realpathSync } from "fs"
import { dirname, join, relative, resolve as pathResolve } from "path" import { dirname, join, relative, resolve as pathResolve, win32 } from "path"
import { Readable } from "stream" import { Readable } from "stream"
import { pipeline } from "stream/promises" import { pipeline } from "stream/promises"
import { Glob } from "./glob" import { Glob } from "./glob"
@@ -106,13 +106,23 @@ export namespace Filesystem {
*/ */
export function normalizePath(p: string): string { export function normalizePath(p: string): string {
if (process.platform !== "win32") return p if (process.platform !== "win32") return p
const resolved = win32.normalize(win32.resolve(windowsPath(p)))
try { try {
return realpathSync.native(p) return realpathSync.native(resolved)
} catch { } catch {
return p return resolved
} }
} }
export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
return join(normalizePath(dir), "*")
}
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary. // We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
// Also resolves symlinks so that callers using the result as a cache key // Also resolves symlinks so that callers using the result as a cache key
// always get the same canonical path for a given physical directory. // always get the same canonical path for a given physical directory.