Skip to content

Commit 5392cde

Browse files
committed
feat(windows): default to PowerShell for shell tools and ! commands
Enable PowerShellTool by default on Windows (opt-out via CLAUDE_CODE_USE_POWERSHELL_TOOL=0). Prefer powershell for input-box ! routing when available. settings.defaultShell still overrides. macOS/Linux unchanged.
1 parent b4149bb commit 5392cde

6 files changed

Lines changed: 116 additions & 18 deletions

File tree

src/constants/prompts.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { TASK_CREATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/Tas
2020
import type { Tools } from '../Tool.js'
2121
import type { Command } from '../types/command.js'
2222
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
23+
import { POWERSHELL_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/PowerShellTool/toolName.js'
2324
import {
2425
getCanonicalName,
2526
getMarketingNameForModel,
@@ -273,8 +274,20 @@ function getUsingYourToolsSection(enabledTools: Set<string>): string {
273274
return [`# Using your tools`, ...prependBullets(items)].join(`\n`)
274275
}
275276

277+
const hasPowerShell = enabledTools.has(POWERSHELL_TOOL_NAME)
278+
const hasBash = enabledTools.has(BASH_TOOL_NAME)
279+
const shellToolGuidance = hasPowerShell
280+
? hasBash
281+
? `On Windows, prefer the ${POWERSHELL_TOOL_NAME} tool for terminal operations (git, npm, docker, builds, tests, system commands). Use ${BASH_TOOL_NAME} only when the user asks for bash/Git Bash or a command is clearly bash-only. Prefer dedicated tools over shell equivalents (e.g., ${FILE_READ_TOOL_NAME} over Get-Content/cat, ${FILE_EDIT_TOOL_NAME} over sed, ${GLOB_TOOL_NAME} over find, ${GREP_TOOL_NAME} over grep/Select-String).`
282+
: `Prefer dedicated tools over ${POWERSHELL_TOOL_NAME} equivalents (e.g., ${FILE_READ_TOOL_NAME} over Get-Content, ${FILE_EDIT_TOOL_NAME} over (Get-Content) -replace, ${GLOB_TOOL_NAME} over Get-ChildItem -Recurse, ${GREP_TOOL_NAME} over Select-String). Reserve ${POWERSHELL_TOOL_NAME} for shell operations: package installs, test runners, build commands, git operations.`
283+
: `Prefer dedicated tools over ${BASH_TOOL_NAME} equivalents (e.g., ${FILE_READ_TOOL_NAME} over cat, ${FILE_EDIT_TOOL_NAME} over sed, ${GLOB_TOOL_NAME} over find, ${GREP_TOOL_NAME} over grep). Reserve ${BASH_TOOL_NAME} for shell operations: package installs, test runners, build commands, git operations.`
284+
285+
const coreToolsList = hasPowerShell
286+
? `Core tools (Read, Edit, Write, Glob, Grep, ${POWERSHELL_TOOL_NAME}${hasBash ? `, ${BASH_TOOL_NAME}` : ''}, Agent, WebFetch, WebSearch, AskUserQuestion, NotebookEdit, TaskCreate, TaskUpdate, TaskList, TaskGet, TodoWrite, Skill, CronCreate, CronDelete, CronList, Config, LSP, MCPTool) can be called directly as needed. ${shellToolGuidance}`
287+
: `Core tools (Read, Edit, Write, Glob, Grep, Bash, Agent, WebFetch, WebSearch, AskUserQuestion, NotebookEdit, TaskCreate, TaskUpdate, TaskList, TaskGet, TodoWrite, Skill, CronCreate, CronDelete, CronList, Config, LSP, MCPTool) can be called directly as needed. ${shellToolGuidance}`
288+
276289
const items = [
277-
`Core tools (Read, Edit, Write, Glob, Grep, Bash, Agent, WebFetch, WebSearch, AskUserQuestion, NotebookEdit, TaskCreate, TaskUpdate, TaskList, TaskGet, TodoWrite, Skill, CronCreate, CronDelete, CronList, Config, LSP, MCPTool) can be called directly as needed. Prefer dedicated tools over ${BASH_TOOL_NAME} equivalents (e.g., ${FILE_READ_TOOL_NAME} over cat, ${FILE_EDIT_TOOL_NAME} over sed, ${GLOB_TOOL_NAME} over find, ${GREP_TOOL_NAME} over grep). Reserve ${BASH_TOOL_NAME} for shell operations: package installs, test runners, build commands, git operations.`,
290+
coreToolsList,
278291
`Search before saying unknown — when the user references a file, function, or module you have not seen, search with ${GREP_TOOL_NAME}/${GLOB_TOOL_NAME} first.`,
279292
taskToolName
280293
? `Break down and manage your work with the ${taskToolName} tool. Mark each task as completed as soon as you are done.`

src/services/tips/tipRegistry.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,11 +238,12 @@ const externalTips: Tip[] = [
238238
{
239239
id: 'powershell-tool-env',
240240
content: async () =>
241-
'Set CLAUDE_CODE_USE_POWERSHELL_TOOL=1 to enable the PowerShell tool (preview)',
242-
cooldownSessions: 10,
241+
'PowerShell is the default shell on Windows. Set CLAUDE_CODE_USE_POWERSHELL_TOOL=0 or defaultShell=bash to prefer Bash/Git Bash instead.',
242+
cooldownSessions: 20,
243243
isRelevant: async () =>
244244
getPlatform() === 'windows' &&
245-
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL === undefined,
245+
process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL === undefined &&
246+
getSettings_DEPRECATED().defaultShell === undefined,
246247
},
247248
{
248249
id: 'status-line',

src/utils/settings/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,13 +460,13 @@ export const SettingsSchema = lazySchema(() =>
460460
.boolean()
461461
.optional()
462462
.describe('Disable all hooks and statusLine execution'),
463-
// Which shell backs input-box `!` (see docs/design/ps-shell-selection.md §4.2)
463+
// Which shell backs input-box `!` (see resolveDefaultShell)
464464
defaultShell: z
465465
.enum(['bash', 'powershell'])
466466
.optional()
467467
.describe(
468468
'Default shell for input-box ! commands. ' +
469-
"Defaults to 'bash' on all platforms (no Windows auto-flip).",
469+
"Defaults to 'powershell' on Windows when the PowerShell tool is enabled, otherwise 'bash'. Set explicitly to override.",
470470
),
471471
// Only run hooks defined in managed settings (managed-settings.json)
472472
allowManagedHooksOnly: z
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Windows default shell: PowerShell tool on by default; ! routing prefers it.
3+
*/
4+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
5+
6+
const settingsState: { defaultShell?: 'bash' | 'powershell' } = {}
7+
8+
mock.module('src/utils/settings/settings.js', () => ({
9+
getInitialSettings: () => ({ ...settingsState }),
10+
}))
11+
12+
// Force windows platform for these unit tests regardless of host OS.
13+
mock.module('src/utils/platform.js', () => ({
14+
getPlatform: () => 'windows' as const,
15+
SUPPORTED_PLATFORMS: ['macos', 'wsl'],
16+
}))
17+
18+
import { isPowerShellToolEnabled } from '../shellToolUtils.js'
19+
import { resolveDefaultShell } from '../resolveDefaultShell.js'
20+
21+
const ENV_KEY = 'CLAUDE_CODE_USE_POWERSHELL_TOOL'
22+
let savedEnv: string | undefined
23+
24+
beforeEach(() => {
25+
savedEnv = process.env[ENV_KEY]
26+
delete process.env[ENV_KEY]
27+
delete settingsState.defaultShell
28+
})
29+
30+
afterEach(() => {
31+
if (savedEnv === undefined) delete process.env[ENV_KEY]
32+
else process.env[ENV_KEY] = savedEnv
33+
delete settingsState.defaultShell
34+
})
35+
36+
describe('isPowerShellToolEnabled (windows)', () => {
37+
test('enabled by default when env unset', () => {
38+
expect(isPowerShellToolEnabled()).toBe(true)
39+
})
40+
41+
test('disabled when env is falsy', () => {
42+
process.env[ENV_KEY] = '0'
43+
expect(isPowerShellToolEnabled()).toBe(false)
44+
process.env[ENV_KEY] = 'false'
45+
expect(isPowerShellToolEnabled()).toBe(false)
46+
})
47+
48+
test('enabled when env is truthy', () => {
49+
process.env[ENV_KEY] = '1'
50+
expect(isPowerShellToolEnabled()).toBe(true)
51+
})
52+
})
53+
54+
describe('resolveDefaultShell (windows)', () => {
55+
test('defaults to powershell when tool enabled and no settings', () => {
56+
expect(resolveDefaultShell()).toBe('powershell')
57+
})
58+
59+
test('honors settings.defaultShell=bash', () => {
60+
settingsState.defaultShell = 'bash'
61+
expect(resolveDefaultShell()).toBe('bash')
62+
})
63+
64+
test('honors settings.defaultShell=powershell', () => {
65+
settingsState.defaultShell = 'powershell'
66+
expect(resolveDefaultShell()).toBe('powershell')
67+
})
68+
69+
test('falls back to bash when PowerShell tool is disabled', () => {
70+
process.env[ENV_KEY] = '0'
71+
expect(resolveDefaultShell()).toBe('bash')
72+
})
73+
})
Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
import { getInitialSettings } from '../settings/settings.js'
2+
import { getPlatform } from '../platform.js'
3+
import { isPowerShellToolEnabled } from './shellToolUtils.js'
24

35
/**
4-
* Resolve the default shell for input-box `!` commands.
6+
* Resolve the default shell for input-box `!` commands and agent preference.
57
*
6-
* Resolution order (docs/design/ps-shell-selection.md §4.2):
7-
* settings.defaultShell → 'bash'
8+
* Resolution order:
9+
* 1. settings.defaultShell (explicit user/project setting)
10+
* 2. Windows + PowerShell tool enabled → 'powershell'
11+
* 3. otherwise → 'bash'
812
*
9-
* Platform default is 'bash' everywhere — we do NOT auto-flip Windows to
10-
* PowerShell (would break existing Windows users with bash hooks).
13+
* Opt out of the Windows PowerShell default via settings.defaultShell=bash
14+
* or CLAUDE_CODE_USE_POWERSHELL_TOOL=0 (disables the PowerShell tool entirely).
1115
*/
1216
export function resolveDefaultShell(): 'bash' | 'powershell' {
13-
return getInitialSettings().defaultShell ?? 'bash'
17+
const configured = getInitialSettings().defaultShell
18+
if (configured === 'bash' || configured === 'powershell') {
19+
return configured
20+
}
21+
if (getPlatform() === 'windows' && isPowerShellToolEnabled()) {
22+
return 'powershell'
23+
}
24+
return 'bash'
1425
}

src/utils/shell/shellToolUtils.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
22
import { POWERSHELL_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/PowerShellTool/toolName.js'
3-
import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js'
3+
import { isEnvDefinedFalsy } from '../envUtils.js'
44
import { getPlatform } from '../platform.js'
55

66
export const SHELL_TOOL_NAMES: string[] = [BASH_TOOL_NAME, POWERSHELL_TOOL_NAME]
77

88
/**
99
* Runtime gate for PowerShellTool. Windows-only (the permission engine uses
10-
* Win32-specific path normalizations). Ant defaults on (opt-out via env=0);
11-
* external defaults off (opt-in via env=1).
10+
* Win32-specific path normalizations).
11+
*
12+
* Default: ON on Windows for all builds (opt-out via
13+
* CLAUDE_CODE_USE_POWERSHELL_TOOL=0 / false / off). Non-Windows always off.
1214
*
1315
* Used by tools.ts (tool-list visibility), processBashCommand (! routing),
1416
* and promptShellExecution (skill frontmatter routing) so the gate is
1517
* consistent across all paths that invoke PowerShellTool.call().
1618
*/
1719
export function isPowerShellToolEnabled(): boolean {
1820
if (getPlatform() !== 'windows') return false
19-
return process.env.USER_TYPE === 'ant'
20-
? !isEnvDefinedFalsy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
21-
: isEnvTruthy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
21+
return !isEnvDefinedFalsy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
2222
}

0 commit comments

Comments
 (0)