From 80e62761f7251a104f1b21d9c73920c720f0ec00 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 24 Aug 2026 12:27:03 +0200 Subject: [PATCH] feat(coding-agent): add optional PowerShell tool (#8512) --- packages/coding-agent/README.md | 6 +- .../docs/environment-variables.md | 16 ++-- packages/coding-agent/docs/extensions.md | 11 +-- packages/coding-agent/docs/sdk.md | 9 ++- packages/coding-agent/docs/settings.md | 10 ++- packages/coding-agent/docs/usage.md | 2 +- packages/coding-agent/docs/windows.md | 26 ++++++- packages/coding-agent/src/cli/args.ts | 15 ++-- .../coding-agent/src/core/extensions/index.ts | 3 + .../coding-agent/src/core/extensions/types.ts | 18 +++++ packages/coding-agent/src/core/sdk.ts | 2 + .../coding-agent/src/core/system-prompt.ts | 11 ++- packages/coding-agent/src/core/tools/bash.ts | 76 ++++++++++++++----- packages/coding-agent/src/core/tools/index.ts | 32 +++++++- .../coding-agent/src/core/tools/powershell.ts | 67 ++++++++++++++++ packages/coding-agent/src/index.ts | 13 +++- packages/coding-agent/src/utils/shell.ts | 26 +++++-- .../test/default-tools-setting.test.ts | 13 +++- .../experimental-tool-strict-mode.test.ts | 2 + .../coding-agent/test/powershell-tool.test.ts | 30 ++++++++ ...uiltin-tools-keeps-extension-tools.test.ts | 2 +- .../coding-agent/test/system-prompt.test.ts | 14 ++++ .../tool-system-prompt-contributions.test.ts | 12 ++- 23 files changed, 351 insertions(+), 65 deletions(-) create mode 100644 packages/coding-agent/src/core/tools/powershell.ts create mode 100644 packages/coding-agent/test/powershell-tool.test.ts diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 66a079007..0a6c1e14f 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -584,7 +584,7 @@ cat README.md | pi -p "Summarize this text" | `--no-builtin-tools`, `-nbt` | Disable built-in tools by default but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools by default | -Available built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` +Available built-in tools: `read`, `bash`, `powershell` (Windows), `edit`, `write`, `grep`, `find`, `ls` ### Resource Options @@ -682,7 +682,7 @@ pi --thinking high "Solve this complex problem" | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) | | `VISUAL`, `EDITOR` | Fallback external editor for Ctrl+G when `externalEditor` is unset; defaults to Notepad on Windows and `nano` elsewhere | -Commands run by the LLM-callable bash tool also receive current session metadata: +Commands run by the LLM-callable `bash` and `powershell` tools also receive current session metadata: | Variable | Description | |----------|-------------| @@ -692,7 +692,7 @@ Commands run by the LLM-callable bash tool also receive current session metadata | `PI_MODEL` | Currently selected model ID | | `PI_REASONING_LEVEL` | Current effective reasoning level | -These values are resolved when each command starts. See [Environment Variables](docs/environment-variables.md#bash-tool-session-environment) for semantics, examples, and custom-tool opt-out. +These values are resolved when each command starts. See [Environment Variables](docs/environment-variables.md#shell-tool-session-environment) for semantics, examples, and custom-tool opt-out. --- diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 29c324f63..fe9cafa8b 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -4,7 +4,7 @@ Pi uses environment variables in three ways: - Variables such as `PI_OFFLINE` configure the Pi process. - Pi sets process markers so child processes can identify Pi as the launching agent. -- Commands run by the LLM-callable bash tool receive `PI_*` variables describing the current session. +- Commands run by the LLM-callable shell tools receive `PI_*` variables describing the current session. Provider API-key variables are documented separately in [Providers](providers.md#environment-variables-or-auth-file). @@ -17,9 +17,9 @@ The CLI and RPC entry points set two process markers: Child processes inherit both markers. They are not session-specific and are not set automatically when Pi is embedded through the SDK. -## Bash Tool Session Environment +## Shell Tool Session Environment -Commands run by the bash tool receive the current Pi session state: +Commands run by the `bash` and `powershell` tools receive the current Pi session state: | Variable | Description | |----------|-------------| @@ -29,7 +29,7 @@ Commands run by the bash tool receive the current Pi session state: | `PI_MODEL` | Currently selected model ID | | `PI_REASONING_LEVEL` | Current effective reasoning level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | -The values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next bash command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally. +The values are resolved when each command starts. Switching models or changing the reasoning level therefore affects the next shell command without restarting Pi. `PI_PROVIDER` and `PI_MODEL` identify the selected Pi model, not a different upstream model that a router may choose internally. When asked which model or provider is running, inspect these variables instead of inferring the answer from the system prompt: @@ -46,11 +46,11 @@ if [ -n "$PI_SESSION_FILE" ]; then fi ``` -These variables are injected into the LLM-callable bash tool. They are not injected into user-entered `!` or `!!` commands. +These variables are injected into the LLM-callable `bash` and `powershell` tools. They are not injected into user-entered `!` or `!!` commands. -### Custom Bash Tools +### Custom Shell Tools -Bash tools created with `createBashTool()` expose the session environment by default when registered with Pi. Injection happens before `spawnHook`, so a hook receives the variables in `ctx.env`: +Tools created with `createBashTool()` or `createPowerShellTool()` expose the session environment by default when registered with Pi. Injection happens before `spawnHook`, so a hook receives the variables in `ctx.env`: ```typescript const bashTool = createBashTool(cwd, { @@ -64,7 +64,7 @@ const bashTool = createBashTool(cwd, { Disable session metadata independently of the spawn hook: ```typescript -const bashTool = createBashTool(cwd, { +const powershellTool = createPowerShellTool(cwd, { exposeSessionEnvironment: false, spawnHook: (ctx) => ctx, }); diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 2e069acdf..7643856be 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2059,7 +2059,7 @@ pi.registerTool({ ### Overriding Built-in Tools -Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. +Extensions can override built-in tools (`read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. ```bash # Extension's read tool replaces built-in read @@ -2083,6 +2083,7 @@ See [examples/extensions/tool-override.ts](../examples/extensions/tool-override. Built-in tool implementations: - [read.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/read.ts) - `ReadToolDetails` - [bash.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/bash.ts) - `BashToolDetails` +- [powershell.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/powershell.ts) - `PowerShellToolDetails` - [edit.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts) - [write.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/write.ts) - [grep.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/tools/grep.ts) - `GrepToolDetails` @@ -2118,11 +2119,11 @@ pi.registerTool({ }); ``` -**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` +**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `PowerShellOperations`, `LsOperations`, `GrepOperations`, `FindOperations` For `user_bash`, extensions can reuse pi's local shell backend via `createLocalBashOperations()` instead of reimplementing local process spawning, shell resolution, and process-tree termination. -The bash tool also supports a spawn hook to adjust the command, cwd, or env before execution: +The `bash` and `powershell` tools also support a spawn hook to adjust the command, cwd, or env before execution: ```typescript import { createBashTool } from "@earendil-works/pi-coding-agent"; @@ -2136,7 +2137,7 @@ const bashTool = createBashTool(cwd, { }); ``` -`createBashTool()` exposes the current session to commands through `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`. Injection happens before `spawnHook`, so hooks receive these values in `env` and preserve them when they spread the existing environment as above. Set `exposeSessionEnvironment: false` to disable them: +`createBashTool()` and `createPowerShellTool()` expose the current session to commands through `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`. Injection happens before `spawnHook`, so hooks receive these values in `env` and preserve them when they spread the existing environment as above. Set `exposeSessionEnvironment: false` to disable them: ```typescript const bashTool = createBashTool(cwd, { @@ -2144,7 +2145,7 @@ const bashTool = createBashTool(cwd, { }); ``` -See [Bash tool session environment](environment-variables.md#bash-tool-session-environment) for variable semantics. See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. +See [Shell tool session environment](environment-variables.md#shell-tool-session-environment) for variable semantics. See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. ### Output Truncation diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index a4e4f2276..5b75b2f8a 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -519,7 +519,7 @@ const { session } = await createAgentSession({ resourceLoader: loader }); Specify which built-in tools to enable: -- Built-in tool names: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls` +- Built-in tool names: `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls` - Default built-ins: `read`, `bash`, `edit`, `write` - `noTools: "all"` disables all tools - `noTools: "builtin"` disables default built-ins while keeping extension and custom tools enabled @@ -540,6 +540,11 @@ const { session } = await createAgentSession({ tools: ["read", "bash", "grep"], }); +// Use PowerShell instead of Bash on Windows +const { session } = await createAgentSession({ + tools: ["read", "powershell", "edit", "write"], +}); + // Disable one tool while keeping the rest available const { session } = await createAgentSession({ excludeTools: ["ask_question"], @@ -1196,7 +1201,7 @@ SettingsManager // Tool factories createCodingTools createReadOnlyTools -createReadTool, createBashTool, createEditTool, createWriteTool +createReadTool, createBashTool, createPowerShellTool, createEditTool, createWriteTool createGrepTool, createFindTool, createLsTool // Types diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index b23392843..d0ded5f68 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -220,7 +220,7 @@ Windows paths in JSON must use forward slashes or escaped backslashes: |---------|------|---------|-------------| | `defaultTools` | string[] | - | Built-in tools enabled initially. When omitted, Pi uses its standard defaults | -`defaultTools` selects the built-in tools enabled at startup. Extension and SDK custom tools remain enabled: +`defaultTools` selects the built-in tools enabled at startup. Extension and SDK custom tools remain enabled. Available built-ins are `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, and `ls`: ```json { @@ -228,6 +228,14 @@ Windows paths in JSON must use forward slashes or escaped backslashes: } ``` +On Windows, select `powershell` instead of `bash`, or include both: + +```json +{ + "defaultTools": ["read", "powershell", "edit", "write"] +} +``` + An empty array starts with no built-in tools while preserving extension and SDK custom tools. `--tools` replaces this behavior with a strict allowlist for all tools, `--no-tools` disables all tools, and `--no-builtin-tools` disables the built-in defaults. `--exclude-tools` filters the resulting list. A project `defaultTools` array replaces the global array. ### Sessions diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index bbf60db1e..4db22b8c5 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -213,7 +213,7 @@ cat README.md | pi -p "Summarize this text" | `--no-builtin-tools`, `-nbt` | Disable built-in tools but keep extension/custom tools enabled | | `--no-tools`, `-nt` | Disable all tools | -Built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`. +Built-in tools: `read`, `bash`, `powershell` (Windows), `edit`, `write`, `grep`, `find`, `ls`. ### Resource Options diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 007f649c1..2517fd946 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -1,6 +1,6 @@ # Windows Setup -Pi requires a bash shell on Windows. Checked locations (in order): +Pi uses Git Bash by default on Windows. Checked locations (in order): 1. Custom path from `~/.pi/agent/settings.json` 2. Git Bash (`C:\Program Files\Git\bin\bash.exe`) @@ -8,7 +8,29 @@ Pi requires a bash shell on Windows. Checked locations (in order): For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient. -## Custom Shell Path +## PowerShell Tool + +The optional `powershell` tool runs commands through `pwsh.exe` when available, otherwise Windows PowerShell. It starts PowerShell with `-NoProfile -NonInteractive -ExecutionPolicy Bypass`. Administrator-enforced execution policies can still take precedence. + +Use `defaultTools` to replace the model-facing `bash` tool: + +```json +{ + "defaultTools": ["read", "powershell", "edit", "write"] +} +``` + +Or enable both while comparing behavior: + +```json +{ + "defaultTools": ["read", "bash", "powershell", "edit", "write"] +} +``` + +The `!` and `!!` editor commands still use Bash. + +## Custom Bash Path ```json { diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 8e03419e6..8ad5da63e 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -435,12 +435,13 @@ ${chalk.bold("Environment Variables:")} PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) ${chalk.bold("Built-in Tool Names:")} - read - Read file contents - bash - Execute bash commands - edit - Edit files with find/replace - write - Write files (creates/overwrites) - grep - Search file contents (read-only, off by default) - find - Find files by glob pattern (read-only, off by default) - ls - List directory contents (read-only, off by default) + read - Read file contents + bash - Execute bash commands + powershell - Execute PowerShell commands on Windows + edit - Edit files with find/replace + write - Write files (creates/overwrites) + grep - Search file contents (read-only, off by default) + find - Find files by glob pattern (read-only, off by default) + ls - List directory contents (read-only, off by default) `); } diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 4ef89cff9..90c786041 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -105,6 +105,8 @@ export type { MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, + PowerShellToolCallEvent, + PowerShellToolResultEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, @@ -180,6 +182,7 @@ export { isFindToolResult, isGrepToolResult, isLsToolResult, + isPowerShellToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 9303c0bc4..bade46419 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -78,6 +78,8 @@ import type { GrepToolInput, LsToolDetails, LsToolInput, + PowerShellToolDetails, + PowerShellToolInput, ReadToolDetails, ReadToolInput, WriteToolInput, @@ -876,6 +878,11 @@ export interface BashToolCallEvent extends ToolCallEventBase { input: BashToolInput; } +export interface PowerShellToolCallEvent extends ToolCallEventBase { + toolName: "powershell"; + input: PowerShellToolInput; +} + export interface ReadToolCallEvent extends ToolCallEventBase { toolName: "read"; input: ReadToolInput; @@ -919,6 +926,7 @@ export interface CustomToolCallEvent extends ToolCallEventBase { */ export type ToolCallEvent = | BashToolCallEvent + | PowerShellToolCallEvent | ReadToolCallEvent | EditToolCallEvent | WriteToolCallEvent @@ -942,6 +950,11 @@ export interface BashToolResultEvent extends ToolResultEventBase { details: BashToolDetails | undefined; } +export interface PowerShellToolResultEvent extends ToolResultEventBase { + toolName: "powershell"; + details: PowerShellToolDetails | undefined; +} + export interface ReadToolResultEvent extends ToolResultEventBase { toolName: "read"; details: ReadToolDetails | undefined; @@ -980,6 +993,7 @@ export interface CustomToolResultEvent extends ToolResultEventBase { /** Fired after a tool executes. Can modify result. */ export type ToolResultEvent = | BashToolResultEvent + | PowerShellToolResultEvent | ReadToolResultEvent | EditToolResultEvent | WriteToolResultEvent @@ -992,6 +1006,9 @@ export type ToolResultEvent = export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent { return e.toolName === "bash"; } +export function isPowerShellToolResult(e: ToolResultEvent): e is PowerShellToolResultEvent { + return e.toolName === "powershell"; +} export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent { return e.toolName === "read"; } @@ -1032,6 +1049,7 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { * CustomToolCallEvent.toolName is `string` which overlaps with all literals. */ export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent; +export function isToolCallEventType(toolName: "powershell", event: ToolCallEvent): event is PowerShellToolCallEvent; export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent; export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent; export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 8af041a57..adbf102a3 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -23,6 +23,7 @@ import { createFindTool, createGrepTool, createLsTool, + createPowerShellTool, createReadOnlyTools, createReadTool, createWriteTool, @@ -125,6 +126,7 @@ export { createGrepTool, createFindTool, createLsTool, + createPowerShellTool, }; // Helper Functions diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 3b1aa6334..ec450678a 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -95,14 +95,21 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { }; const hasBash = tools.includes("bash"); + const hasPowerShell = tools.includes("powershell"); const hasGrep = tools.includes("grep"); const hasFind = tools.includes("find"); const hasLs = tools.includes("ls"); const hasRead = tools.includes("read"); // File exploration guidelines - if (hasBash && !hasGrep && !hasFind && !hasLs) { - addGuideline("Use bash for file operations like ls, rg, find"); + if ((hasBash || hasPowerShell) && !hasGrep && !hasFind && !hasLs) { + if (hasBash && hasPowerShell) { + addGuideline("Use bash or PowerShell for file operations like listing, searching, and finding files"); + } else if (hasPowerShell) { + addGuideline("Use PowerShell for file operations like listing, searching, and finding files"); + } else { + addGuideline("Use bash for file operations like ls, rg, find"); + } } for (const guideline of promptGuidelines ?? []) { diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 9c745df59..b3139b85d 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -12,6 +12,7 @@ import { getShellConfig, getShellEnv, killProcessTree, + type ShellConfig, trackDetachedChildPid, untrackDetachedChildPid, } from "../../utils/shell.ts"; @@ -39,7 +40,7 @@ function resolveTimeoutMs(timeout: number | undefined): number | undefined { } const bashSchema = Type.Object({ - command: Type.String({ description: "Bash command to execute" }), + command: Type.String({ description: "Shell command to execute" }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), }); @@ -79,24 +80,19 @@ export interface BashOperations { ) => Promise<{ exitCode: number | null }>; } -/** - * Create bash operations using pi's built-in local shell execution backend. - * - * This is useful for extensions that intercept user_bash and still want pi's - * standard local shell behavior while wrapping or rewriting commands. - */ -export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { +/** Shared process execution used by the built-in shell tools. */ +export function createLocalShellOperations(shellName: string, resolveShellConfig: () => ShellConfig): BashOperations { return { exec: async (command, cwd, { onData, signal, timeout, env }) => { const timeoutMs = resolveTimeoutMs(timeout); if (signal?.aborted) { throw new Error("aborted"); } - const shellConfig = getShellConfig(options?.shellPath); + const shellConfig = resolveShellConfig(); try { await fsAccess(cwd, constants.F_OK); } catch { - throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`); + throw new Error(`Working directory does not exist: ${cwd}\nCannot execute ${shellName} commands.`); } const commandFromStdin = shellConfig.commandTransport === "stdin"; @@ -153,6 +149,16 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas }; } +/** + * Create bash operations using pi's built-in local shell execution backend. + * + * This is useful for extensions that intercept user_bash and still want pi's + * standard local shell behavior while wrapping or rewriting commands. + */ +export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { + return createLocalShellOperations("bash", () => getShellConfig(options?.shellPath)); +} + export interface BashSpawnContext { command: string; cwd: string; @@ -205,7 +211,7 @@ export interface BashToolOptions { const BASH_PREVIEW_LINES = 5; const BASH_UPDATE_THROTTLE_MS = 100; -type BashRenderState = { +export type BashRenderState = { startedAt: number | undefined; endedAt: number | undefined; interval: NodeJS.Timeout | undefined; @@ -229,12 +235,12 @@ function formatDuration(ms: number): string { return `${(ms / 1000).toFixed(1)}s`; } -function formatBashCall(args: { command?: string; timeout?: number } | undefined): string { +function formatShellCall(args: { command?: string; timeout?: number } | undefined, prompt: string): string { const command = str(args?.command); const timeout = args?.timeout as number | undefined; const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "..."); - return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix; + return theme.fg("toolTitle", theme.bold(`${prompt} ${commandDisplay}`)) + timeoutSuffix; } function rebuildBashResultRenderComponent( @@ -319,8 +325,19 @@ function rebuildBashResultRenderComponent( } } -export function createBashToolDefinition( +export interface ShellToolConfig { + name: string; + label: string; + shellName: string; + prompt: string; + promptSnippet: string; + promptGuidelines?: readonly string[]; + tempFilePrefix: string; +} + +export function createShellToolDefinition( cwd: string, + config: ShellToolConfig, options?: BashToolOptions, ): ToolDefinition { const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath }); @@ -328,11 +345,11 @@ export function createBashToolDefinition( const exposeSessionEnvironment = options?.exposeSessionEnvironment ?? true; const spawnHook = options?.spawnHook; return { - name: "bash", - label: "bash", - description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, - promptSnippet: bashToolSystemPromptContribution.snippet, - promptGuidelines: exposeSessionEnvironment ? [...bashToolSystemPromptContribution.guidelines] : undefined, + name: config.name, + label: config.label, + description: `Execute a ${config.shellName} command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + promptSnippet: config.promptSnippet, + promptGuidelines: exposeSessionEnvironment && config.promptGuidelines ? [...config.promptGuidelines] : undefined, parameters: bashSchema, constrainedSampling: getExperimentalToolSampling(), async execute( @@ -344,7 +361,7 @@ export function createBashToolDefinition( ) { const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook, exposeSessionEnvironment, ctx); - const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" }); + const output = new OutputAccumulator({ tempFilePrefix: config.tempFilePrefix }); let acceptingOutput = true; let updateTimer: NodeJS.Timeout | undefined; let updateDirty = false; @@ -468,7 +485,7 @@ export function createBashToolDefinition( state.endedAt = undefined; } const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatBashCall(args)); + text.setText(formatShellCall(args, config.prompt)); return text; }, renderResult(result, options, _theme, context) { @@ -499,6 +516,23 @@ export function createBashToolDefinition( }; } +const bashToolConfig: ShellToolConfig = { + name: "bash", + label: "bash", + shellName: "bash", + prompt: "$", + promptSnippet: bashToolSystemPromptContribution.snippet, + promptGuidelines: bashToolSystemPromptContribution.guidelines, + tempFilePrefix: "pi-bash", +}; + +export function createBashToolDefinition( + cwd: string, + options?: BashToolOptions, +): ToolDefinition { + return createShellToolDefinition(cwd, bashToolConfig, options); +} + export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { const definition = createBashToolDefinition(cwd, options); const tool = wrapToolDefinition(definition); diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index e55f91406..5e8fd5b36 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -42,6 +42,17 @@ export { type LsToolInput, type LsToolOptions, } from "./ls.ts"; +export { + createLocalPowerShellOperations, + createPowerShellTool, + createPowerShellToolDefinition, + type PowerShellOperations, + type PowerShellSpawnContext, + type PowerShellSpawnHook, + type PowerShellToolDetails, + type PowerShellToolInput, + type PowerShellToolOptions, +} from "./powershell.ts"; export { createReadTool, createReadToolDefinition, @@ -75,17 +86,28 @@ import { createEditTool, createEditToolDefinition, type EditToolOptions } from " import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.ts"; import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts"; import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts"; +import { createPowerShellTool, createPowerShellToolDefinition, type PowerShellToolOptions } from "./powershell.ts"; import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts"; import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts"; export type Tool = AgentTool; export type ToolDef = ToolDefinition; -export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"; -export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]); +export type ToolName = "read" | "bash" | "powershell" | "edit" | "write" | "grep" | "find" | "ls"; +export const allToolNames: Set = new Set([ + "read", + "bash", + "powershell", + "edit", + "write", + "grep", + "find", + "ls", +]); export interface ToolsOptions { read?: ReadToolOptions; bash?: BashToolOptions; + powershell?: PowerShellToolOptions; write?: WriteToolOptions; edit?: EditToolOptions; grep?: GrepToolOptions; @@ -99,6 +121,8 @@ export function createToolDefinition(toolName: ToolName, cwd: string, options?: return createReadToolDefinition(cwd, options?.read); case "bash": return createBashToolDefinition(cwd, options?.bash); + case "powershell": + return createPowerShellToolDefinition(cwd, options?.powershell); case "edit": return createEditToolDefinition(cwd, options?.edit); case "write": @@ -120,6 +144,8 @@ export function createTool(toolName: ToolName, cwd: string, options?: ToolsOptio return createReadTool(cwd, options?.read); case "bash": return createBashTool(cwd, options?.bash); + case "powershell": + return createPowerShellTool(cwd, options?.powershell); case "edit": return createEditTool(cwd, options?.edit); case "write": @@ -157,6 +183,7 @@ export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): R return { read: createReadToolDefinition(cwd, options?.read), bash: createBashToolDefinition(cwd, options?.bash), + powershell: createPowerShellToolDefinition(cwd, options?.powershell), edit: createEditToolDefinition(cwd, options?.edit), write: createWriteToolDefinition(cwd, options?.write), grep: createGrepToolDefinition(cwd, options?.grep), @@ -187,6 +214,7 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record {} + +export function createLocalPowerShellOperations(): PowerShellOperations { + const operations = createLocalShellOperations("PowerShell", getPowerShellConfig); + return { + exec: (command, cwd, options) => operations.exec(`${UTF8_OUTPUT_PREFIX}${command}`, cwd, options), + }; +} + +const powershellToolConfig: ShellToolConfig = { + name: "powershell", + label: "powershell", + shellName: "PowerShell", + prompt: "PS>", + promptSnippet: powershellToolSystemPromptContribution.snippet, + promptGuidelines: powershellToolSystemPromptContribution.guidelines, + tempFilePrefix: "pi-powershell", +}; + +export function createPowerShellToolDefinition( + cwd: string, + options?: PowerShellToolOptions, +): ReturnType { + return createShellToolDefinition(cwd, powershellToolConfig, { + ...options, + operations: options?.operations ?? createLocalPowerShellOperations(), + }); +} + +export function createPowerShellTool(cwd: string, options?: PowerShellToolOptions): ReturnType { + const definition = createPowerShellToolDefinition(cwd, options); + const tool = wrapToolDefinition(definition); + Object.assign(tool, { + promptSnippet: definition.promptSnippet, + promptGuidelines: definition.promptGuidelines, + }); + return tool; +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 6d64d70c8..210b84010 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -107,6 +107,7 @@ export type { MessageRenderOptions, MessageStartEvent, MessageUpdateEvent, + PowerShellToolCallEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, @@ -159,6 +160,7 @@ export { isFindToolResult, isGrepToolResult, isLsToolResult, + isPowerShellToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, @@ -218,6 +220,7 @@ export { createFindTool, createGrepTool, createLsTool, + createPowerShellTool, createReadOnlyTools, createReadTool, createWriteTool, @@ -285,7 +288,9 @@ export { createFindToolDefinition, createGrepToolDefinition, createLocalBashOperations, + createLocalPowerShellOperations, createLsToolDefinition, + createPowerShellToolDefinition, createReadToolDefinition, createWriteToolDefinition, DEFAULT_MAX_BYTES, @@ -307,6 +312,12 @@ export { type LsToolDetails, type LsToolInput, type LsToolOptions, + type PowerShellOperations, + type PowerShellSpawnContext, + type PowerShellSpawnHook, + type PowerShellToolDetails, + type PowerShellToolInput, + type PowerShellToolOptions, type ReadOperations, type ReadToolDetails, type ReadToolInput, @@ -405,4 +416,4 @@ export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts"; export { convertToPng } from "./utils/image-convert.ts"; export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts"; // Shell utilities -export { getShellConfig } from "./utils/shell.ts"; +export { getPowerShellConfig, getShellConfig } from "./utils/shell.ts"; diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 2cafa595e..fae75267e 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -21,11 +21,11 @@ function getBashShellConfig(shell: string): ShellConfig { return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] }; } -function findBashOnPath(): string | null { +function findExecutableOnPath(executable: string): string | null { if (process.platform === "win32") { // Windows: Use 'where' and verify file exists (where can return non-existent paths) try { - const result = spawnSync("where", ["bash.exe"], { + const result = spawnSync("where", [executable], { encoding: "utf-8", timeout: 5000, windowsHide: true, @@ -44,7 +44,7 @@ function findBashOnPath(): string | null { // Unix: Use 'which' and trust its output (handles Termux and special filesystems) try { - const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("which", [executable], { encoding: "utf-8", timeout: 5000 }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch) { @@ -92,7 +92,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { } // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) - const bashOnPath = findBashOnPath(); + const bashOnPath = findExecutableOnPath("bash.exe"); if (bashOnPath) { return getBashShellConfig(bashOnPath); } @@ -111,7 +111,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { return getBashShellConfig("/bin/bash"); } - const bashOnPath = findBashOnPath(); + const bashOnPath = findExecutableOnPath("bash"); if (bashOnPath) { return getBashShellConfig(bashOnPath); } @@ -119,6 +119,22 @@ export function getShellConfig(customShellPath?: string): ShellConfig { return { shell: "sh", args: ["-c"] }; } +export const POWERSHELL_ARGS = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"] as const; + +/** Resolve PowerShell on Windows, preferring PowerShell 7 when available. */ +export function getPowerShellConfig(): ShellConfig { + if (process.platform !== "win32") { + throw new Error("The powershell tool is only available on Windows."); + } + + const shell = findExecutableOnPath("pwsh.exe") ?? findExecutableOnPath("powershell.exe"); + if (!shell) { + throw new Error("No PowerShell executable found. Install PowerShell or add powershell.exe/pwsh.exe to PATH."); + } + + return { shell, args: [...POWERSHELL_ARGS] }; +} + export function getShellEnv(): NodeJS.ProcessEnv { const binDir = getBinDir(); const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH"; diff --git a/packages/coding-agent/test/default-tools-setting.test.ts b/packages/coding-agent/test/default-tools-setting.test.ts index 69adbceb5..5b9331400 100644 --- a/packages/coding-agent/test/default-tools-setting.test.ts +++ b/packages/coding-agent/test/default-tools-setting.test.ts @@ -63,13 +63,22 @@ describe("defaultTools setting", () => { .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "edit", "find", "grep", "ls", "powershell", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["grep", "find"]); expect(session.systemPrompt).toContain("- grep:"); expect(session.systemPrompt).not.toContain("- read:"); session.dispose(); }); + it("can select powershell instead of bash", async () => { + const session = await createSession(["read", "powershell", "edit", "write"]); + + expect(session.getActiveToolNames()).toEqual(["read", "powershell", "edit", "write"]); + expect(session.systemPrompt).toContain("- powershell: Execute PowerShell commands"); + expect(session.systemPrompt).not.toContain("- bash:"); + session.dispose(); + }); + it("keeps extension and SDK custom tools enabled", async () => { const session = await createSession( ["grep"], @@ -143,7 +152,7 @@ describe("defaultTools setting", () => { .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "edit", "find", "grep", "ls", "powershell", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["ls"]); session.dispose(); }); diff --git a/packages/coding-agent/test/experimental-tool-strict-mode.test.ts b/packages/coding-agent/test/experimental-tool-strict-mode.test.ts index 07a00d94c..2e8b0334d 100644 --- a/packages/coding-agent/test/experimental-tool-strict-mode.test.ts +++ b/packages/coding-agent/test/experimental-tool-strict-mode.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { createBashToolDefinition, createEditToolDefinition, + createPowerShellToolDefinition, createReadToolDefinition, createWriteToolDefinition, } from "../src/core/tools/index.ts"; @@ -10,6 +11,7 @@ function createBuiltInTools() { return [ createReadToolDefinition(process.cwd()), createBashToolDefinition(process.cwd()), + createPowerShellToolDefinition(process.cwd()), createEditToolDefinition(process.cwd()), createWriteToolDefinition(process.cwd()), ]; diff --git a/packages/coding-agent/test/powershell-tool.test.ts b/packages/coding-agent/test/powershell-tool.test.ts new file mode 100644 index 000000000..22bc193c8 --- /dev/null +++ b/packages/coding-agent/test/powershell-tool.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { createPowerShellTool } from "../src/core/tools/powershell.ts"; +import { getPowerShellConfig, POWERSHELL_ARGS } from "../src/utils/shell.ts"; + +function getTextOutput(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content + .filter((content) => content.type === "text") + .map((content) => content.text ?? "") + .join("\n"); +} + +describe("powershell tool", () => { + it("uses process-local execution policy bypass", () => { + expect(POWERSHELL_ARGS).toEqual(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"]); + }); + + it.skipIf(process.platform !== "win32")("executes PowerShell commands with UTF-8 output", async () => { + const config = getPowerShellConfig(); + expect(config.args).toEqual(POWERSHELL_ARGS); + + const tool = createPowerShellTool(process.cwd()); + const result = await tool.execute("powershell-test", { + command: "Write-Output 'héllo €'; Get-ExecutionPolicy -Scope Process", + }); + const output = getTextOutput(result); + + expect(output).toContain("héllo €"); + expect(output).toContain("Bypass"); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts index 3d900ee36..86ad26a08 100644 --- a/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts +++ b/packages/coding-agent/test/suite/regressions/3592-no-builtin-tools-keeps-extension-tools.test.ts @@ -78,7 +78,7 @@ describe("regression #3592: no-builtin-tools keeps extension tools enabled", () .getAllTools() .map((tool) => tool.name) .sort(), - ).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "read", "write"]); + ).toEqual(["bash", "dynamic_tool", "edit", "find", "grep", "ls", "powershell", "read", "write"]); expect(session.getActiveToolNames()).toEqual(["dynamic_tool"]); expect(session.systemPrompt).toContain("- dynamic_tool: Run dynamic test behavior"); expect(session.systemPrompt).not.toContain("- read:"); diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index 6e38fcb0f..41d83207b 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -46,6 +46,20 @@ describe("buildSystemPrompt", () => { expect(prompt).toContain("- write:"); }); + test.each([ + [["powershell"], "Use PowerShell for file operations"], + [["bash", "powershell"], "Use bash or PowerShell for file operations"], + ] as const)("uses shell-specific guidance for %j", (selectedTools, expected) => { + const prompt = buildSystemPrompt({ + selectedTools: [...selectedTools], + contextFiles: [], + skills: [], + cwd: process.cwd(), + }); + + expect(prompt).toContain(expected); + }); + test("instructs models to resolve pi docs and examples under absolute base paths", () => { const prompt = buildSystemPrompt({ contextFiles: [], diff --git a/packages/coding-agent/test/tool-system-prompt-contributions.test.ts b/packages/coding-agent/test/tool-system-prompt-contributions.test.ts index 4a2927c16..ed39fa508 100644 --- a/packages/coding-agent/test/tool-system-prompt-contributions.test.ts +++ b/packages/coding-agent/test/tool-system-prompt-contributions.test.ts @@ -4,12 +4,17 @@ import { createEditToolDefinition, editToolSystemPromptContribution } from "../s import { createFindToolDefinition, findToolSystemPromptContribution } from "../src/core/tools/find.ts"; import { createGrepToolDefinition, grepToolSystemPromptContribution } from "../src/core/tools/grep.ts"; import { createLsToolDefinition, lsToolSystemPromptContribution } from "../src/core/tools/ls.ts"; +import { + createPowerShellToolDefinition, + powershellToolSystemPromptContribution, +} from "../src/core/tools/powershell.ts"; import { createReadToolDefinition, readToolSystemPromptContribution } from "../src/core/tools/read.ts"; import { createWriteToolDefinition, writeToolSystemPromptContribution } from "../src/core/tools/write.ts"; const cases = [ ["read", readToolSystemPromptContribution, createReadToolDefinition], ["bash", bashToolSystemPromptContribution, createBashToolDefinition], + ["powershell", powershellToolSystemPromptContribution, createPowerShellToolDefinition], ["edit", editToolSystemPromptContribution, createEditToolDefinition], ["write", writeToolSystemPromptContribution, createWriteToolDefinition], ["grep", grepToolSystemPromptContribution, createGrepToolDefinition], @@ -28,8 +33,11 @@ describe("built-in tool system prompt contributions", () => { }, ); - test("keeps bash session-environment guidance conditional", () => { - const definition = createBashToolDefinition("/workspace", { exposeSessionEnvironment: false }); + test.each([ + ["bash", createBashToolDefinition], + ["powershell", createPowerShellToolDefinition], + ] as const)("keeps %s session-environment guidance conditional", (_name, createDefinition) => { + const definition = createDefinition("/workspace", { exposeSessionEnvironment: false }); expect(definition.promptGuidelines).toBeUndefined(); });