co-mono/packages/coding-agent/src/utils/shell.ts
Daniel Nouri 8fc0610a53 fix: Use bash instead of sh on Unix systems
The bash tool is named "bash" and described as executing bash commands,
but was using sh on Unix. On many distros (Ubuntu, Debian, Alpine, etc.),
/bin/sh is a POSIX-only shell that doesn't support bash syntax like [[ ]],
arrays, or here-strings. This caused the LLM to write bash syntax that
failed, wasting tokens on rewrites.

Now prefers /bin/bash on Unix, falling back to sh only if bash isn't found.
2025-12-26 23:12:57 +01:00

139 lines
4 KiB
TypeScript

import { existsSync } from "node:fs";
import { spawn, spawnSync } from "child_process";
import { SettingsManager } from "../core/settings-manager.js";
let cachedShellConfig: { shell: string; args: string[] } | null = null;
/**
* Find bash executable on PATH (Windows)
*/
function findBashOnPath(): string | null {
try {
const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 });
if (result.status === 0 && result.stdout) {
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
if (firstMatch && existsSync(firstMatch)) {
return firstMatch;
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Get shell configuration based on platform.
* Resolution order:
* 1. User-specified shellPath in settings.json
* 2. On Windows: Git Bash in known locations, then bash on PATH
* 3. On Unix: /bin/bash
* 4. Fallback: sh
*/
export function getShellConfig(): { shell: string; args: string[] } {
if (cachedShellConfig) {
return cachedShellConfig;
}
const settings = SettingsManager.create();
const customShellPath = settings.getShellPath();
// 1. Check user-specified shell path
if (customShellPath) {
if (existsSync(customShellPath)) {
cachedShellConfig = { shell: customShellPath, args: ["-c"] };
return cachedShellConfig;
}
throw new Error(
`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.pi/agent/settings.json`,
);
}
if (process.platform === "win32") {
// 2. Try Git Bash in known locations
const paths: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
for (const path of paths) {
if (existsSync(path)) {
cachedShellConfig = { shell: path, args: ["-c"] };
return cachedShellConfig;
}
}
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findBashOnPath();
if (bashOnPath) {
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
return cachedShellConfig;
}
throw new Error(
`No bash shell found. Options:\n` +
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
` 3. Set shellPath in ~/.pi/agent/settings.json\n\n` +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
);
}
// Unix: prefer bash over sh
if (existsSync("/bin/bash")) {
cachedShellConfig = { shell: "/bin/bash", args: ["-c"] };
return cachedShellConfig;
}
cachedShellConfig = { shell: "sh", args: ["-c"] };
return cachedShellConfig;
}
/**
* Sanitize binary output for display/storage.
* Removes characters that crash string-width or cause display issues:
* - Control characters (except tab, newline, carriage return)
* - Lone surrogates
* - Unicode Format characters (crash string-width due to a bug)
*/
export function sanitizeBinaryOutput(str: string): string {
// Fast path: use regex to remove problematic characters
// - \p{Format}: Unicode format chars like \u0601 that crash string-width
// - \p{Surrogate}: Lone surrogates from invalid UTF-8
// - Control chars except \t \n \r
return str.replace(/[\p{Format}\p{Surrogate}]/gu, "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
}
/**
* Kill a process and all its children (cross-platform)
*/
export function killProcessTree(pid: number): void {
if (process.platform === "win32") {
// Use taskkill on Windows to kill process tree
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
});
} catch {
// Ignore errors if taskkill fails
}
} else {
// Use SIGKILL on Unix/Linux/Mac
try {
process.kill(-pid, "SIGKILL");
} catch {
// Fallback to killing just the child if process group kill fails
try {
process.kill(pid, "SIGKILL");
} catch {
// Process already dead
}
}
}
}