feat(coding-agent): add optional PowerShell tool (#8512)

This commit is contained in:
Armin Ronacher 2026-08-24 12:27:03 +02:00 • committed by GitHub
parent 4af9d21d3b
commit 80e62761f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 351 additions and 65 deletions

View file

@ -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.
---

View file

@ -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,
});

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
{

View file

@ -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)
`);
}

View file

@ -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,

View file

@ -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;

View file

@ -23,6 +23,7 @@ import {
createFindTool,
createGrepTool,
createLsTool,
createPowerShellTool,
createReadOnlyTools,
createReadTool,
createWriteTool,
@ -125,6 +126,7 @@ export {
createGrepTool,
createFindTool,
createLsTool,
createPowerShellTool,
};
// Helper Functions

View file

@ -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 ?? []) {

View file

@ -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<typeof bashSchema, BashToolDetails | undefined, BashRenderState> {
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<typeof bashSchema, BashToolDetails | undefined, BashRenderState> {
return createShellToolDefinition(cwd, bashToolConfig, options);
}
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
const definition = createBashToolDefinition(cwd, options);
const tool = wrapToolDefinition(definition);

View file

@ -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<any>;
export type ToolDef = ToolDefinition<any, any>;
export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls";
export const allToolNames: Set<ToolName> = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
export type ToolName = "read" | "bash" | "powershell" | "edit" | "write" | "grep" | "find" | "ls";
export const allToolNames: Set<ToolName> = 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<Tool
return {
read: createReadTool(cwd, options?.read),
bash: createBashTool(cwd, options?.bash),
powershell: createPowerShellTool(cwd, options?.powershell),
edit: createEditTool(cwd, options?.edit),
write: createWriteTool(cwd, options?.write),
grep: createGrepTool(cwd, options?.grep),

View file

@ -0,0 +1,67 @@
import { getPowerShellConfig } from "../../utils/shell.ts";
import {
type BashOperations,
type BashSpawnContext,
type BashSpawnHook,
type BashToolDetails,
type BashToolInput,
type BashToolOptions,
type createBashTool,
createLocalShellOperations,
createShellToolDefinition,
type ShellToolConfig,
} from "./bash.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
const UTF8_OUTPUT_PREFIX = "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n";
export const powershellToolSystemPromptContribution = {
snippet: "Execute PowerShell commands",
guidelines: ["You can inspect PI_* environment variables for current model and session details."],
} as const;
export type PowerShellOperations = BashOperations;
export type PowerShellSpawnContext = BashSpawnContext;
export type PowerShellSpawnHook = BashSpawnHook;
export type PowerShellToolDetails = BashToolDetails;
export type PowerShellToolInput = BashToolInput;
export interface PowerShellToolOptions
extends Pick<BashToolOptions, "operations" | "exposeSessionEnvironment" | "spawnHook"> {}
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<typeof createShellToolDefinition> {
return createShellToolDefinition(cwd, powershellToolConfig, {
...options,
operations: options?.operations ?? createLocalPowerShellOperations(),
});
}
export function createPowerShellTool(cwd: string, options?: PowerShellToolOptions): ReturnType<typeof createBashTool> {
const definition = createPowerShellToolDefinition(cwd, options);
const tool = wrapToolDefinition(definition);
Object.assign(tool, {
promptSnippet: definition.promptSnippet,
promptGuidelines: definition.promptGuidelines,
});
return tool;
}

View file

@ -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";

View file

@ -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";

View file

@ -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();
});

View file

@ -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()),
];

View file

@ -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");
});
});

View file

@ -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:");

View file

@ -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: [],

View file

@ -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();
});