fix(windows): handle tab edits and missing Git Bash #1168

Preserve file indentation across tabs and spaces, route command tools to an available Windows shell, and make the source CLI launcher cross-platform.
This commit is contained in:
Relakkes Yang
2026-08-02 16:45:19 +08:00
parent 5f8bae08db
commit f332057b88
20 changed files with 1266 additions and 214 deletions
+45 -25
View File
@@ -1,30 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
#!/usr/bin/env -S bun --no-env-file
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
#Get your current working directory and export it as an environment variable.
export CALLER_DIR="${CALLER_DIR:-$(pwd -W 2>/dev/null || pwd)}"
import { existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
cd "$ROOT_DIR"
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const callerDir = process.env.CALLER_DIR || process.cwd()
const skipDotenv = process.env.CC_HAHA_SKIP_DOTENV === '1'
const rootEnvPath = path.join(rootDir, '.env')
const envFileArgs =
!skipDotenv && existsSync(rootEnvPath)
? [`--env-file=${rootEnvPath}`]
: ['--no-env-file']
const entrypoint =
process.env.CLAUDE_CODE_FORCE_RECOVERY_CLI === '1'
? path.join(rootDir, 'src', 'localRecoveryCli.ts')
: path.join(rootDir, 'src', 'entrypoints', 'cli.tsx')
# When spawned by the desktop/web server as a child CLI process,
# skip .env loading — the server has already set the correct env
# via cc-haha/settings.json. Loading .env would re-inject stale
# provider keys (e.g., a MiniMax key as ANTHROPIC_API_KEY) that
# override the active provider config.
if [[ "${CC_HAHA_SKIP_DOTENV:-0}" == "1" ]]; then
# Bun auto-loads .env by default; explicitly point to /dev/null to suppress.
ENV_FILE_FLAG="--env-file=/dev/null"
elif [[ -f .env ]]; then
ENV_FILE_FLAG="--env-file=.env"
else
ENV_FILE_FLAG=""
fi
const command = [
process.execPath,
'--feature=TRANSCRIPT_CLASSIFIER',
...envFileArgs,
entrypoint,
...process.argv.slice(2),
]
const child = Bun.spawn(command, {
cwd: rootDir,
env: { ...process.env, CALLER_DIR: callerDir },
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit',
})
# Force recovery CLI (simple readline REPL, no Ink TUI)
if [[ "${CLAUDE_CODE_FORCE_RECOVERY_CLI:-0}" == "1" ]]; then
exec bun --feature=TRANSCRIPT_CLASSIFIER $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@"
fi
const forwardSignal = (signal: NodeJS.Signals) => {
try {
child.kill(signal)
} catch {
// The child may have completed between the signal and this handler.
}
}
const forwardInterrupt = () => forwardSignal('SIGINT')
const forwardTerminate = () => forwardSignal('SIGTERM')
process.on('SIGINT', forwardInterrupt)
process.on('SIGTERM', forwardTerminate)
# Default: full CLI with Ink TUI
exec bun --feature=TRANSCRIPT_CLASSIFIER $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@"
const exitCode = await child.exited
process.off('SIGINT', forwardInterrupt)
process.off('SIGTERM', forwardTerminate)
process.exit(exitCode)
+2 -2
View File
@@ -8,8 +8,8 @@
"claude-haha": "./bin/claude-haha"
},
"scripts": {
"claude-haha": "bun run ./bin/claude-haha",
"start": "bun run ./bin/claude-haha",
"claude-haha": "bun --no-env-file run ./bin/claude-haha",
"start": "bun --no-env-file run ./bin/claude-haha",
"perf:local-index": "bun run scripts/perf/local-index-benchmark.ts",
"perf:local-index:10k": "bun run scripts/perf/local-index-benchmark.ts --sessions 10000 --runs 20",
"check:pr": "bun run scripts/pr/check-pr.ts",
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
test('source CLI launcher runs through Bun without requiring Bash', async () => {
const configDir = await mkdtemp(path.join(tmpdir(), 'cc-haha-cli-launcher-'))
const repoRoot = path.resolve(import.meta.dir, '..')
try {
const child = Bun.spawn(
[process.execPath, 'run', 'claude-haha', '--version'],
{
cwd: repoRoot,
env: {
...process.env,
CC_HAHA_SKIP_DOTENV: '1',
CLAUDE_CONFIG_DIR: configDir,
},
stdout: 'pipe',
stderr: 'pipe',
},
)
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
expect(exitCode, stderr).toBe(0)
expect(stdout).toContain('(Claude Code)')
} finally {
await rm(configDir, { recursive: true, force: true })
}
})
+138 -64
View File
@@ -1,30 +1,50 @@
import type { Command } from '../commands.js'
import type { ToolUseContext } from '../Tool.js'
import {
getAttributionTexts,
getEnhancedPRAttribution,
} from '../utils/attribution.js'
import { getDefaultBranch } from '../utils/git.js'
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
import {
resolveDefaultShell,
type ShellToolType,
} from '../utils/shell/resolveDefaultShell.js'
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'
const ALLOWED_TOOLS = [
'Bash(git checkout --branch:*)',
'Bash(git checkout -b:*)',
'Bash(git add:*)',
'Bash(git status:*)',
'Bash(git push:*)',
'Bash(git commit:*)',
'Bash(gh pr create:*)',
'Bash(gh pr edit:*)',
'Bash(gh pr view:*)',
'Bash(gh pr merge:*)',
const ALLOWED_COMMANDS = [
'git checkout --branch:*',
'git checkout -b:*',
'git add:*',
'git status:*',
'git push:*',
'git commit:*',
'gh pr create:*',
'gh pr edit:*',
'gh pr view:*',
'gh pr merge:*',
]
const NON_SHELL_ALLOWED_TOOLS = [
'ToolSearch',
'mcp__slack__send_message',
'mcp__claude_ai_Slack__slack_send_message',
]
function getPromptContent(
export function getCommitPushPrAllowedTools(
shell: ShellToolType | null,
): string[] {
if (!shell) return [...NON_SHELL_ALLOWED_TOOLS]
const toolName = shell === 'powershell' ? 'PowerShell' : 'Bash'
return [
...ALLOWED_COMMANDS.map(command => `${toolName}(${command})`),
...NON_SHELL_ALLOWED_TOOLS,
]
}
export function getPromptContent(
defaultBranch: string,
shell: ShellToolType,
prAttribution?: string,
): string {
const { commit: commitAttribution, pr: defaultPrAttribution } =
@@ -54,6 +74,45 @@ function getPromptContent(
slackStep = ''
}
const commitAttributionText = commitAttribution
? `\n\n${commitAttribution}`
: ''
const prAttributionText = effectivePrAttribution
? `\n\n${effectivePrAttribution}`
: ''
const prViewCommand =
shell === 'powershell'
? 'gh pr view --json number 2>$null; if ($LASTEXITCODE -ne 0) { exit 0 }'
: 'gh pr view --json number 2>/dev/null || true'
const commitExample =
shell === 'powershell'
? `$commitMessage = @'
Commit message here.${commitAttributionText}
'@
git commit -m $commitMessage`
: `git commit -m "$(cat <<'EOF'
Commit message here.${commitAttributionText}
EOF
)"`
const prExample =
shell === 'powershell'
? `$prBody = @'
## Summary
<1-3 bullet points>
## Test plan
[Bulleted markdown checklist of TODOs for testing the pull request...]${changelogSection}${prAttributionText}
'@
gh pr create --title "Short, descriptive title" --body $prBody`
: `gh pr create --title "Short, descriptive title" --body "$(cat <<'EOF'
## Summary
<1-3 bullet points>
## Test plan
[Bulleted markdown checklist of TODOs for testing the pull request...]${changelogSection}${prAttributionText}
EOF
)"`
return `${prefix}## Context
- \`SAFEUSER\`: ${safeUser}
@@ -62,7 +121,7 @@ function getPromptContent(
- \`git diff HEAD\`: !\`git diff HEAD\`
- \`git branch --show-current\`: !\`git branch --show-current\`
- \`git diff ${defaultBranch}...HEAD\`: !\`git diff ${defaultBranch}...HEAD\`
- \`gh pr view --json number 2>/dev/null || true\`: !\`gh pr view --json number 2>/dev/null || true\`
- \`${prViewCommand}\`: !\`${prViewCommand}\`
## Git Safety Protocol
@@ -79,25 +138,15 @@ Analyze all changes that will be included in the pull request, making sure to lo
Based on the above changes:
1. Create a new branch if on ${defaultBranch} (use SAFEUSER from context above for the branch name prefix, falling back to whoami if SAFEUSER is empty, e.g., \`username/feature-name\`)
2. Create a single commit with an appropriate message using heredoc syntax${commitAttribution ? `, ending with the attribution text shown in the example below` : ''}:
2. Create a single commit with an appropriate message using ${shell === 'powershell' ? 'a PowerShell here-string' : 'heredoc syntax'}${commitAttribution ? `, ending with the attribution text shown in the example below` : ''}:
\`\`\`
git commit -m "$(cat <<'EOF'
Commit message here.${commitAttribution ? `\n\n${commitAttribution}` : ''}
EOF
)"
${commitExample}
\`\`\`
3. Push the branch to origin
4. If a PR already exists for this branch (check the gh pr view output above), update the PR title and body using \`gh pr edit\` to reflect the current diff${addReviewerArg}. Otherwise, create a pull request using \`gh pr create\` with heredoc syntax for the body${reviewerArg}.
4. If a PR already exists for this branch (check the gh pr view output above), update the PR title and body using \`gh pr edit\` to reflect the current diff${addReviewerArg}. Otherwise, create a pull request using \`gh pr create\` with ${shell === 'powershell' ? 'a PowerShell here-string' : 'heredoc syntax'} for the body${reviewerArg}.
- IMPORTANT: Keep PR titles short (under 70 characters). Use the body for details.
\`\`\`
gh pr create --title "Short, descriptive title" --body "$(cat <<'EOF'
## Summary
<1-3 bullet points>
## Test plan
[Bulleted markdown checklist of TODOs for testing the pull request...]${changelogSection}${effectivePrAttribution ? `\n\n${effectivePrAttribution}` : ''}
EOF
)"
${prExample}
\`\`\`
You have the capability to call multiple tools in a single response. You MUST do all of the above in a single message.${slackStep}
@@ -105,53 +154,78 @@ You have the capability to call multiple tools in a single response. You MUST do
Return the PR URL when you're done, so the user can see it.`
}
export async function buildCommitPushPrPrompt(
args: string,
context: ToolUseContext,
dependencies: {
resolveShell?: typeof resolveDefaultShell
execute?: typeof executeShellCommandsInPrompt
getBranch?: typeof getDefaultBranch
getPrAttribution?: typeof getEnhancedPRAttribution
} = {},
) {
const shell = (dependencies.resolveShell ?? resolveDefaultShell)()
if (!shell) {
throw new Error(
'No supported command shell is available. Install Git Bash or enable PowerShell.',
)
}
const allowedTools = getCommitPushPrAllowedTools(shell)
const [defaultBranch, prAttribution] = await Promise.all([
(dependencies.getBranch ?? getDefaultBranch)(),
(dependencies.getPrAttribution ?? getEnhancedPRAttribution)(
context.getAppState,
),
])
let promptContent = getPromptContent(defaultBranch, shell, prAttribution)
const trimmedArgs = args?.trim()
if (trimmedArgs) {
promptContent += `\n\n## Additional instructions from user\n\n${trimmedArgs}`
}
const finalContent = await (
dependencies.execute ?? executeShellCommandsInPrompt
)(
promptContent,
{
...context,
getAppState() {
const appState = context.getAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: allowedTools,
},
},
}
},
},
'/commit-push-pr',
shell,
)
return [{ type: 'text' as const, text: finalContent }]
}
const command = {
type: 'prompt',
name: 'commit-push-pr',
description: 'Commit, push, and open a PR',
allowedTools: ALLOWED_TOOLS,
get allowedTools() {
return getCommitPushPrAllowedTools(resolveDefaultShell())
},
get contentLength() {
// Use 'main' as estimate for content length calculation
return getPromptContent('main').length
return getPromptContent('main', resolveDefaultShell() ?? 'bash').length
},
progressMessage: 'creating commit and PR',
source: 'builtin',
async getPromptForCommand(args, context) {
// Get default branch and enhanced PR attribution
const [defaultBranch, prAttribution] = await Promise.all([
getDefaultBranch(),
getEnhancedPRAttribution(context.getAppState),
])
let promptContent = getPromptContent(defaultBranch, prAttribution)
// Append user instructions if args provided
const trimmedArgs = args?.trim()
if (trimmedArgs) {
promptContent += `\n\n## Additional instructions from user\n\n${trimmedArgs}`
}
const finalContent = await executeShellCommandsInPrompt(
promptContent,
{
...context,
getAppState() {
const appState = context.getAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: ALLOWED_TOOLS,
},
},
}
},
},
'/commit-push-pr',
)
return [{ type: 'text', text: finalContent }]
return buildCommitPushPrPrompt(args, context)
},
} satisfies Command
+76 -34
View File
@@ -1,15 +1,26 @@
import type { Command } from '../commands.js'
import type { ToolUseContext } from '../Tool.js'
import { getAttributionTexts } from '../utils/attribution.js'
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
import {
resolveDefaultShell,
type ShellToolType,
} from '../utils/shell/resolveDefaultShell.js'
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'
const ALLOWED_TOOLS = [
'Bash(git add:*)',
'Bash(git status:*)',
'Bash(git commit:*)',
const ALLOWED_COMMANDS = [
'git add:*',
'git status:*',
'git commit:*',
]
function getPromptContent(): string {
export function getCommitAllowedTools(shell: ShellToolType | null): string[] {
if (!shell) return []
const toolName = shell === 'powershell' ? 'PowerShell' : 'Bash'
return ALLOWED_COMMANDS.map(command => `${toolName}(${command})`)
}
export function getPromptContent(shell: ShellToolType): string {
const { commit: commitAttribution } = getAttributionTexts()
let prefix = ''
@@ -17,6 +28,18 @@ function getPromptContent(): string {
prefix = getUndercoverInstructions() + '\n'
}
const attribution = commitAttribution ? `\n\n${commitAttribution}` : ''
const commitExample =
shell === 'powershell'
? `$commitMessage = @'
Commit message here.${attribution}
'@
git commit -m $commitMessage`
: `git commit -m "$(cat <<'EOF'
Commit message here.${attribution}
EOF
)"`
return `${prefix}## Context
- Current git status: !\`git status\`
@@ -43,49 +66,68 @@ Based on the above changes, create a single git commit:
- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
2. Stage relevant files and create the commit using HEREDOC syntax:
2. Stage relevant files and create the commit using ${shell === 'powershell' ? 'a PowerShell here-string' : 'HEREDOC syntax'}:
\`\`\`
git commit -m "$(cat <<'EOF'
Commit message here.${commitAttribution ? `\n\n${commitAttribution}` : ''}
EOF
)"
${commitExample}
\`\`\`
You have the capability to call multiple tools in a single response. Stage and create the commit using a single message. Do not use any other tools or do anything else. Do not send any other text or messages besides these tool calls.`
}
export async function buildCommitPrompt(
context: ToolUseContext,
dependencies: {
resolveShell?: typeof resolveDefaultShell
execute?: typeof executeShellCommandsInPrompt
} = {},
) {
const shell = (dependencies.resolveShell ?? resolveDefaultShell)()
if (!shell) {
throw new Error(
'No supported command shell is available. Install Git Bash or enable PowerShell.',
)
}
const allowedTools = getCommitAllowedTools(shell)
const promptContent = getPromptContent(shell)
const finalContent = await (
dependencies.execute ?? executeShellCommandsInPrompt
)(
promptContent,
{
...context,
getAppState() {
const appState = context.getAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: allowedTools,
},
},
}
},
},
'/commit',
shell,
)
return [{ type: 'text' as const, text: finalContent }]
}
const command = {
type: 'prompt',
name: 'commit',
description: 'Create a git commit',
allowedTools: ALLOWED_TOOLS,
get allowedTools() {
return getCommitAllowedTools(resolveDefaultShell())
},
contentLength: 0, // Dynamic content
progressMessage: 'creating commit',
source: 'builtin',
async getPromptForCommand(_args, context) {
const promptContent = getPromptContent()
const finalContent = await executeShellCommandsInPrompt(
promptContent,
{
...context,
getAppState() {
const appState = context.getAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: ALLOWED_TOOLS,
},
},
}
},
},
'/commit',
)
return [{ type: 'text', text: finalContent }]
return buildCommitPrompt(context)
},
} satisfies Command
+55 -36
View File
@@ -1,6 +1,11 @@
import { parseFrontmatter } from '../utils/frontmatterParser.js'
import { parseSlashCommandToolsFromFrontmatter } from '../utils/markdownConfigLoader.js'
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
import type { ToolUseContext } from '../Tool.js'
import {
resolveDefaultShell,
type ShellToolType,
} from '../utils/shell/resolveDefaultShell.js'
import { createMovedToPluginCommand } from './createMovedToPluginCommand.js'
const SECURITY_REVIEW_MARKDOWN = `---
@@ -195,6 +200,55 @@ Begin your analysis now. Do this in 3 steps:
Your final reply must contain the markdown report and nothing else.`
export function getSecurityReviewMarkdown(shell: ShellToolType): string {
if (shell === 'bash') return SECURITY_REVIEW_MARKDOWN
return SECURITY_REVIEW_MARKDOWN.replaceAll('Bash(', 'PowerShell(')
}
export async function buildSecurityReviewPrompt(
context: ToolUseContext,
dependencies: {
resolveShell?: typeof resolveDefaultShell
execute?: typeof executeShellCommandsInPrompt
} = {},
) {
const shell = (dependencies.resolveShell ?? resolveDefaultShell)()
if (!shell) {
throw new Error(
'No supported command shell is available. Install Git Bash or enable PowerShell.',
)
}
const parsed = parseFrontmatter(getSecurityReviewMarkdown(shell))
const allowedTools = parseSlashCommandToolsFromFrontmatter(
parsed.frontmatter['allowed-tools'],
)
const processedContent = await (
dependencies.execute ?? executeShellCommandsInPrompt
)(
parsed.content,
{
...context,
getAppState() {
const appState = context.getAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: allowedTools,
},
},
}
},
},
'security-review',
shell,
)
return [{ type: 'text' as const, text: processedContent }]
}
export default createMovedToPluginCommand({
name: 'security-review',
description:
@@ -203,41 +257,6 @@ export default createMovedToPluginCommand({
pluginName: 'security-review',
pluginCommand: 'security-review',
async getPromptWhileMarketplaceIsPrivate(_args, context) {
// Parse frontmatter from the markdown
const parsed = parseFrontmatter(SECURITY_REVIEW_MARKDOWN)
// Parse allowed tools from frontmatter
const allowedTools = parseSlashCommandToolsFromFrontmatter(
parsed.frontmatter['allowed-tools'],
)
// Execute bash commands in the prompt
const processedContent = await executeShellCommandsInPrompt(
parsed.content,
{
...context,
getAppState() {
const appState = context.getAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: allowedTools,
},
},
}
},
},
'security-review',
)
return [
{
type: 'text',
text: processedContent,
},
]
return buildSecurityReviewPrompt(context)
},
})
+238
View File
@@ -0,0 +1,238 @@
import { describe, expect, mock, test } from 'bun:test'
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
import {
buildCommitPrompt,
getCommitAllowedTools,
getPromptContent as getCommitPrompt,
} from './commit.js'
import {
buildCommitPushPrPrompt,
getCommitPushPrAllowedTools,
getPromptContent as getCommitPushPrPrompt,
} from './commit-push-pr.js'
import {
buildSecurityReviewPrompt,
getSecurityReviewMarkdown,
} from './security-review.js'
function createTool(name: string, stdout: string) {
const call = mock(async () => ({
data: { stdout, stderr: '', interrupted: false },
}))
return {
call,
tool: {
name,
call,
maxResultSizeChars: 100_000,
mapToolResultToToolResultBlockParam: () => ({
type: 'tool_result' as const,
tool_use_id: 'test',
content: stdout,
}),
},
}
}
const allow = async () => ({ behavior: 'allow' as const })
const processResult = async (
_tool: unknown,
data: { stdout: string },
) => ({
type: 'tool_result' as const,
tool_use_id: 'test',
content: data.stdout,
})
const context = {
getAppState: () => ({
toolPermissionContext: {
alwaysAllowRules: { command: [] },
},
}),
}
function createPromptExecutor(expectedCommandName: string) {
return mock(
async (
_text: string,
executionContext: typeof context,
commandName: string,
shell: string,
) => {
expect(commandName).toBe(expectedCommandName)
expect(shell).toBe('powershell')
const allowed = executionContext.getAppState().toolPermissionContext
.alwaysAllowRules.command
expect(allowed.some(rule => rule.startsWith('PowerShell('))).toBe(true)
expect(allowed.some(rule => rule.startsWith('Bash('))).toBe(false)
return 'processed'
},
)
}
describe('built-in command shell prompts', () => {
test('uses PowerShell permission rules and here-strings for commit', () => {
expect(getCommitAllowedTools('powershell')).toEqual([
'PowerShell(git add:*)',
'PowerShell(git status:*)',
'PowerShell(git commit:*)',
])
const prompt = getCommitPrompt('powershell')
expect(prompt).toContain("$commitMessage = @'")
expect(prompt).toContain('git commit -m $commitMessage')
expect(prompt).not.toContain("cat <<'EOF'")
})
test('keeps Bash syntax when Bash is selected', () => {
const prompt = getCommitPrompt('bash')
expect(prompt).toContain("cat <<'EOF'")
expect(prompt).not.toContain("$commitMessage = @'")
})
test('uses PowerShell-safe probing and multiline arguments for commit-push-pr', () => {
const tools = getCommitPushPrAllowedTools('powershell')
expect(tools).toContain('PowerShell(gh pr view:*)')
expect(tools).not.toContain('Bash(gh pr view:*)')
const prompt = getCommitPushPrPrompt('main', 'powershell')
expect(prompt).toContain('2>$null')
expect(prompt).toContain("$prBody = @'")
expect(prompt).not.toContain('/dev/null')
expect(prompt).not.toContain("cat <<'EOF'")
})
test('keeps the Bash commit-push-pr prompt unchanged in shape', () => {
const prompt = getCommitPushPrPrompt('main', 'bash')
expect(prompt).toContain('2>/dev/null || true')
expect(prompt).toContain("cat <<'EOF'")
expect(prompt).not.toContain('2>$null')
})
test('uses PowerShell permissions for the security review command', () => {
const markdown = getSecurityReviewMarkdown('powershell')
expect(markdown).toContain('allowed-tools: PowerShell(git diff:*)')
expect(markdown).not.toContain('allowed-tools: Bash(')
})
test('builds /commit with PowerShell execution and permissions', async () => {
const execute = createPromptExecutor('/commit')
const result = await buildCommitPrompt(context as never, {
resolveShell: () => 'powershell',
execute: execute as never,
})
expect(result).toEqual([{ type: 'text', text: 'processed' }])
expect(execute).toHaveBeenCalledTimes(1)
})
test('builds /commit-push-pr without contacting GitHub', async () => {
const execute = createPromptExecutor('/commit-push-pr')
const result = await buildCommitPushPrPrompt(
'keep the title short',
context as never,
{
resolveShell: () => 'powershell',
execute: execute as never,
getBranch: async () => 'main',
getPrAttribution: async () => '',
},
)
expect(result).toEqual([{ type: 'text', text: 'processed' }])
expect(execute.mock.calls[0]?.[0]).toContain(
'## Additional instructions from user\n\nkeep the title short',
)
})
test('builds /security-review with PowerShell execution and permissions', async () => {
const execute = createPromptExecutor('security-review')
const result = await buildSecurityReviewPrompt(context as never, {
resolveShell: () => 'powershell',
execute: execute as never,
})
expect(result).toEqual([{ type: 'text', text: 'processed' }])
expect(execute).toHaveBeenCalledTimes(1)
})
test('fails clearly when no shell is enabled', async () => {
await expect(
buildCommitPrompt(context as never, { resolveShell: () => null }),
).rejects.toThrow('No supported command shell is available')
await expect(
buildCommitPushPrPrompt('', context as never, {
resolveShell: () => null,
}),
).rejects.toThrow('No supported command shell is available')
await expect(
buildSecurityReviewPrompt(context as never, {
resolveShell: () => null,
}),
).rejects.toThrow('No supported command shell is available')
})
})
describe('embedded command shell routing', () => {
test('executes PowerShell content without losing Unicode or tabs', async () => {
const bash = createTool('Bash', 'bash-output')
const powershell = createTool('PowerShell', '中文\tpowershell-output')
const result = await executeShellCommandsInPrompt(
'result: !`Write-Output 中文`',
{} as never,
'/windows-command',
'powershell',
{
availability: { bash: false, powershell: true },
bashTool: bash.tool as never,
powerShellTool: powershell.tool as never,
checkPermission: allow as never,
processResult: processResult as never,
},
)
expect(result).toBe('result: 中文\tpowershell-output')
expect(powershell.call).toHaveBeenCalledTimes(1)
expect(powershell.call.mock.calls[0]?.[0]).toEqual({
command: 'Write-Output 中文',
})
expect(bash.call).not.toHaveBeenCalled()
})
test('keeps Bash content on Bash', async () => {
const bash = createTool('Bash', 'bash-output')
const powershell = createTool('PowerShell', 'powershell-output')
const result = await executeShellCommandsInPrompt(
'result: !`printf ok`',
{} as never,
'/bash-command',
'bash',
{
availability: { bash: true, powershell: false },
bashTool: bash.tool as never,
powerShellTool: powershell.tool as never,
checkPermission: allow as never,
processResult: processResult as never,
},
)
expect(result).toBe('result: bash-output')
expect(bash.call).toHaveBeenCalledTimes(1)
expect(powershell.call).not.toHaveBeenCalled()
})
test('reports an unavailable explicit shell instead of changing syntax', async () => {
await expect(
executeShellCommandsInPrompt(
'result: !`printf ok`',
{} as never,
'/bash-command',
'bash',
{ availability: { bash: false, powershell: true } },
),
).rejects.toThrow('Bash is required to execute commands in /bash-command')
})
})
+47
View File
@@ -0,0 +1,47 @@
import { afterAll, describe, expect, test } from 'bun:test'
import { getEmptyToolPermissionContext } from './Tool.js'
import { tryFindGitBashPath } from './utils/windowsPaths.js'
const originalGitBashPath = process.env.CLAUDE_CODE_GIT_BASH_PATH
const originalSimple = process.env.CLAUDE_CODE_SIMPLE
function clearGitBashPathCache(): void {
const memoizedTryFindGitBashPath =
tryFindGitBashPath as typeof tryFindGitBashPath & {
cache: { clear(): void }
}
memoizedTryFindGitBashPath.cache.clear()
}
afterAll(() => {
if (originalGitBashPath === undefined) {
delete process.env.CLAUDE_CODE_GIT_BASH_PATH
} else {
process.env.CLAUDE_CODE_GIT_BASH_PATH = originalGitBashPath
}
if (originalSimple === undefined) delete process.env.CLAUDE_CODE_SIMPLE
else process.env.CLAUDE_CODE_SIMPLE = originalSimple
clearGitBashPathCache()
})
describe('Windows shell tool exposure', () => {
test('substitutes PowerShell for Bash when Git Bash is unavailable', async () => {
if (process.platform !== 'win32') return
process.env.CLAUDE_CODE_GIT_BASH_PATH =
'C:\\missing-git-bash\\bash.exe'
clearGitBashPathCache()
const { getAllBaseTools, getTools } = await import('./tools.js')
const baseToolNames = getAllBaseTools().map(tool => tool.name)
expect(baseToolNames).toContain('PowerShell')
expect(baseToolNames).not.toContain('Bash')
process.env.CLAUDE_CODE_SIMPLE = '1'
const simpleToolNames = getTools(getEmptyToolPermissionContext()).map(
tool => tool.name,
)
expect(simpleToolNames).toEqual(['PowerShell', 'Read', 'Edit'])
})
})
+13 -4
View File
@@ -138,7 +138,10 @@ import type { ToolPermissionContext } from './Tool.js'
import { getDenyRuleForTool } from './utils/permissions/permissions.js'
import { hasEmbeddedSearchTools } from './utils/embeddedTools.js'
import { isEnvTruthy } from './utils/envUtils.js'
import { isPowerShellToolEnabled } from './utils/shell/shellToolUtils.js'
import {
isBashToolEnabled,
isPowerShellToolEnabled,
} from './utils/shell/shellToolUtils.js'
import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js'
import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.js'
import {
@@ -192,10 +195,11 @@ export function getToolsForDefaultPreset(): string[] {
* NOTE: This MUST stay in sync with https://console.statsig.com/4aF3Ewatb6xPVpCwxb5nA3/dynamic_configs/claude_code_global_system_caching, in order to cache the system prompt across users.
*/
export function getAllBaseTools(): Tools {
const powerShellTool = getPowerShellTool()
return [
AgentTool,
TaskOutputTool,
BashTool,
...(isBashToolEnabled() ? [BashTool] : []),
// Ant-native builds have bfs/ugrep embedded in the bun binary (same ARGV0
// trick as ripgrep). When available, find/grep in Claude's shell are aliased
// to these fast tools, so the dedicated Glob/Grep tools are unnecessary.
@@ -240,7 +244,7 @@ export function getAllBaseTools(): Tools {
...(SendUserFileTool ? [SendUserFileTool] : []),
...(PushNotificationTool ? [PushNotificationTool] : []),
...(SubscribePRTool ? [SubscribePRTool] : []),
...(getPowerShellTool() ? [getPowerShellTool()] : []),
...(powerShellTool ? [powerShellTool] : []),
...(SnipTool ? [SnipTool] : []),
...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),
ListMcpResourcesTool,
@@ -285,7 +289,12 @@ export const getTools = (permissionContext: ToolPermissionContext): Tools => {
}
return filterToolsByDenyRules(replSimple, permissionContext)
}
const simpleTools: Tool[] = [BashTool, FileReadTool, FileEditTool]
const shellTool = isBashToolEnabled() ? BashTool : getPowerShellTool()
const simpleTools: Tool[] = [
...(shellTool ? [shellTool] : []),
FileReadTool,
FileEditTool,
]
// When coordinator mode is also active, include AgentTool and TaskStopTool
// so the coordinator gets Task+TaskStop (via useMergedTools filtering) and
// workers get Bash/Read/Edit (via filterToolsForAgent filtering).
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ToolUseContext } from '../../Tool.js'
import { getEmptyToolPermissionContext } from '../../Tool.js'
import { FileReadTool } from '../FileReadTool/FileReadTool.js'
import { FileEditTool } from './FileEditTool.js'
const temporaryDirectories: string[] = []
const originalSimple = process.env.CLAUDE_CODE_SIMPLE
const originalDisableCheckpoints =
process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING
beforeEach(() => {
process.env.CLAUDE_CODE_SIMPLE = '1'
process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING = '1'
})
afterEach(async () => {
if (originalSimple === undefined) delete process.env.CLAUDE_CODE_SIMPLE
else process.env.CLAUDE_CODE_SIMPLE = originalSimple
if (originalDisableCheckpoints === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING
} else {
process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING =
originalDisableCheckpoints
}
await Promise.all(
temporaryDirectories.splice(0).map(path =>
rm(path, { recursive: true, force: true }),
),
)
})
function makeToolUseContext(): ToolUseContext {
return {
readFileState: new Map(),
abortController: new AbortController(),
updateFileHistoryState: () => {},
getAppState: () => ({
toolPermissionContext: getEmptyToolPermissionContext(),
}),
} as unknown as ToolUseContext
}
describe('FileEditTool indentation matching', () => {
test('edits a unique tab-indented target from a space-indented input', async () => {
const root = await mkdtemp(join(tmpdir(), 'cc-haha-edit-'))
temporaryDirectories.push(root)
const directory = join(root, '中文目录')
const filePath = join(directory, 'Tab 样例.txt')
await mkdir(directory)
await writeFile(filePath, '\t\t中文目标:旧值\n\t相邻内容\n', 'utf8')
const context = makeToolUseContext()
await FileReadTool.call({ file_path: filePath }, context)
const input = {
file_path: filePath,
old_string: ' 中文目标:旧值',
new_string: ' 中文目标:新值',
}
await expect(FileEditTool.validateInput(input, context)).resolves.toEqual({
result: true,
meta: { actualOldString: '\t\t中文目标:旧值' },
})
await FileEditTool.call(
input,
context,
undefined,
{ uuid: 'indentation-test' } as never,
)
expect(await readFile(filePath, 'utf8')).toBe(
'\t\t中文目标:新值\n\t相邻内容\n',
)
})
})
+12 -5
View File
@@ -73,6 +73,7 @@ import {
areFileEditsInputsEquivalent,
findActualString,
getPatchForEdit,
preserveIndentationStyle,
preserveQuoteStyle,
} from './utils.js'
@@ -342,6 +343,12 @@ export const FileEditTool = buildTool({
}
}
const actualNewString = preserveIndentationStyle(
old_string,
actualOldString,
preserveQuoteStyle(old_string, actualOldString, new_string),
)
// Additional validation for Claude settings files
const settingsValidationResult = validateInputForSettingsFileEdit(
fullFilePath,
@@ -349,8 +356,8 @@ export const FileEditTool = buildTool({
() => {
// Simulate the edit to get the final content using the exact same logic as the tool
return replace_all
? file.replaceAll(actualOldString, new_string)
: file.replace(actualOldString, new_string)
? file.replaceAll(actualOldString, actualNewString)
: file.replace(actualOldString, actualNewString)
},
)
@@ -471,11 +478,11 @@ export const FileEditTool = buildTool({
const actualOldString =
findActualString(originalFileContents, old_string) || old_string
// Preserve curly quotes in new_string when the file uses them
const actualNewString = preserveQuoteStyle(
// Preserve file typography and indentation in new_string when possible
const actualNewString = preserveIndentationStyle(
old_string,
actualOldString,
new_string,
preserveQuoteStyle(old_string, actualOldString, new_string),
)
// 4. Generate patch
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, test } from 'bun:test'
import { findActualString, preserveIndentationStyle } from './utils.js'
describe('FileEditTool indentation matching', () => {
test('matches a unique tab-indented line when the model returns spaces', () => {
const file = '\t\t中文目标:旧值\n\t相邻内容'
expect(findActualString(file, ' 中文目标:旧值')).toBe(
'\t\t中文目标:旧值',
)
})
test('matches the full file indentation instead of a whitespace suffix', () => {
expect(
findActualString(
' 中文目标:旧值',
' 中文目标:旧值',
),
).toBe(' 中文目标:旧值')
})
test('matches mixed indentation across multiple lines', () => {
const file = ['function demo() {', '\tif (ready) {', '\t\t运行()', '\t}', '}'].join(
'\n',
)
const search = [
' if (ready) {',
' 运行()',
' }',
].join('\n')
expect(findActualString(file, search)).toBe(
['\tif (ready) {', '\t\t运行()', '\t}'].join('\n'),
)
})
test('refuses an indentation-insensitive match when the target is ambiguous', () => {
const file = [
'\t\t重复目标:只修改其中一行',
' 重复目标:只修改其中一行',
].join('\n')
expect(findActualString(file, ' 重复目标:只修改其中一行')).toBeNull()
})
test('prefers exact indentation over a fuzzy alternative', () => {
const file = ' 重复目标\n\t重复目标\n'
expect(findActualString(file, ' 重复目标')).toBe(' 重复目标')
})
test('preserves the file indentation when the replacement keeps model indentation', () => {
expect(
preserveIndentationStyle(
' 中文目标:旧值',
'\t\t中文目标:旧值',
' 中文目标:新值',
),
).toBe('\t\t中文目标:新值')
})
test('keeps an intentional indentation change from the replacement', () => {
expect(
preserveIndentationStyle(
' 中文目标:旧值',
'\t\t中文目标:旧值',
' 中文目标:新值',
),
).toBe(' 中文目标:新值')
})
test('keeps substring matching for non-indentation whitespace', () => {
expect(findActualString('前缀 中文目标:旧值', ' 中文目标:旧值')).toBe(
' 中文目标:旧值',
)
})
test('keeps quote normalization when indentation fallback does not apply', () => {
expect(findActualString('const value = “中文”', 'const value = "中文"')).toBe(
'const value = “中文”',
)
})
test('leaves replacements with a different line count unchanged', () => {
expect(
preserveIndentationStyle(' 旧值', '\t旧值', ' 新值\n 新增值'),
).toBe(' 新值\n 新增值')
})
test('does not transform indentation after an exact match', () => {
expect(preserveIndentationStyle('\t旧值', '\t旧值', '\t新值')).toBe(
'\t新值',
)
})
})
+115 -10
View File
@@ -74,24 +74,129 @@ export function findActualString(
fileContent: string,
searchString: string,
): string | null {
// First try exact match
if (fileContent.includes(searchString)) {
return searchString
}
// Try with normalized quotes
const normalizedSearch = normalizeQuotes(searchString)
const normalizedFile = normalizeQuotes(fileContent)
const startsWithIndentation = /^[ \t]+(?=\S)/.test(normalizedSearch)
const searchIndex = normalizedFile.indexOf(normalizedSearch)
if (searchIndex !== -1) {
// Find the actual string in the file that matches
return fileContent.substring(searchIndex, searchIndex + searchString.length)
// An exact match at the start of a line is stronger than a fuzzy candidate.
// A raw substring match is not enough here: four spaces also occur as a
// suffix of an eight-space indentation prefix.
if (startsWithIndentation) {
const exactLineMatch = normalizedFile.match(
new RegExp(`^${escapeRegExp(normalizedSearch)}`, 'm'),
)
if (exactLineMatch?.index !== undefined) {
return fileContent.substring(
exactLineMatch.index,
exactLineMatch.index + normalizedSearch.length,
)
}
} else if (fileContent.includes(searchString)) {
return searchString
} else {
const normalizedMatchIndex = normalizedFile.indexOf(normalizedSearch)
if (normalizedMatchIndex !== -1) {
return fileContent.substring(
normalizedMatchIndex,
normalizedMatchIndex + normalizedSearch.length,
)
}
}
// Models can reproduce indentation shown by Read as spaces even when the
// file contains tabs. Accept that difference only when the surrounding text
// identifies exactly one match; choosing between multiple indentation-only
// candidates could edit the wrong block.
const indentationPattern = buildIndentationInsensitivePattern(normalizedSearch)
if (indentationPattern) {
const matches = [...normalizedFile.matchAll(indentationPattern)]
if (matches.length === 1) {
const match = matches[0]!
return fileContent.substring(
match.index!,
match.index! + match[0].length,
)
}
if (matches.length > 1) return null
}
// Preserve legacy substring matching for a leading space in the middle of a
// line, where it is content rather than indentation.
if (fileContent.includes(searchString)) return searchString
const normalizedMatchIndex = normalizedFile.indexOf(normalizedSearch)
if (normalizedMatchIndex !== -1) {
return fileContent.substring(
normalizedMatchIndex,
normalizedMatchIndex + normalizedSearch.length,
)
}
return null
}
function buildIndentationInsensitivePattern(searchString: string): RegExp | null {
const lines = searchString.split('\n')
let hasIndentedContent = false
const pattern = lines
.map(line => {
const indentation = line.match(/^[ \t]+(?=\S)/)?.[0]
if (!indentation) {
return escapeRegExp(line)
}
hasIndentedContent = true
return `[ \\t]+${escapeRegExp(line.slice(indentation.length))}`
})
.join('\\n')
return hasIndentedContent ? new RegExp(`^${pattern}`, 'gm') : null
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Preserve the file's leading whitespace when new_string retained the
* indentation supplied in old_string. Deliberate indentation changes in
* new_string are left untouched.
*/
export function preserveIndentationStyle(
oldString: string,
actualOldString: string,
newString: string,
): string {
if (oldString === actualOldString) return newString
const oldLines = oldString.split('\n')
const actualLines = actualOldString.split('\n')
const newLines = newString.split('\n')
if (
oldLines.length !== actualLines.length ||
oldLines.length !== newLines.length
) {
return newString
}
return newLines
.map((line, index) => {
const oldIndentation = oldLines[index]?.match(/^[ \t]*/)?.[0] ?? ''
const actualIndentation =
actualLines[index]?.match(/^[ \t]*/)?.[0] ?? ''
const newIndentation = line.match(/^[ \t]*/)?.[0] ?? ''
if (
oldIndentation === newIndentation &&
oldIndentation !== actualIndentation
) {
return actualIndentation + line.slice(newIndentation.length)
}
return line
})
.join('\n')
}
/**
* When old_string matched via quote normalization (curly quotes in file,
* straight quotes from model), apply the same curly quote style to new_string
+44 -1
View File
@@ -1,4 +1,7 @@
import { describe, expect, test } from 'bun:test'
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PDF_MAX_PAGES_PER_READ } from '../../constants/apiLimits.js'
import type { ToolUseContext } from '../../Tool.js'
import { getEmptyToolPermissionContext } from '../../Tool.js'
@@ -6,12 +9,24 @@ import { FileReadTool } from './FileReadTool.js'
function makeToolUseContext(): ToolUseContext {
return {
readFileState: new Map(),
abortController: new AbortController(),
getAppState: () => ({
toolPermissionContext: getEmptyToolPermissionContext(),
}),
} as unknown as ToolUseContext
}
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map(path =>
rm(path, { recursive: true, force: true }),
),
)
})
describe('FileReadTool pages validation', () => {
test('ignores invalid PDF pages values for non-PDF files', async () => {
const context = makeToolUseContext()
@@ -65,3 +80,31 @@ describe('FileReadTool pages validation', () => {
})
})
})
describe('FileReadTool Windows text fidelity', () => {
test('preserves Unicode paths and literal tabs in model-facing output', async () => {
const root = await mkdtemp(join(tmpdir(), 'cc-haha-read-'))
temporaryDirectories.push(root)
const directory = join(root, '中文目录')
const filePath = join(directory, 'Tab 样例.txt')
const content = '\t\t中文目标\n 空格目标\n'
await mkdir(directory)
await writeFile(filePath, content, 'utf8')
const result = await FileReadTool.call(
{ file_path: filePath },
makeToolUseContext(),
)
expect(result.data.type).toBe('text')
if (result.data.type !== 'text') return
expect(result.data.file.filePath).toBe(filePath)
expect(result.data.file.content).toContain('\t\t中文目标')
const block = FileReadTool.mapToolResultToToolResultBlockParam(
result.data,
'read-tabs',
)
expect(block.content).toContain('1\t\t\t中文目标')
})
})
File diff suppressed because one or more lines are too long
+39 -9
View File
@@ -24,7 +24,18 @@ type PromptShellTool = Tool & {
): Promise<{ data: ShellOut }>
}
import { isPowerShellToolEnabled } from './shell/shellToolUtils.js'
type PromptShellExecutionDependencies = {
availability?: ReturnType<typeof getShellToolAvailability>
bashTool?: PromptShellTool
powerShellTool?: PromptShellTool
checkPermission?: typeof hasPermissionsToUseTool
processResult?: typeof processToolResultBlock
}
import {
getShellToolAvailability,
resolveAvailableShellTool,
} from './shell/shellToolUtils.js'
// Lazy: this file is on the startup import chain (main → commands →
// loadSkillsDir → here). A static import would load PowerShellTool.ts
@@ -71,16 +82,35 @@ export async function executeShellCommandsInPrompt(
context: ToolUseContext,
slashCommandName: string,
shell?: FrontmatterShell,
dependencies: PromptShellExecutionDependencies = {},
): Promise<string> {
let result = text
// Resolve the tool once. `shell === undefined` and `shell === 'bash'` both
// hit BashTool. PowerShell only when the runtime gate allows — a skill
// author's frontmatter choice doesn't override the user's opt-in/out.
// An explicit frontmatter shell is a syntax contract, so never silently run
// it through the other interpreter. Built-in commands pass their resolved
// shell explicitly; third-party skill/command content still defaults to Bash.
const requestedShell = shell ?? 'bash'
const resolvedShell = resolveAvailableShellTool({
preferredShell: requestedShell,
availability: dependencies.availability ?? getShellToolAvailability(),
allowFallback: false,
})
if (!resolvedShell) {
const installHint =
requestedShell === 'bash'
? 'Install Git Bash or declare `shell: powershell` and use PowerShell syntax.'
: 'Install or enable PowerShell, or declare `shell: bash` and use Bash syntax.'
throw new MalformedCommandError(
`${requestedShell === 'bash' ? 'Bash' : 'PowerShell'} is required to execute commands in ${slashCommandName}. ${installHint}`,
)
}
const shellTool: PromptShellTool =
shell === 'powershell' && isPowerShellToolEnabled()
? getPowerShellTool()
: BashTool
resolvedShell === 'powershell'
? dependencies.powerShellTool ?? getPowerShellTool()
: dependencies.bashTool ?? BashTool
const checkPermission =
dependencies.checkPermission ?? hasPermissionsToUseTool
const processResult = dependencies.processResult ?? processToolResultBlock
// INLINE_PATTERN's lookbehind is ~100x slower than BLOCK_PATTERN on large
// skill content (265µs vs 2µs @ 17KB). 93% of skills have no !` at all,
@@ -95,7 +125,7 @@ export async function executeShellCommandsInPrompt(
if (command) {
try {
// Check permissions before executing
const permissionResult = await hasPermissionsToUseTool(
const permissionResult = await checkPermission(
shellTool,
{ command },
context,
@@ -114,7 +144,7 @@ export async function executeShellCommandsInPrompt(
const { data } = await shellTool.call({ command }, context)
// Reuse the same persistence flow as regular Bash tool calls
const toolResultBlock = await processToolResultBlock(
const toolResultBlock = await processResult(
shellTool,
data,
randomUUID(),
@@ -0,0 +1,40 @@
import { describe, expect, test } from 'bun:test'
import { resolveDefaultShellFromAvailability } from './resolveDefaultShell.js'
describe('resolveDefaultShellFromAvailability', () => {
test('uses PowerShell for the historical Bash default when Git Bash is absent', () => {
expect(
resolveDefaultShellFromAvailability(undefined, {
bash: false,
powershell: true,
}),
).toBe('powershell')
})
test('keeps Bash as the default when it is available', () => {
expect(
resolveDefaultShellFromAvailability(undefined, {
bash: true,
powershell: false,
}),
).toBe('bash')
})
test('falls back from an unavailable configured shell', () => {
expect(
resolveDefaultShellFromAvailability('powershell', {
bash: true,
powershell: false,
}),
).toBe('bash')
})
test('returns null when the user has no enabled command shell', () => {
expect(
resolveDefaultShellFromAvailability(undefined, {
bash: false,
powershell: false,
}),
).toBeNull()
})
})
+27 -7
View File
@@ -1,14 +1,34 @@
import { getInitialSettings } from '../settings/settings.js'
import {
getShellToolAvailability,
resolveAvailableShellTool,
type ShellToolAvailability,
type ShellToolType,
} from './shellToolUtils.js'
export type { ShellToolType } from './shellToolUtils.js'
/**
* Resolve the default shell for input-box `!` commands.
*
* Resolution order (docs/design/ps-shell-selection.md §4.2):
* settings.defaultShell → 'bash'
*
* Platform default is 'bash' everywhere — we do NOT auto-flip Windows to
* PowerShell (would break existing Windows users with bash hooks).
* Prefer settings.defaultShell (or Bash when unset), then fall back to the
* other shell only when the preferred executable is unavailable. This keeps
* the historical Bash default while allowing Windows installations without
* Git Bash to use native PowerShell.
*/
export function resolveDefaultShell(): 'bash' | 'powershell' {
return getInitialSettings().defaultShell ?? 'bash'
export function resolveDefaultShellFromAvailability(
configuredShell: ShellToolType | undefined,
availability: ShellToolAvailability,
): ShellToolType | null {
return resolveAvailableShellTool({
preferredShell: configuredShell ?? 'bash',
availability,
})
}
export function resolveDefaultShell(): ShellToolType | null {
return resolveDefaultShellFromAvailability(
getInitialSettings().defaultShell,
getShellToolAvailability(),
)
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, test } from 'bun:test'
import {
resolveAvailableShellTool,
resolveShellToolAvailability,
} from './shellToolUtils.js'
describe('resolveShellToolAvailability', () => {
test('falls back to PowerShell when Git Bash is unavailable on Windows', () => {
expect(
resolveShellToolAvailability({
platform: 'windows',
gitBashPath: null,
userType: undefined,
powerShellSetting: undefined,
}),
).toEqual({ bash: false, powershell: true })
})
test('keeps the existing external default when Git Bash is available', () => {
expect(
resolveShellToolAvailability({
platform: 'windows',
gitBashPath: 'C:\\Program Files\\Git\\bin\\bash.exe',
userType: undefined,
powerShellSetting: undefined,
}),
).toEqual({ bash: true, powershell: false })
})
test('honors an explicit PowerShell opt-out', () => {
expect(
resolveShellToolAvailability({
platform: 'windows',
gitBashPath: null,
userType: undefined,
powerShellSetting: '0',
}),
).toEqual({ bash: false, powershell: false })
})
test('keeps Bash available on non-Windows platforms', () => {
expect(
resolveShellToolAvailability({
platform: 'macos',
gitBashPath: null,
userType: undefined,
powerShellSetting: undefined,
}),
).toEqual({ bash: true, powershell: false })
})
})
describe('resolveAvailableShellTool', () => {
test('keeps the preferred shell when it is available', () => {
expect(
resolveAvailableShellTool({
preferredShell: 'bash',
availability: { bash: true, powershell: true },
}),
).toBe('bash')
})
test('falls back to PowerShell when Bash is unavailable', () => {
expect(
resolveAvailableShellTool({
preferredShell: 'bash',
availability: { bash: false, powershell: true },
}),
).toBe('powershell')
})
test('does not reinterpret an explicit shell syntax contract', () => {
expect(
resolveAvailableShellTool({
preferredShell: 'bash',
availability: { bash: false, powershell: true },
allowFallback: false,
}),
).toBeNull()
})
test('returns null when neither shell is available', () => {
expect(
resolveAvailableShellTool({
preferredShell: 'bash',
availability: { bash: false, powershell: false },
}),
).toBeNull()
})
})
+67 -11
View File
@@ -2,21 +2,77 @@ import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js'
import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js'
import { getPlatform } from '../platform.js'
import { tryFindGitBashPath } from '../windowsPaths.js'
export const SHELL_TOOL_NAMES: string[] = [BASH_TOOL_NAME, POWERSHELL_TOOL_NAME]
export type ShellToolType = 'bash' | 'powershell'
export type ShellToolAvailability = { bash: boolean; powershell: boolean }
export function resolveShellToolAvailability({
platform,
gitBashPath,
userType,
powerShellSetting,
}: {
platform: ReturnType<typeof getPlatform>
gitBashPath: string | null
userType: string | undefined
powerShellSetting: string | undefined
}): ShellToolAvailability {
if (platform !== 'windows') {
return { bash: true, powershell: false }
}
const bash = gitBashPath !== null
const powershell = isEnvDefinedFalsy(powerShellSetting)
? false
: userType === 'ant' || !bash || isEnvTruthy(powerShellSetting)
return { bash, powershell }
}
export function resolveAvailableShellTool({
preferredShell,
availability,
allowFallback = true,
}: {
preferredShell: ShellToolType
availability: ShellToolAvailability
allowFallback?: boolean
}): ShellToolType | null {
if (availability[preferredShell]) {
return preferredShell
}
if (!allowFallback) {
return null
}
const fallback = preferredShell === 'bash' ? 'powershell' : 'bash'
return availability[fallback] ? fallback : null
}
export function getShellToolAvailability(): ShellToolAvailability {
const platform = getPlatform()
return resolveShellToolAvailability({
platform,
gitBashPath: platform === 'windows' ? tryFindGitBashPath() : null,
userType: process.env.USER_TYPE,
powerShellSetting: process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL,
})
}
/** Windows exposes Bash only when a real Git Bash installation is available. */
export function isBashToolEnabled(): boolean {
return getShellToolAvailability().bash
}
/**
* Runtime gate for PowerShellTool. Windows-only (the permission engine uses
* Win32-specific path normalizations). Ant defaults on (opt-out via env=0);
* external defaults off (opt-in via env=1).
*
* Used by tools.ts (tool-list visibility), processBashCommand (! routing),
* and promptShellExecution (skill frontmatter routing) so the gate is
* consistent across all paths that invoke PowerShellTool.call().
* Runtime gate for PowerShellTool. On Windows it remains opt-in for external
* users with Git Bash, but becomes the default fallback when Git Bash is
* unavailable. An explicit env=0 always opts out.
*/
export function isPowerShellToolEnabled(): boolean {
if (getPlatform() !== 'windows') return false
return process.env.USER_TYPE === 'ant'
? !isEnvDefinedFalsy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
: isEnvTruthy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
return getShellToolAvailability().powershell
}