Skip to content
Draft
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
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Added automatic Portable Git Bash downloads on Windows when no bash shell is installed.

### Fixed

- Fixed npm-family package commands on Windows to avoid shell argument splitting when install prefixes contain spaces ([#4623](https://github.com/earendil-works/pi/issues/4623)).
Expand Down
5 changes: 3 additions & 2 deletions packages/coding-agent/docs/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ Pi requires a bash shell on Windows. Checked locations (in order):

1. Custom path from `~/.pi/agent/settings.json`
2. Git Bash (`C:\Program Files\Git\bin\bash.exe`)
3. `bash.exe` on PATH (Cygwin, MSYS2, WSL)
3. Managed Portable Git Bash (`~/.pi/agent/bin/portable-git/bin/bash.exe`)
4. `bash.exe` on PATH (Cygwin, MSYS2, WSL)

For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient.
If no bash is found, Pi downloads Git for Windows Portable Git into `~/.pi/agent/bin/portable-git`. Set `PI_OFFLINE=1` to disable automatic downloads.

## Custom Shell Path

Expand Down
14 changes: 7 additions & 7 deletions packages/coding-agent/src/core/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { truncateToVisualLines } from "../../modes/interactive/components/visual
import { theme } from "../../modes/interactive/theme/theme.js";
import { waitForChildProcess } from "../../utils/child-process.js";
import {
getShellConfig,
ensureShellConfig,
getShellEnv,
killProcessTree,
trackDetachedChildPid,
Expand Down Expand Up @@ -64,13 +64,13 @@ export interface BashOperations {
*/
export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations {
return {
exec: (command, cwd, { onData, signal, timeout, env }) => {
exec: async (command, cwd, { onData, signal, timeout, env }) => {
if (!existsSync(cwd)) {
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
}
const { shell, args } = await ensureShellConfig(options?.shellPath, { silent: true });

return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig(options?.shellPath);
if (!existsSync(cwd)) {
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
return;
}
const child = spawn(shell, [...args, command], {
cwd,
detached: process.platform !== "win32",
Expand Down
12 changes: 8 additions & 4 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipb
import { parseGitUrl } from "../../utils/git.js";
import { getCwdRelativePath } from "../../utils/paths.js";
import { getPiUserAgent } from "../../utils/pi-user-agent.js";
import { killTrackedDetachedChildren } from "../../utils/shell.js";
import { ensureWindowsBash, killTrackedDetachedChildren } from "../../utils/shell.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { checkForNewPiVersion } from "../../utils/version-check.js";
import { ArminComponent } from "./components/armin.js";
Expand Down Expand Up @@ -567,9 +567,13 @@ export class InteractiveMode {
// Load changelog (only show new entries, skip for resumed sessions)
this.changelogMarkdown = this.getChangelogForDisplay();

// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
// Both are needed: fd for autocomplete, rg for grep tool and bash commands
const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
// Ensure fd, rg, and Windows bash are available, downloading missing tools under getBinDir.
// fd is needed for autocomplete, rg for grep and bash commands, bash for shell execution on Windows.
const [fdPath] = await Promise.all([
ensureTool("fd"),
ensureTool("rg"),
ensureWindowsBash(this.settingsManager.getShellPath()),
]);
this.fdPath = fdPath;

if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
Expand Down
95 changes: 77 additions & 18 deletions packages/coding-agent/src/utils/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ import { existsSync } from "node:fs";
import { delimiter } from "node:path";
import { spawn, spawnSync } from "child_process";
import { getBinDir } from "../config.js";
import {
ensureWindowsPortableGitBash,
getManagedWindowsPortableGitBashCandidates,
getManagedWindowsPortableGitBashPath,
} from "./tools-manager.js";

export interface ShellConfig {
shell: string;
args: string[];
}

function isSystemBashLookupDisabled(): boolean {
const value = process.env.__PI_DISABLE_SYSTEM_BASH_LOOKUP;
if (!value) return false;
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}

/**
* Find bash executable on PATH (cross-platform)
*/
Expand Down Expand Up @@ -47,7 +58,7 @@ function findBashOnPath(): string | null {
* Resolve shell configuration based on platform and an optional explicit shell path.
* Resolution order:
* 1. User-specified shellPath
* 2. On Windows: Git Bash in known locations, then bash on PATH
* 2. On Windows: Git Bash in known locations, managed Portable Git Bash, then bash on PATH
* 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh
*/
export function getShellConfig(customShellPath?: string): ShellConfig {
Expand All @@ -60,35 +71,51 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
}

if (process.platform === "win32") {
const skipSystemBashLookup = isSystemBashLookupDisabled();

// 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`);
}
if (!skipSystemBashLookup) {
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)) {
return { shell: path, args: ["-c"] };
for (const path of paths) {
if (existsSync(path)) {
return { shell: path, args: ["-c"] };
}
}
}

const managedPortableGitBash = getManagedWindowsPortableGitBashPath();
if (managedPortableGitBash) {
return { shell: managedPortableGitBash, args: ["-c"] };
}

// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
if (!skipSystemBashLookup) {
const bashOnPath = findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
}
}

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 settings.json\n\n" +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
` 2. Let pi download Portable Git Bash automatically\n` +
` 3. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
" 4. Set shellPath in settings.json\n\n" +
(skipSystemBashLookup ? "System bash lookup disabled by __PI_DISABLE_SYSTEM_BASH_LOOKUP.\n" : "") +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}\n` +
`Searched managed Portable Git Bash in:\n${getManagedWindowsPortableGitBashCandidates()
.map((p) => ` ${p}`)
.join("\n")}`,
);
}

Expand All @@ -105,6 +132,38 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
return { shell: "sh", args: ["-c"] };
}

export async function ensureShellConfig(
customShellPath?: string,
options: { silent?: boolean } = {},
): Promise<ShellConfig> {
try {
return getShellConfig(customShellPath);
} catch (error) {
if (customShellPath || process.platform !== "win32") {
throw error;
}

const installedBash = await ensureWindowsPortableGitBash(options.silent ?? false);
if (installedBash) {
return { shell: installedBash, args: ["-c"] };
}
throw error;
}
}

export async function ensureWindowsBash(
customShellPath?: string,
silent: boolean = false,
): Promise<string | undefined> {
if (process.platform !== "win32") return undefined;
try {
const { shell } = await ensureShellConfig(customShellPath, { silent });
return shell;
} catch {
return undefined;
}
}

export function getShellEnv(): NodeJS.ProcessEnv {
const binDir = getBinDir();
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
Expand Down
Loading
Loading