From f332057b88f345dd5e3b77bce467ba6ed8968246 Mon Sep 17 00:00:00 2001 From: Relakkes Yang Date: Sun, 2 Aug 2026 16:45:19 +0800 Subject: [PATCH] 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. --- bin/claude-haha | 70 ++++-- package.json | 4 +- scripts/cli-launcher.test.ts | 35 +++ src/commands/commit-push-pr.ts | 202 ++++++++++----- src/commands/commit.ts | 110 +++++--- src/commands/security-review.ts | 91 ++++--- src/commands/windowsShellPrompts.test.ts | 238 ++++++++++++++++++ src/tools.shellAvailability.test.ts | 47 ++++ src/tools.ts | 17 +- src/tools/FileEditTool/FileEditTool.test.ts | 82 ++++++ src/tools/FileEditTool/FileEditTool.ts | 17 +- src/tools/FileEditTool/utils.test.ts | 95 +++++++ src/tools/FileEditTool/utils.ts | 125 ++++++++- src/tools/FileReadTool/FileReadTool.test.ts | 45 +++- .../processUserInput/processBashCommand.tsx | 12 +- src/utils/promptShellExecution.ts | 48 +++- src/utils/shell/resolveDefaultShell.test.ts | 40 +++ src/utils/shell/resolveDefaultShell.ts | 34 ++- src/utils/shell/shellToolUtils.test.ts | 90 +++++++ src/utils/shell/shellToolUtils.ts | 78 +++++- 20 files changed, 1266 insertions(+), 214 deletions(-) create mode 100644 scripts/cli-launcher.test.ts create mode 100644 src/commands/windowsShellPrompts.test.ts create mode 100644 src/tools.shellAvailability.test.ts create mode 100644 src/tools/FileEditTool/FileEditTool.test.ts create mode 100644 src/tools/FileEditTool/utils.test.ts create mode 100644 src/utils/shell/resolveDefaultShell.test.ts create mode 100644 src/utils/shell/shellToolUtils.test.ts diff --git a/bin/claude-haha b/bin/claude-haha index 35191424..5b1e100d 100755 --- a/bin/claude-haha +++ b/bin/claude-haha @@ -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) diff --git a/package.json b/package.json index e8b0608e..920cf9b2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/cli-launcher.test.ts b/scripts/cli-launcher.test.ts new file mode 100644 index 00000000..f1193e36 --- /dev/null +++ b/scripts/cli-launcher.test.ts @@ -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 }) + } +}) diff --git a/src/commands/commit-push-pr.ts b/src/commands/commit-push-pr.ts index ae270cc7..3fd69211 100644 --- a/src/commands/commit-push-pr.ts +++ b/src/commands/commit-push-pr.ts @@ -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 diff --git a/src/commands/commit.ts b/src/commands/commit.ts index 2c9b5555..6974a6f3 100644 --- a/src/commands/commit.ts +++ b/src/commands/commit.ts @@ -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 diff --git a/src/commands/security-review.ts b/src/commands/security-review.ts index 03f7057d..7c3e0b19 100644 --- a/src/commands/security-review.ts +++ b/src/commands/security-review.ts @@ -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) }, }) diff --git a/src/commands/windowsShellPrompts.test.ts b/src/commands/windowsShellPrompts.test.ts new file mode 100644 index 00000000..e671b58f --- /dev/null +++ b/src/commands/windowsShellPrompts.test.ts @@ -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') + }) +}) diff --git a/src/tools.shellAvailability.test.ts b/src/tools.shellAvailability.test.ts new file mode 100644 index 00000000..7dfe6b67 --- /dev/null +++ b/src/tools.shellAvailability.test.ts @@ -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']) + }) +}) diff --git a/src/tools.ts b/src/tools.ts index 09835abc..d5104f4f 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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). diff --git a/src/tools/FileEditTool/FileEditTool.test.ts b/src/tools/FileEditTool/FileEditTool.test.ts new file mode 100644 index 00000000..314d4d47 --- /dev/null +++ b/src/tools/FileEditTool/FileEditTool.test.ts @@ -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', + ) + }) +}) diff --git a/src/tools/FileEditTool/FileEditTool.ts b/src/tools/FileEditTool/FileEditTool.ts index 85399420..f9596a55 100644 --- a/src/tools/FileEditTool/FileEditTool.ts +++ b/src/tools/FileEditTool/FileEditTool.ts @@ -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 diff --git a/src/tools/FileEditTool/utils.test.ts b/src/tools/FileEditTool/utils.test.ts new file mode 100644 index 00000000..1e9f91cf --- /dev/null +++ b/src/tools/FileEditTool/utils.test.ts @@ -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新值', + ) + }) +}) diff --git a/src/tools/FileEditTool/utils.ts b/src/tools/FileEditTool/utils.ts index 2520ed55..798d02b6 100644 --- a/src/tools/FileEditTool/utils.ts +++ b/src/tools/FileEditTool/utils.ts @@ -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 diff --git a/src/tools/FileReadTool/FileReadTool.test.ts b/src/tools/FileReadTool/FileReadTool.test.ts index 6d62e8ee..a0f24cbe 100644 --- a/src/tools/FileReadTool/FileReadTool.test.ts +++ b/src/tools/FileReadTool/FileReadTool.test.ts @@ -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中文目标') + }) +}) diff --git a/src/utils/processUserInput/processBashCommand.tsx b/src/utils/processUserInput/processBashCommand.tsx index 8ab95850..7040c4db 100644 --- a/src/utils/processUserInput/processBashCommand.tsx +++ b/src/utils/processUserInput/processBashCommand.tsx @@ -18,11 +18,11 @@ export async function processBashCommand(inputString: string, precedingInputBloc messages: (UserMessage | AttachmentMessage | SystemMessage)[]; shouldQuery: boolean; }> { - // Shell routing (docs/design/ps-shell-selection.md §5.2): consult - // defaultShell, fall back to bash. isPowerShellToolEnabled() applies the - // same platform + env-var gate as tools.ts so input-box routing matches - // tool-list visibility. Computed up front so telemetry records the - // actual shell, not the raw setting. + // Shell routing (docs/design/ps-shell-selection.md §5.2) preserves the + // configured preference when available, then falls back to the installed + // shell. isPowerShellToolEnabled() applies the same platform + env-var gate + // as tools.ts so input-box routing matches tool-list visibility. Computed + // up front so telemetry records the actual shell, not the raw setting. const usePowerShell = isPowerShellToolEnabled() && resolveDefaultShell() === 'powershell'; logEvent('tengu_input_bash', { powershell: usePowerShell @@ -137,4 +137,4 @@ export async function processBashCommand(inputString: string, precedingInputBloc setToolJSX(null); } } -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJDb250ZW50QmxvY2tQYXJhbSIsInJhbmRvbVVVSUQiLCJSZWFjdCIsIkJhc2hNb2RlUHJvZ3Jlc3MiLCJTZXRUb29sSlNYRm4iLCJCYXNoVG9vbCIsIkF0dGFjaG1lbnRNZXNzYWdlIiwiU3lzdGVtTWVzc2FnZSIsIlVzZXJNZXNzYWdlIiwiU2hlbGxQcm9ncmVzcyIsImxvZ0V2ZW50IiwiZXJyb3JNZXNzYWdlIiwiU2hlbGxFcnJvciIsImNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlIiwiY3JlYXRlVXNlckludGVycnVwdGlvbk1lc3NhZ2UiLCJjcmVhdGVVc2VyTWVzc2FnZSIsInByZXBhcmVVc2VyQ29udGVudCIsInJlc29sdmVEZWZhdWx0U2hlbGwiLCJpc1Bvd2VyU2hlbGxUb29sRW5hYmxlZCIsInByb2Nlc3NUb29sUmVzdWx0QmxvY2siLCJlc2NhcGVYbWwiLCJQcm9jZXNzVXNlcklucHV0Q29udGV4dCIsInByb2Nlc3NCYXNoQ29tbWFuZCIsImlucHV0U3RyaW5nIiwicHJlY2VkaW5nSW5wdXRCbG9ja3MiLCJhdHRhY2htZW50TWVzc2FnZXMiLCJjb250ZXh0Iiwic2V0VG9vbEpTWCIsIlByb21pc2UiLCJtZXNzYWdlcyIsInNob3VsZFF1ZXJ5IiwidXNlUG93ZXJTaGVsbCIsInBvd2Vyc2hlbGwiLCJ1c2VyTWVzc2FnZSIsImNvbnRlbnQiLCJqc3giLCJSZWFjdE5vZGUiLCJvcHRpb25zIiwidmVyYm9zZSIsInNob3VsZEhpZGVQcm9tcHRJbnB1dCIsImJhc2hNb2RlQ29udGV4dCIsIl8iLCJvblByb2dyZXNzIiwicHJvZ3Jlc3MiLCJkYXRhIiwic2hvd1NwaW5uZXIiLCJQU01vZCIsIlBvd2VyU2hlbGxUb29sIiwicmVxdWlyZSIsInNoZWxsVG9vbCIsInJlc3BvbnNlIiwiY2FsbCIsImNvbW1hbmQiLCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94IiwidW5kZWZpbmVkIiwiRXJyb3IiLCJzdGRlcnIiLCJtYXBwZWQiLCJzdGRvdXQiLCJlIiwiaW50ZXJydXB0ZWQiLCJ0b29sVXNlIl0sInNvdXJjZXMiOlsicHJvY2Vzc0Jhc2hDb21tYW5kLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgdHlwZSB7IENvbnRlbnRCbG9ja1BhcmFtIH0gZnJvbSAnQGFudGhyb3BpYy1haS9zZGsvcmVzb3VyY2VzJ1xuaW1wb3J0IHsgcmFuZG9tVVVJRCB9IGZyb20gJ2NyeXB0bydcbmltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgQmFzaE1vZGVQcm9ncmVzcyB9IGZyb20gJ3NyYy9jb21wb25lbnRzL0Jhc2hNb2RlUHJvZ3Jlc3MuanMnXG5pbXBvcnQgdHlwZSB7IFNldFRvb2xKU1hGbiB9IGZyb20gJ3NyYy9Ub29sLmpzJ1xuaW1wb3J0IHsgQmFzaFRvb2wgfSBmcm9tICdzcmMvdG9vbHMvQmFzaFRvb2wvQmFzaFRvb2wuanMnXG5pbXBvcnQgdHlwZSB7XG4gIEF0dGFjaG1lbnRNZXNzYWdlLFxuICBTeXN0ZW1NZXNzYWdlLFxuICBVc2VyTWVzc2FnZSxcbn0gZnJvbSAnc3JjL3R5cGVzL21lc3NhZ2UuanMnXG5pbXBvcnQgdHlwZSB7IFNoZWxsUHJvZ3Jlc3MgfSBmcm9tICdzcmMvdHlwZXMvdG9vbHMuanMnXG5pbXBvcnQgeyBsb2dFdmVudCB9IGZyb20gJy4uLy4uL3NlcnZpY2VzL2FuYWx5dGljcy9pbmRleC5qcydcbmltcG9ydCB7IGVycm9yTWVzc2FnZSwgU2hlbGxFcnJvciB9IGZyb20gJy4uL2Vycm9ycy5qcydcbmltcG9ydCB7XG4gIGNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlLFxuICBjcmVhdGVVc2VySW50ZXJydXB0aW9uTWVzc2FnZSxcbiAgY3JlYXRlVXNlck1lc3NhZ2UsXG4gIHByZXBhcmVVc2VyQ29udGVudCxcbn0gZnJvbSAnLi4vbWVzc2FnZXMuanMnXG5pbXBvcnQgeyByZXNvbHZlRGVmYXVsdFNoZWxsIH0gZnJvbSAnLi4vc2hlbGwvcmVzb2x2ZURlZmF1bHRTaGVsbC5qcydcbmltcG9ydCB7IGlzUG93ZXJTaGVsbFRvb2xFbmFibGVkIH0gZnJvbSAnLi4vc2hlbGwvc2hlbGxUb29sVXRpbHMuanMnXG5pbXBvcnQgeyBwcm9jZXNzVG9vbFJlc3VsdEJsb2NrIH0gZnJvbSAnLi4vdG9vbFJlc3VsdFN0b3JhZ2UuanMnXG5pbXBvcnQgeyBlc2NhcGVYbWwgfSBmcm9tICcuLi94bWwuanMnXG5pbXBvcnQgdHlwZSB7IFByb2Nlc3NVc2VySW5wdXRDb250ZXh0IH0gZnJvbSAnLi9wcm9jZXNzVXNlcklucHV0LmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gcHJvY2Vzc0Jhc2hDb21tYW5kKFxuICBpbnB1dFN0cmluZzogc3RyaW5nLFxuICBwcmVjZWRpbmdJbnB1dEJsb2NrczogQ29udGVudEJsb2NrUGFyYW1bXSxcbiAgYXR0YWNobWVudE1lc3NhZ2VzOiBBdHRhY2htZW50TWVzc2FnZVtdLFxuICBjb250ZXh0OiBQcm9jZXNzVXNlcklucHV0Q29udGV4dCxcbiAgc2V0VG9vbEpTWDogU2V0VG9vbEpTWEZuLFxuKTogUHJvbWlzZTx7XG4gIG1lc3NhZ2VzOiAoVXNlck1lc3NhZ2UgfCBBdHRhY2htZW50TWVzc2FnZSB8IFN5c3RlbU1lc3NhZ2UpW11cbiAgc2hvdWxkUXVlcnk6IGJvb2xlYW5cbn0+IHtcbiAgLy8gU2hlbGwgcm91dGluZyAoZG9jcy9kZXNpZ24vcHMtc2hlbGwtc2VsZWN0aW9uLm1kIMKnNS4yKTogY29uc3VsdFxuICAvLyBkZWZhdWx0U2hlbGwsIGZhbGwgYmFjayB0byBiYXNoLiBpc1Bvd2VyU2hlbGxUb29sRW5hYmxlZCgpIGFwcGxpZXMgdGhlXG4gIC8vIHNhbWUgcGxhdGZvcm0gKyBlbnYtdmFyIGdhdGUgYXMgdG9vbHMudHMgc28gaW5wdXQtYm94IHJvdXRpbmcgbWF0Y2hlc1xuICAvLyB0b29sLWxpc3QgdmlzaWJpbGl0eS4gQ29tcHV0ZWQgdXAgZnJvbnQgc28gdGVsZW1ldHJ5IHJlY29yZHMgdGhlXG4gIC8vIGFjdHVhbCBzaGVsbCwgbm90IHRoZSByYXcgc2V0dGluZy5cbiAgY29uc3QgdXNlUG93ZXJTaGVsbCA9XG4gICAgaXNQb3dlclNoZWxsVG9vbEVuYWJsZWQoKSAmJiByZXNvbHZlRGVmYXVsdFNoZWxsKCkgPT09ICdwb3dlcnNoZWxsJ1xuXG4gIGxvZ0V2ZW50KCd0ZW5ndV9pbnB1dF9iYXNoJywgeyBwb3dlcnNoZWxsOiB1c2VQb3dlclNoZWxsIH0pXG5cbiAgY29uc3QgdXNlck1lc3NhZ2UgPSBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgY29udGVudDogcHJlcGFyZVVzZXJDb250ZW50KHtcbiAgICAgIGlucHV0U3RyaW5nOiBgPGJhc2gtaW5wdXQ+JHtpbnB1dFN0cmluZ308L2Jhc2gtaW5wdXQ+YCxcbiAgICAgIHByZWNlZGluZ0lucHV0QmxvY2tzLFxuICAgIH0pLFxuICB9KVxuXG4gIC8vIGN0cmwrYiB0byBiYWNrZ3JvdW5kIGluZGljYXRvclxuICBsZXQganN4OiBSZWFjdC5SZWFjdE5vZGVcblxuICAvLyBKdXN0IHNob3cgaW5pdGlhbCBVSVxuICBzZXRUb29sSlNYKHtcbiAgICBqc3g6IChcbiAgICAgIDxCYXNoTW9kZVByb2dyZXNzXG4gICAgICAgIGlucHV0PXtpbnB1dFN0cmluZ31cbiAgICAgICAgcHJvZ3Jlc3M9e251bGx9XG4gICAgICAgIHZlcmJvc2U9e2NvbnRleHQub3B0aW9ucy52ZXJib3NlfVxuICAgICAgLz5cbiAgICApLFxuICAgIHNob3VsZEhpZGVQcm9tcHRJbnB1dDogZmFsc2UsXG4gIH0pXG5cbiAgdHJ5IHtcbiAgICBjb25zdCBiYXNoTW9kZUNvbnRleHQ6IFByb2Nlc3NVc2VySW5wdXRDb250ZXh0ID0ge1xuICAgICAgLi4uY29udGV4dCxcbiAgICAgIC8vIFRPRE86IENsZWFuIHVwIHRoaXMgaGFja1xuICAgICAgc2V0VG9vbEpTWDogXyA9PiB7XG4gICAgICAgIGpzeCA9IF8/LmpzeFxuICAgICAgfSxcbiAgICB9XG5cbiAgICAvLyBQcm9ncmVzcyBVSSDigJQgc2hhcmVkIGFjcm9zcyBib3RoIHNoZWxsIGJhY2tlbmRzIChib3RoIGVtaXQgU2hlbGxQcm9ncmVzcylcbiAgICBjb25zdCBvblByb2dyZXNzID0gKHByb2dyZXNzOiB7IGRhdGE6IFNoZWxsUHJvZ3Jlc3MgfSkgPT4ge1xuICAgICAgc2V0VG9vbEpTWCh7XG4gICAgICAgIGpzeDogKFxuICAgICAgICAgIDw+XG4gICAgICAgICAgICA8QmFzaE1vZGVQcm9ncmVzc1xuICAgICAgICAgICAgICBpbnB1dD17aW5wdXRTdHJpbmchfVxuICAgICAgICAgICAgICBwcm9ncmVzcz17cHJvZ3Jlc3MuZGF0YX1cbiAgICAgICAgICAgICAgdmVyYm9zZT17Y29udGV4dC5vcHRpb25zLnZlcmJvc2V9XG4gICAgICAgICAgICAvPlxuICAgICAgICAgICAge2pzeH1cbiAgICAgICAgICA8Lz5cbiAgICAgICAgKSxcbiAgICAgICAgc2hvdWxkSGlkZVByb21wdElucHV0OiBmYWxzZSxcbiAgICAgICAgc2hvd1NwaW5uZXI6IGZhbHNlLFxuICAgICAgfSlcbiAgICB9XG5cbiAgICAvLyBVc2VyLWluaXRpYXRlZCBgIWAgY29tbWFuZHMgcnVuIG91dHNpZGUgc2FuZGJveC4gQm90aCBzaGVsbCB0b29scyBob25vclxuICAgIC8vIGRhbmdlcm91c2x5RGlzYWJsZVNhbmRib3ggKGNoZWNrZWQgYWdhaW5zdCBhcmVVbnNhbmRib3hlZENvbW1hbmRzQWxsb3dlZCgpXG4gICAgLy8gaW4gc2hvdWxkVXNlU2FuZGJveC50cykuIFBTIHNhbmRib3ggaXMgTGludXgvbWFjT1MvV1NMMiBvbmx5IOKAlCBvbiBXaW5kb3dzXG4gICAgLy8gbmF0aXZlLCBzaG91bGRVc2VTYW5kYm94KCkgcmV0dXJucyBmYWxzZSByZWdhcmRsZXNzICh1bnN1cHBvcnRlZCBwbGF0Zm9ybSkuXG4gICAgLy8gTGF6eS1yZXF1aXJlIFBvd2VyU2hlbGxUb29sIHNvIGl0cyB+MzAwS0IgY2h1bmsgb25seSBsb2FkcyB3aGVuIHRoZVxuICAgIC8vIHVzZXIgaGFzIGFjdHVhbGx5IHNlbGVjdGVkIHRoZSBwb3dlcnNoZWxsIGRlZmF1bHQgc2hlbGwuXG4gICAgdHlwZSBQU01vZCA9IHR5cGVvZiBpbXBvcnQoJ3NyYy90b29scy9Qb3dlclNoZWxsVG9vbC9Qb3dlclNoZWxsVG9vbC5qcycpXG4gICAgbGV0IFBvd2VyU2hlbGxUb29sOiBQU01vZFsnUG93ZXJTaGVsbFRvb2wnXSB8IG51bGwgPSBudWxsXG4gICAgaWYgKHVzZVBvd2VyU2hlbGwpIHtcbiAgICAgIC8qIGVzbGludC1kaXNhYmxlIEB0eXBlc2NyaXB0LWVzbGludC9uby1yZXF1aXJlLWltcG9ydHMgKi9cbiAgICAgIFBvd2VyU2hlbGxUb29sID0gKFxuICAgICAgICByZXF1aXJlKCdzcmMvdG9vbHMvUG93ZXJTaGVsbFRvb2wvUG93ZXJTaGVsbFRvb2wuanMnKSBhcyBQU01vZFxuICAgICAgKS5Qb3dlclNoZWxsVG9vbFxuICAgICAgLyogZXNsaW50LWVuYWJsZSBAdHlwZXNjcmlwdC1lc2xpbnQvbm8tcmVxdWlyZS1pbXBvcnRzICovXG4gICAgfVxuICAgIGNvbnN0IHNoZWxsVG9vbCA9IFBvd2VyU2hlbGxUb29sID8/IEJhc2hUb29sXG5cbiAgICBjb25zdCByZXNwb25zZSA9IFBvd2VyU2hlbGxUb29sXG4gICAgICA/IGF3YWl0IFBvd2VyU2hlbGxUb29sLmNhbGwoXG4gICAgICAgICAgeyBjb21tYW5kOiBpbnB1dFN0cmluZywgZGFuZ2Vyb3VzbHlEaXNhYmxlU2FuZGJveDogdHJ1ZSB9LFxuICAgICAgICAgIGJhc2hNb2RlQ29udGV4dCxcbiAgICAgICAgICB1bmRlZmluZWQsXG4gICAgICAgICAgdW5kZWZpbmVkLFxuICAgICAgICAgIG9uUHJvZ3Jlc3MsXG4gICAgICAgIClcbiAgICAgIDogYXdhaXQgQmFzaFRvb2wuY2FsbChcbiAgICAgICAgICB7XG4gICAgICAgICAgICBjb21tYW5kOiBpbnB1dFN0cmluZyxcbiAgICAgICAgICAgIGRhbmdlcm91c2x5RGlzYWJsZVNhbmRib3g6IHRydWUsXG4gICAgICAgICAgfSxcbiAgICAgICAgICBiYXNoTW9kZUNvbnRleHQsXG4gICAgICAgICAgdW5kZWZpbmVkLFxuICAgICAgICAgIHVuZGVmaW5lZCxcbiAgICAgICAgICBvblByb2dyZXNzLFxuICAgICAgICApXG4gICAgY29uc3QgZGF0YSA9IHJlc3BvbnNlLmRhdGFcblxuICAgIGlmICghZGF0YSkge1xuICAgICAgdGhyb3cgbmV3IEVycm9yKCdObyByZXN1bHQgcmVjZWl2ZWQgZnJvbSBzaGVsbCBjb21tYW5kJylcbiAgICB9XG5cbiAgICBjb25zdCBzdGRlcnIgPSBkYXRhLnN0ZGVyclxuICAgIC8vIFJldXNlIHRoZSBzYW1lIGZvcm1hdHRpbmcgcGlwZWxpbmUgYXMgaW5saW5lICFgY21kYCBiYXNoIChwcm9tcHRTaGVsbEV4ZWN1dGlvbilcbiAgICAvLyBhbmQgbW9kZWwtaW5pdGlhdGVkIEJhc2guIFdoZW4gQmFzaFRvb2wuY2FsbCgpIHBlcnNpc3RzIGxhcmdlIG91dHB1dCB0byBkaXNrLFxuICAgIC8vIGRhdGEucGVyc2lzdGVkT3V0cHV0UGF0aCBpcyBzZXQgYW5kIHRoZSBmb3JtYXR0ZXIgd3JhcHMgaW4gPHBlcnNpc3RlZC1vdXRwdXQ+LlxuICAgIC8vIFBhc3Mgc3RkZXJyOicnIHRvIGtlZXAgaXQgc2VwYXJhdGUgZm9yIHRoZSA8YmFzaC1zdGRlcnI+IFVJIHRhZy5cbiAgICBjb25zdCBtYXBwZWQgPSBhd2FpdCBwcm9jZXNzVG9vbFJlc3VsdEJsb2NrKFxuICAgICAgc2hlbGxUb29sLFxuICAgICAgeyAuLi5kYXRhLCBzdGRlcnI6ICcnIH0sXG4gICAgICByYW5kb21VVUlEKCksXG4gICAgKVxuICAgIC8vIG1hcHBlZC5jb250ZW50IG1heSBjb250YWluIG91ciBvd24gPHBlcnNpc3RlZC1vdXRwdXQ+IHdyYXBwZXIgKHRydXN0ZWRcbiAgICAvLyBYTUwgZnJvbSBidWlsZExhcmdlVG9vbFJlc3VsdE1lc3NhZ2UpLiBFc2NhcGluZyBpdCB3b3VsZCB0dXJuIHN0cnVjdHVyYWxcbiAgICAvLyB0YWdzIGludG8gJmx0O3BlcnNpc3RlZC1vdXRwdXQmZ3Q7LCBicmVha2luZyB0aGUgbW9kZWwncyBwYXJzZSBhbmRcbiAgICAvLyBVc2VyQmFzaE91dHB1dE1lc3NhZ2UncyBleHRyYWN0VGFnLiBFc2NhcGUgdGhlIHJhdyBmYWxsYmFjayBvbmx5LlxuICAgIGNvbnN0IHN0ZG91dCA9XG4gICAgICB0eXBlb2YgbWFwcGVkLmNvbnRlbnQgPT09ICdzdHJpbmcnXG4gICAgICAgID8gbWFwcGVkLmNvbnRlbnRcbiAgICAgICAgOiBlc2NhcGVYbWwoZGF0YS5zdGRvdXQpXG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgIGNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlKCksXG4gICAgICAgIHVzZXJNZXNzYWdlLFxuICAgICAgICAuLi5hdHRhY2htZW50TWVzc2FnZXMsXG4gICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICBjb250ZW50OiBgPGJhc2gtc3Rkb3V0PiR7c3Rkb3V0fTwvYmFzaC1zdGRvdXQ+PGJhc2gtc3RkZXJyPiR7ZXNjYXBlWG1sKHN0ZGVycil9PC9iYXNoLXN0ZGVycj5gLFxuICAgICAgICB9KSxcbiAgICAgIF0sXG4gICAgICBzaG91bGRRdWVyeTogZmFsc2UsXG4gICAgfVxuICB9IGNhdGNoIChlKSB7XG4gICAgaWYgKGUgaW5zdGFuY2VvZiBTaGVsbEVycm9yKSB7XG4gICAgICBpZiAoZS5pbnRlcnJ1cHRlZCkge1xuICAgICAgICByZXR1cm4ge1xuICAgICAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgICAgICBjcmVhdGVTeW50aGV0aWNVc2VyQ2F2ZWF0TWVzc2FnZSgpLFxuICAgICAgICAgICAgdXNlck1lc3NhZ2UsXG4gICAgICAgICAgICBjcmVhdGVVc2VySW50ZXJydXB0aW9uTWVzc2FnZSh7IHRvb2xVc2U6IGZhbHNlIH0pLFxuICAgICAgICAgICAgLi4uYXR0YWNobWVudE1lc3NhZ2VzLFxuICAgICAgICAgIF0sXG4gICAgICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgICAgICB9XG4gICAgICB9XG4gICAgICByZXR1cm4ge1xuICAgICAgICBtZXNzYWdlczogW1xuICAgICAgICAgIGNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlKCksXG4gICAgICAgICAgdXNlck1lc3NhZ2UsXG4gICAgICAgICAgLi4uYXR0YWNobWVudE1lc3NhZ2VzLFxuICAgICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICAgIGNvbnRlbnQ6IGA8YmFzaC1zdGRvdXQ+JHtlc2NhcGVYbWwoZS5zdGRvdXQpfTwvYmFzaC1zdGRvdXQ+PGJhc2gtc3RkZXJyPiR7ZXNjYXBlWG1sKGUuc3RkZXJyKX08L2Jhc2gtc3RkZXJyPmAsXG4gICAgICAgICAgfSksXG4gICAgICAgIF0sXG4gICAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICAgIH1cbiAgICB9XG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgIGNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlKCksXG4gICAgICAgIHVzZXJNZXNzYWdlLFxuICAgICAgICAuLi5hdHRhY2htZW50TWVzc2FnZXMsXG4gICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICBjb250ZW50OiBgPGJhc2gtc3RkZXJyPkNvbW1hbmQgZmFpbGVkOiAke2VzY2FwZVhtbChlcnJvck1lc3NhZ2UoZSkpfTwvYmFzaC1zdGRlcnI+YCxcbiAgICAgICAgfSksXG4gICAgICBdLFxuICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgIH1cbiAgfSBmaW5hbGx5IHtcbiAgICBzZXRUb29sSlNYKG51bGwpXG4gIH1cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsY0FBY0EsaUJBQWlCLFFBQVEsNkJBQTZCO0FBQ3BFLFNBQVNDLFVBQVUsUUFBUSxRQUFRO0FBQ25DLE9BQU8sS0FBS0MsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsZ0JBQWdCLFFB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TUFBTXlELE1BQU0sR0FDVixPQUFPRCxNQUFNLENBQUN2QixPQUFPLEtBQUssUUFBUSxHQUM5QnVCLE1BQU0sQ0FBQ3ZCLE9BQU8sR0FDZGQsU0FBUyxDQUFDd0IsSUFBSSxDQUFDYyxNQUFNLENBQUM7SUFDNUIsT0FBTztNQUNMN0IsUUFBUSxFQUFFLENBQ1JoQixnQ0FBZ0MsQ0FBQyxDQUFDLEVBQ2xDb0IsV0FBVyxFQUNYLEdBQUdSLGtCQUFrQixFQUNyQlYsaUJBQWlCLENBQUM7UUFDaEJtQixPQUFPLEVBQUUsZ0JBQWdCd0IsTUFBTSw4QkFBOEJ0QyxTQUFTLENBQUNvQyxNQUFNLENBQUM7TUFDaEYsQ0FBQyxDQUFDLENBQ0g7TUFDRDFCLFdBQVcsRUFBRTtJQUNmLENBQUM7RUFDSCxDQUFDLENBQUMsT0FBTzZCLENBQUMsRUFBRTtJQUNWLElBQUlBLENBQUMsWUFBWS9DLFVBQVUsRUFBRTtNQUMzQixJQUFJK0MsQ0FBQyxDQUFDQyxXQUFXLEVBQUU7UUFDakIsT0FBTztVQUNML0IsUUFBUSxFQUFFLENBQ1JoQixnQ0FBZ0MsQ0FBQyxDQUFDLEVBQ2xDb0IsV0FBVyxFQUNYbkIsNkJBQTZCLENBQUM7WUFBRStDLE9BQU8sRUFBRTtVQUFNLENBQUMsQ0FBQyxFQUNqRCxHQUFHcEMsa0JBQWtCLENBQ3RCO1VBQ0RLLFdBQVcsRUFBRTtRQUNmLENBQUM7TUFDSDtNQUNBLE9BQU87UUFDTEQsUUFBUSxFQUFFLENBQ1JoQixnQ0FBZ0MsQ0FBQyxDQUFDLEVBQ2xDb0IsV0FBVyxFQUNYLEdBQUdSLGtCQUFrQixFQUNyQlYsaUJBQWlCLENBQUM7VUFDaEJtQixPQUFPLEVBQUUsZ0JBQWdCZCxTQUFTLENBQUN1QyxDQUFDLENBQUNELE1BQU0sQ0FBQyw4QkFBOEJ0QyxTQUFTLENBQUN1QyxDQUFDLENBQUNILE1BQU0sQ0FBQztRQUMvRixDQUFDLENBQUMsQ0FDSDtRQUNEMUIsV0FBVyxFQUFFO01BQ2YsQ0FBQztJQUNIO0lBQ0EsT0FBTztNQUNMRCxRQUFRLEVBQUUsQ0FDUmhCLGdDQUFnQyxDQUFDLENBQUMsRUFDbENvQixXQUFXLEVBQ1gsR0FBR1Isa0JBQWtCLEVBQ3JCVixpQkFBaUIsQ0FBQztRQUNoQm1CLE9BQU8sRUFBRSxnQ0FBZ0NkLFNBQVMsQ0FBQ1QsWUFBWSxDQUFDZ0QsQ0FBQyxDQUFDLENBQUM7TUFDckUsQ0FBQyxDQUFDLENBQ0g7TUFDRDdCLFdBQVcsRUFBRTtJQUNmLENBQUM7RUFDSCxDQUFDLFNBQVM7SUFDUkgsVUFBVSxDQUFDLElBQUksQ0FBQztFQUNsQjtBQUNGIiwiaWdub3JlTGlzdCI6W119 \ No newline at end of file +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJDb250ZW50QmxvY2tQYXJhbSIsInJhbmRvbVVVSUQiLCJSZWFjdCIsIkJhc2hNb2RlUHJvZ3Jlc3MiLCJTZXRUb29sSlNYRm4iLCJCYXNoVG9vbCIsIkF0dGFjaG1lbnRNZXNzYWdlIiwiU3lzdGVtTWVzc2FnZSIsIlVzZXJNZXNzYWdlIiwiU2hlbGxQcm9ncmVzcyIsImxvZ0V2ZW50IiwiZXJyb3JNZXNzYWdlIiwiU2hlbGxFcnJvciIsImNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlIiwiY3JlYXRlVXNlckludGVycnVwdGlvbk1lc3NhZ2UiLCJjcmVhdGVVc2VyTWVzc2FnZSIsInByZXBhcmVVc2VyQ29udGVudCIsInJlc29sdmVEZWZhdWx0U2hlbGwiLCJpc1Bvd2VyU2hlbGxUb29sRW5hYmxlZCIsInByb2Nlc3NUb29sUmVzdWx0QmxvY2siLCJlc2NhcGVYbWwiLCJQcm9jZXNzVXNlcklucHV0Q29udGV4dCIsInByb2Nlc3NCYXNoQ29tbWFuZCIsImlucHV0U3RyaW5nIiwicHJlY2VkaW5nSW5wdXRCbG9ja3MiLCJhdHRhY2htZW50TWVzc2FnZXMiLCJjb250ZXh0Iiwic2V0VG9vbEpTWCIsIlByb21pc2UiLCJtZXNzYWdlcyIsInNob3VsZFF1ZXJ5IiwidXNlUG93ZXJTaGVsbCIsInBvd2Vyc2hlbGwiLCJ1c2VyTWVzc2FnZSIsImNvbnRlbnQiLCJqc3giLCJSZWFjdE5vZGUiLCJvcHRpb25zIiwidmVyYm9zZSIsInNob3VsZEhpZGVQcm9tcHRJbnB1dCIsImJhc2hNb2RlQ29udGV4dCIsIl8iLCJvblByb2dyZXNzIiwicHJvZ3Jlc3MiLCJkYXRhIiwic2hvd1NwaW5uZXIiLCJQU01vZCIsIlBvd2VyU2hlbGxUb29sIiwicmVxdWlyZSIsInNoZWxsVG9vbCIsInJlc3BvbnNlIiwiY2FsbCIsImNvbW1hbmQiLCJkYW5nZXJvdXNseURpc2FibGVTYW5kYm94IiwidW5kZWZpbmVkIiwiRXJyb3IiLCJzdGRlcnIiLCJtYXBwZWQiLCJzdGRvdXQiLCJlIiwiaW50ZXJydXB0ZWQiLCJ0b29sVXNlIl0sInNvdXJjZXMiOlsicHJvY2Vzc0Jhc2hDb21tYW5kLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgdHlwZSB7IENvbnRlbnRCbG9ja1BhcmFtIH0gZnJvbSAnQGFudGhyb3BpYy1haS9zZGsvcmVzb3VyY2VzJ1xuaW1wb3J0IHsgcmFuZG9tVVVJRCB9IGZyb20gJ2NyeXB0bydcbmltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgQmFzaE1vZGVQcm9ncmVzcyB9IGZyb20gJ3NyYy9jb21wb25lbnRzL0Jhc2hNb2RlUHJvZ3Jlc3MuanMnXG5pbXBvcnQgdHlwZSB7IFNldFRvb2xKU1hGbiB9IGZyb20gJ3NyYy9Ub29sLmpzJ1xuaW1wb3J0IHsgQmFzaFRvb2wgfSBmcm9tICdzcmMvdG9vbHMvQmFzaFRvb2wvQmFzaFRvb2wuanMnXG5pbXBvcnQgdHlwZSB7XG4gIEF0dGFjaG1lbnRNZXNzYWdlLFxuICBTeXN0ZW1NZXNzYWdlLFxuICBVc2VyTWVzc2FnZSxcbn0gZnJvbSAnc3JjL3R5cGVzL21lc3NhZ2UuanMnXG5pbXBvcnQgdHlwZSB7IFNoZWxsUHJvZ3Jlc3MgfSBmcm9tICdzcmMvdHlwZXMvdG9vbHMuanMnXG5pbXBvcnQgeyBsb2dFdmVudCB9IGZyb20gJy4uLy4uL3NlcnZpY2VzL2FuYWx5dGljcy9pbmRleC5qcydcbmltcG9ydCB7IGVycm9yTWVzc2FnZSwgU2hlbGxFcnJvciB9IGZyb20gJy4uL2Vycm9ycy5qcydcbmltcG9ydCB7XG4gIGNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlLFxuICBjcmVhdGVVc2VySW50ZXJydXB0aW9uTWVzc2FnZSxcbiAgY3JlYXRlVXNlck1lc3NhZ2UsXG4gIHByZXBhcmVVc2VyQ29udGVudCxcbn0gZnJvbSAnLi4vbWVzc2FnZXMuanMnXG5pbXBvcnQgeyByZXNvbHZlRGVmYXVsdFNoZWxsIH0gZnJvbSAnLi4vc2hlbGwvcmVzb2x2ZURlZmF1bHRTaGVsbC5qcydcbmltcG9ydCB7IGlzUG93ZXJTaGVsbFRvb2xFbmFibGVkIH0gZnJvbSAnLi4vc2hlbGwvc2hlbGxUb29sVXRpbHMuanMnXG5pbXBvcnQgeyBwcm9jZXNzVG9vbFJlc3VsdEJsb2NrIH0gZnJvbSAnLi4vdG9vbFJlc3VsdFN0b3JhZ2UuanMnXG5pbXBvcnQgeyBlc2NhcGVYbWwgfSBmcm9tICcuLi94bWwuanMnXG5pbXBvcnQgdHlwZSB7IFByb2Nlc3NVc2VySW5wdXRDb250ZXh0IH0gZnJvbSAnLi9wcm9jZXNzVXNlcklucHV0LmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gcHJvY2Vzc0Jhc2hDb21tYW5kKFxuICBpbnB1dFN0cmluZzogc3RyaW5nLFxuICBwcmVjZWRpbmdJbnB1dEJsb2NrczogQ29udGVudEJsb2NrUGFyYW1bXSxcbiAgYXR0YWNobWVudE1lc3NhZ2VzOiBBdHRhY2htZW50TWVzc2FnZVtdLFxuICBjb250ZXh0OiBQcm9jZXNzVXNlcklucHV0Q29udGV4dCxcbiAgc2V0VG9vbEpTWDogU2V0VG9vbEpTWEZuLFxuKTogUHJvbWlzZTx7XG4gIG1lc3NhZ2VzOiAoVXNlck1lc3NhZ2UgfCBBdHRhY2htZW50TWVzc2FnZSB8IFN5c3RlbU1lc3NhZ2UpW11cbiAgc2hvdWxkUXVlcnk6IGJvb2xlYW5cbn0+IHtcbiAgLy8gU2hlbGwgcm91dGluZyAoZG9jcy9kZXNpZ24vcHMtc2hlbGwtc2VsZWN0aW9uLm1kIMKnNS4yKTogY29uc3VsdFxuICAvLyBkZWZhdWx0U2hlbGwsIGZhbGwgYmFjayB0byBiYXNoLiBpc1Bvd2VyU2hlbGxUb29sRW5hYmxlZCgpIGFwcGxpZXMgdGhlXG4gIC8vIHNhbWUgcGxhdGZvcm0gKyBlbnYtdmFyIGdhdGUgYXMgdG9vbHMudHMgc28gaW5wdXQtYm94IHJvdXRpbmcgbWF0Y2hlc1xuICAvLyB0b29sLWxpc3QgdmlzaWJpbGl0eS4gQ29tcHV0ZWQgdXAgZnJvbnQgc28gdGVsZW1ldHJ5IHJlY29yZHMgdGhlXG4gIC8vIGFjdHVhbCBzaGVsbCwgbm90IHRoZSByYXcgc2V0dGluZy5cbiAgY29uc3QgdXNlUG93ZXJTaGVsbCA9XG4gICAgaXNQb3dlclNoZWxsVG9vbEVuYWJsZWQoKSAmJiByZXNvbHZlRGVmYXVsdFNoZWxsKCkgPT09ICdwb3dlcnNoZWxsJ1xuXG4gIGxvZ0V2ZW50KCd0ZW5ndV9pbnB1dF9iYXNoJywgeyBwb3dlcnNoZWxsOiB1c2VQb3dlclNoZWxsIH0pXG5cbiAgY29uc3QgdXNlck1lc3NhZ2UgPSBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgY29udGVudDogcHJlcGFyZVVzZXJDb250ZW50KHtcbiAgICAgIGlucHV0U3RyaW5nOiBgPGJhc2gtaW5wdXQ+JHtpbnB1dFN0cmluZ308L2Jhc2gtaW5wdXQ+YCxcbiAgICAgIHByZWNlZGluZ0lucHV0QmxvY2tzLFxuICAgIH0pLFxuICB9KVxuXG4gIC8vIGN0cmwrYiB0byBiYWNrZ3JvdW5kIGluZGljYXRvclxuICBsZXQganN4OiBSZWFjdC5SZWFjdE5vZGVcblxuICAvLyBKdXN0IHNob3cgaW5pdGlhbCBVSVxuICBzZXRUb29sSlNYKHtcbiAgICBqc3g6IChcbiAgICAgIDxCYXNoTW9kZVByb2dyZXNzXG4gICAgICAgIGlucHV0PXtpbnB1dFN0cmluZ31cbiAgICAgICAgcHJvZ3Jlc3M9e251bGx9XG4gICAgICAgIHZlcmJvc2U9e2NvbnRleHQub3B0aW9ucy52ZXJib3NlfVxuICAgICAgLz5cbiAgICApLFxuICAgIHNob3VsZEhpZGVQcm9tcHRJbnB1dDogZmFsc2UsXG4gIH0pXG5cbiAgdHJ5IHtcbiAgICBjb25zdCBiYXNoTW9kZUNvbnRleHQ6IFByb2Nlc3NVc2VySW5wdXRDb250ZXh0ID0ge1xuICAgICAgLi4uY29udGV4dCxcbiAgICAgIC8vIFRPRE86IENsZWFuIHVwIHRoaXMgaGFja1xuICAgICAgc2V0VG9vbEpTWDogXyA9PiB7XG4gICAgICAgIGpzeCA9IF8/LmpzeFxuICAgICAgfSxcbiAgICB9XG5cbiAgICAvLyBQcm9ncmVzcyBVSSDigJQgc2hhcmVkIGFjcm9zcyBib3RoIHNoZWxsIGJhY2tlbmRzIChib3RoIGVtaXQgU2hlbGxQcm9ncmVzcylcbiAgICBjb25zdCBvblByb2dyZXNzID0gKHByb2dyZXNzOiB7IGRhdGE6IFNoZWxsUHJvZ3Jlc3MgfSkgPT4ge1xuICAgICAgc2V0VG9vbEpTWCh7XG4gICAgICAgIGpzeDogKFxuICAgICAgICAgIDw+XG4gICAgICAgICAgICA8QmFzaE1vZGVQcm9ncmVzc1xuICAgICAgICAgICAgICBpbnB1dD17aW5wdXRTdHJpbmchfVxuICAgICAgICAgICAgICBwcm9ncmVzcz17cHJvZ3Jlc3MuZGF0YX1cbiAgICAgICAgICAgICAgdmVyYm9zZT17Y29udGV4dC5vcHRpb25zLnZlcmJvc2V9XG4gICAgICAgICAgICAvPlxuICAgICAgICAgICAge2pzeH1cbiAgICAgICAgICA8Lz5cbiAgICAgICAgKSxcbiAgICAgICAgc2hvdWxkSGlkZVByb21wdElucHV0OiBmYWxzZSxcbiAgICAgICAgc2hvd1NwaW5uZXI6IGZhbHNlLFxuICAgICAgfSlcbiAgICB9XG5cbiAgICAvLyBVc2VyLWluaXRpYXRlZCBgIWAgY29tbWFuZHMgcnVuIG91dHNpZGUgc2FuZGJveC4gQm90aCBzaGVsbCB0b29scyBob25vclxuICAgIC8vIGRhbmdlcm91c2x5RGlzYWJsZVNhbmRib3ggKGNoZWNrZWQgYWdhaW5zdCBhcmVVbnNhbmRib3hlZENvbW1hbmRzQWxsb3dlZCgpXG4gICAgLy8gaW4gc2hvdWxkVXNlU2FuZGJveC50cykuIFBTIHNhbmRib3ggaXMgTGludXgvbWFjT1MvV1NMMiBvbmx5IOKAlCBvbiBXaW5kb3dzXG4gICAgLy8gbmF0aXZlLCBzaG91bGRVc2VTYW5kYm94KCkgcmV0dXJucyBmYWxzZSByZWdhcmRsZXNzICh1bnN1cHBvcnRlZCBwbGF0Zm9ybSkuXG4gICAgLy8gTGF6eS1yZXF1aXJlIFBvd2VyU2hlbGxUb29sIHNvIGl0cyB+MzAwS0IgY2h1bmsgb25seSBsb2FkcyB3aGVuIHRoZVxuICAgIC8vIHVzZXIgaGFzIGFjdHVhbGx5IHNlbGVjdGVkIHRoZSBwb3dlcnNoZWxsIGRlZmF1bHQgc2hlbGwuXG4gICAgdHlwZSBQU01vZCA9IHR5cGVvZiBpbXBvcnQoJ3NyYy90b29scy9Qb3dlclNoZWxsVG9vbC9Qb3dlclNoZWxsVG9vbC5qcycpXG4gICAgbGV0IFBvd2VyU2hlbGxUb29sOiBQU01vZFsnUG93ZXJTaGVsbFRvb2wnXSB8IG51bGwgPSBudWxsXG4gICAgaWYgKHVzZVBvd2VyU2hlbGwpIHtcbiAgICAgIC8qIGVzbGludC1kaXNhYmxlIEB0eXBlc2NyaXB0LWVzbGludC9uby1yZXF1aXJlLWltcG9ydHMgKi9cbiAgICAgIFBvd2VyU2hlbGxUb29sID0gKFxuICAgICAgICByZXF1aXJlKCdzcmMvdG9vbHMvUG93ZXJTaGVsbFRvb2wvUG93ZXJTaGVsbFRvb2wuanMnKSBhcyBQU01vZFxuICAgICAgKS5Qb3dlclNoZWxsVG9vbFxuICAgICAgLyogZXNsaW50LWVuYWJsZSBAdHlwZXNjcmlwdC1lc2xpbnQvbm8tcmVxdWlyZS1pbXBvcnRzICovXG4gICAgfVxuICAgIGNvbnN0IHNoZWxsVG9vbCA9IFBvd2VyU2hlbGxUb29sID8/IEJhc2hUb29sXG5cbiAgICBjb25zdCByZXNwb25zZSA9IFBvd2VyU2hlbGxUb29sXG4gICAgICA/IGF3YWl0IFBvd2VyU2hlbGxUb29sLmNhbGwoXG4gICAgICAgICAgeyBjb21tYW5kOiBpbnB1dFN0cmluZywgZGFuZ2Vyb3VzbHlEaXNhYmxlU2FuZGJveDogdHJ1ZSB9LFxuICAgICAgICAgIGJhc2hNb2RlQ29udGV4dCxcbiAgICAgICAgICB1bmRlZmluZWQsXG4gICAgICAgICAgdW5kZWZpbmVkLFxuICAgICAgICAgIG9uUHJvZ3Jlc3MsXG4gICAgICAgIClcbiAgICAgIDogYXdhaXQgQmFzaFRvb2wuY2FsbChcbiAgICAgICAgICB7XG4gICAgICAgICAgICBjb21tYW5kOiBpbnB1dFN0cmluZyxcbiAgICAgICAgICAgIGRhbmdlcm91c2x5RGlzYWJsZVNhbmRib3g6IHRydWUsXG4gICAgICAgICAgfSxcbiAgICAgICAgICBiYXNoTW9kZUNvbnRleHQsXG4gICAgICAgICAgdW5kZWZpbmVkLFxuICAgICAgICAgIHVuZGVmaW5lZCxcbiAgICAgICAgICBvblByb2dyZXNzLFxuICAgICAgICApXG4gICAgY29uc3QgZGF0YSA9IHJlc3BvbnNlLmRhdGFcblxuICAgIGlmICghZGF0YSkge1xuICAgICAgdGhyb3cgbmV3IEVycm9yKCdObyByZXN1bHQgcmVjZWl2ZWQgZnJvbSBzaGVsbCBjb21tYW5kJylcbiAgICB9XG5cbiAgICBjb25zdCBzdGRlcnIgPSBkYXRhLnN0ZGVyclxuICAgIC8vIFJldXNlIHRoZSBzYW1lIGZvcm1hdHRpbmcgcGlwZWxpbmUgYXMgaW5saW5lICFgY21kYCBiYXNoIChwcm9tcHRTaGVsbEV4ZWN1dGlvbilcbiAgICAvLyBhbmQgbW9kZWwtaW5pdGlhdGVkIEJhc2guIFdoZW4gQmFzaFRvb2wuY2FsbCgpIHBlcnNpc3RzIGxhcmdlIG91dHB1dCB0byBkaXNrLFxuICAgIC8vIGRhdGEucGVyc2lzdGVkT3V0cHV0UGF0aCBpcyBzZXQgYW5kIHRoZSBmb3JtYXR0ZXIgd3JhcHMgaW4gPHBlcnNpc3RlZC1vdXRwdXQ+LlxuICAgIC8vIFBhc3Mgc3RkZXJyOicnIHRvIGtlZXAgaXQgc2VwYXJhdGUgZm9yIHRoZSA8YmFzaC1zdGRlcnI+IFVJIHRhZy5cbiAgICBjb25zdCBtYXBwZWQgPSBhd2FpdCBwcm9jZXNzVG9vbFJlc3VsdEJsb2NrKFxuICAgICAgc2hlbGxUb29sLFxuICAgICAgeyAuLi5kYXRhLCBzdGRlcnI6ICcnIH0sXG4gICAgICByYW5kb21VVUlEKCksXG4gICAgKVxuICAgIC8vIG1hcHBlZC5jb250ZW50IG1heSBjb250YWluIG91ciBvd24gPHBlcnNpc3RlZC1vdXRwdXQ+IHdyYXBwZXIgKHRydXN0ZWRcbiAgICAvLyBYTUwgZnJvbSBidWlsZExhcmdlVG9vbFJlc3VsdE1lc3NhZ2UpLiBFc2NhcGluZyBpdCB3b3VsZCB0dXJuIHN0cnVjdHVyYWxcbiAgICAvLyB0YWdzIGludG8gJmx0O3BlcnNpc3RlZC1vdXRwdXQmZ3Q7LCBicmVha2luZyB0aGUgbW9kZWwncyBwYXJzZSBhbmRcbiAgICAvLyBVc2VyQmFzaE91dHB1dE1lc3NhZ2UncyBleHRyYWN0VGFnLiBFc2NhcGUgdGhlIHJhdyBmYWxsYmFjayBvbmx5LlxuICAgIGNvbnN0IHN0ZG91dCA9XG4gICAgICB0eXBlb2YgbWFwcGVkLmNvbnRlbnQgPT09ICdzdHJpbmcnXG4gICAgICAgID8gbWFwcGVkLmNvbnRlbnRcbiAgICAgICAgOiBlc2NhcGVYbWwoZGF0YS5zdGRvdXQpXG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgIGNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlKCksXG4gICAgICAgIHVzZXJNZXNzYWdlLFxuICAgICAgICAuLi5hdHRhY2htZW50TWVzc2FnZXMsXG4gICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICBjb250ZW50OiBgPGJhc2gtc3Rkb3V0PiR7c3Rkb3V0fTwvYmFzaC1zdGRvdXQ+PGJhc2gtc3RkZXJyPiR7ZXNjYXBlWG1sKHN0ZGVycil9PC9iYXNoLXN0ZGVycj5gLFxuICAgICAgICB9KSxcbiAgICAgIF0sXG4gICAgICBzaG91bGRRdWVyeTogZmFsc2UsXG4gICAgfVxuICB9IGNhdGNoIChlKSB7XG4gICAgaWYgKGUgaW5zdGFuY2VvZiBTaGVsbEVycm9yKSB7XG4gICAgICBpZiAoZS5pbnRlcnJ1cHRlZCkge1xuICAgICAgICByZXR1cm4ge1xuICAgICAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgICAgICBjcmVhdGVTeW50aGV0aWNVc2VyQ2F2ZWF0TWVzc2FnZSgpLFxuICAgICAgICAgICAgdXNlck1lc3NhZ2UsXG4gICAgICAgICAgICBjcmVhdGVVc2VySW50ZXJydXB0aW9uTWVzc2FnZSh7IHRvb2xVc2U6IGZhbHNlIH0pLFxuICAgICAgICAgICAgLi4uYXR0YWNobWVudE1lc3NhZ2VzLFxuICAgICAgICAgIF0sXG4gICAgICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgICAgICB9XG4gICAgICB9XG4gICAgICByZXR1cm4ge1xuICAgICAgICBtZXNzYWdlczogW1xuICAgICAgICAgIGNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlKCksXG4gICAgICAgICAgdXNlck1lc3NhZ2UsXG4gICAgICAgICAgLi4uYXR0YWNobWVudE1lc3NhZ2VzLFxuICAgICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICAgIGNvbnRlbnQ6IGA8YmFzaC1zdGRvdXQ+JHtlc2NhcGVYbWwoZS5zdGRvdXQpfTwvYmFzaC1zdGRvdXQ+PGJhc2gtc3RkZXJyPiR7ZXNjYXBlWG1sKGUuc3RkZXJyKX08L2Jhc2gtc3RkZXJyPmAsXG4gICAgICAgICAgfSksXG4gICAgICAgIF0sXG4gICAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICAgIH1cbiAgICB9XG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgIGNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlKCksXG4gICAgICAgIHVzZXJNZXNzYWdlLFxuICAgICAgICAuLi5hdHRhY2htZW50TWVzc2FnZXMsXG4gICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICBjb250ZW50OiBgPGJhc2gtc3RkZXJyPkNvbW1hbmQgZmFpbGVkOiAke2VzY2FwZVhtbChlcnJvck1lc3NhZ2UoZSkpfTwvYmFzaC1zdGRlcnI+YCxcbiAgICAgICAgfSksXG4gICAgICBdLFxuICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgIH1cbiAgfSBmaW5hbGx5IHtcbiAgICBzZXRUb29sSlNYKG51bGwpXG4gIH1cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsY0FBY0EsaUJBQWlCLFFBQVEsNkJBQTZCO0FBQ3BFLFNBQVNDLFVBQVUsUUFBUSxRQUFRO0FBQ25DLE9BQU8sS0FBS0MsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsZ0JBQWdCLFFB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TUFBTXlELE1BQU0sR0FDVixPQUFPRCxNQUFNLENBQUN2QixPQUFPLEtBQUssUUFBUSxHQUM5QnVCLE1BQU0sQ0FBQ3ZCLE9BQU8sR0FDZGQsU0FBUyxDQUFDd0IsSUFBSSxDQUFDYyxNQUFNLENBQUM7SUFDNUIsT0FBTztNQUNMN0IsUUFBUSxFQUFFLENBQ1JoQixnQ0FBZ0MsQ0FBQyxDQUFDLEVBQ2xDb0IsV0FBVyxFQUNYLEdBQUdSLGtCQUFrQixFQUNyQlYsaUJBQWlCLENBQUM7UUFDaEJtQixPQUFPLEVBQUUsZ0JBQWdCd0IsTUFBTSw4QkFBOEJ0QyxTQUFTLENBQUNvQyxNQUFNLENBQUM7TUFDaEYsQ0FBQyxDQUFDLENBQ0g7TUFDRDFCLFdBQVcsRUFBRTtJQUNmLENBQUM7RUFDSCxDQUFDLENBQUMsT0FBTzZCLENBQUMsRUFBRTtJQUNWLElBQUlBLENBQUMsWUFBWS9DLFVBQVUsRUFBRTtNQUMzQixJQUFJK0MsQ0FBQyxDQUFDQyxXQUFXLEVBQUU7UUFDakIsT0FBTztVQUNML0IsUUFBUSxFQUFFLENBQ1JoQixnQ0FBZ0MsQ0FBQyxDQUFDLEVBQ2xDb0IsV0FBVyxFQUNYbkIsNkJBQTZCLENBQUM7WUFBRStDLE9BQU8sRUFBRTtVQUFNLENBQUMsQ0FBQyxFQUNqRCxHQUFHcEMsa0JBQWtCLENBQ3RCO1VBQ0RLLFdBQVcsRUFBRTtRQUNmLENBQUM7TUFDSDtNQUNBLE9BQU87UUFDTEQsUUFBUSxFQUFFLENBQ1JoQixnQ0FBZ0MsQ0FBQyxDQUFDLEVBQ2xDb0IsV0FBVyxFQUNYLEdBQUdSLGtCQUFrQixFQUNyQlYsaUJBQWlCLENBQUM7VUFDaEJtQixPQUFPLEVBQUUsZ0JBQWdCZCxTQUFTLENBQUN1QyxDQUFDLENBQUNELE1BQU0sQ0FBQyw4QkFBOEJ0QyxTQUFTLENBQUN1QyxDQUFDLENBQUNILE1BQU0sQ0FBQztRQUMvRixDQUFDLENBQUMsQ0FDSDtRQUNEMUIsV0FBVyxFQUFFO01BQ2YsQ0FBQztJQUNIO0lBQ0EsT0FBTztNQUNMRCxRQUFRLEVBQUUsQ0FDUmhCLGdDQUFnQyxDQUFDLENBQUMsRUFDbENvQixXQUFXLEVBQ1gsR0FBR1Isa0JBQWtCLEVBQ3JCVixpQkFBaUIsQ0FBQztRQUNoQm1CLE9BQU8sRUFBRSxnQ0FBZ0NkLFNBQVMsQ0FBQ1QsWUFBWSxDQUFDZ0QsQ0FBQyxDQUFDLENBQUM7TUFDckUsQ0FBQyxDQUFDLENBQ0g7TUFDRDdCLFdBQVcsRUFBRTtJQUNmLENBQUM7RUFDSCxDQUFDLFNBQVM7SUFDUkgsVUFBVSxDQUFDLElBQUksQ0FBQztFQUNsQjtBQUNGIiwiaWdub3JlTGlzdCI6W119 diff --git a/src/utils/promptShellExecution.ts b/src/utils/promptShellExecution.ts index 94269946..12db1332 100644 --- a/src/utils/promptShellExecution.ts +++ b/src/utils/promptShellExecution.ts @@ -24,7 +24,18 @@ type PromptShellTool = Tool & { ): Promise<{ data: ShellOut }> } -import { isPowerShellToolEnabled } from './shell/shellToolUtils.js' +type PromptShellExecutionDependencies = { + availability?: ReturnType + 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 { 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(), diff --git a/src/utils/shell/resolveDefaultShell.test.ts b/src/utils/shell/resolveDefaultShell.test.ts new file mode 100644 index 00000000..3bde6d4f --- /dev/null +++ b/src/utils/shell/resolveDefaultShell.test.ts @@ -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() + }) +}) diff --git a/src/utils/shell/resolveDefaultShell.ts b/src/utils/shell/resolveDefaultShell.ts index fd6dd3da..57bc7013 100644 --- a/src/utils/shell/resolveDefaultShell.ts +++ b/src/utils/shell/resolveDefaultShell.ts @@ -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(), + ) } diff --git a/src/utils/shell/shellToolUtils.test.ts b/src/utils/shell/shellToolUtils.test.ts new file mode 100644 index 00000000..90cd94e9 --- /dev/null +++ b/src/utils/shell/shellToolUtils.test.ts @@ -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() + }) +}) diff --git a/src/utils/shell/shellToolUtils.ts b/src/utils/shell/shellToolUtils.ts index 0de75034..787461e8 100644 --- a/src/utils/shell/shellToolUtils.ts +++ b/src/utils/shell/shellToolUtils.ts @@ -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 + 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 }