mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
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:
+45
-25
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user