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
|
#!/usr/bin/env -S bun --no-env-file
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
import { existsSync } from 'node:fs'
|
||||||
#Get your current working directory and export it as an environment variable.
|
import path from 'node:path'
|
||||||
export CALLER_DIR="${CALLER_DIR:-$(pwd -W 2>/dev/null || pwd)}"
|
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,
|
const command = [
|
||||||
# skip .env loading — the server has already set the correct env
|
process.execPath,
|
||||||
# via cc-haha/settings.json. Loading .env would re-inject stale
|
'--feature=TRANSCRIPT_CLASSIFIER',
|
||||||
# provider keys (e.g., a MiniMax key as ANTHROPIC_API_KEY) that
|
...envFileArgs,
|
||||||
# override the active provider config.
|
entrypoint,
|
||||||
if [[ "${CC_HAHA_SKIP_DOTENV:-0}" == "1" ]]; then
|
...process.argv.slice(2),
|
||||||
# Bun auto-loads .env by default; explicitly point to /dev/null to suppress.
|
]
|
||||||
ENV_FILE_FLAG="--env-file=/dev/null"
|
const child = Bun.spawn(command, {
|
||||||
elif [[ -f .env ]]; then
|
cwd: rootDir,
|
||||||
ENV_FILE_FLAG="--env-file=.env"
|
env: { ...process.env, CALLER_DIR: callerDir },
|
||||||
else
|
stdin: 'inherit',
|
||||||
ENV_FILE_FLAG=""
|
stdout: 'inherit',
|
||||||
fi
|
stderr: 'inherit',
|
||||||
|
})
|
||||||
|
|
||||||
# Force recovery CLI (simple readline REPL, no Ink TUI)
|
const forwardSignal = (signal: NodeJS.Signals) => {
|
||||||
if [[ "${CLAUDE_CODE_FORCE_RECOVERY_CLI:-0}" == "1" ]]; then
|
try {
|
||||||
exec bun --feature=TRANSCRIPT_CLASSIFIER $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@"
|
child.kill(signal)
|
||||||
fi
|
} 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
|
const exitCode = await child.exited
|
||||||
exec bun --feature=TRANSCRIPT_CLASSIFIER $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@"
|
process.off('SIGINT', forwardInterrupt)
|
||||||
|
process.off('SIGTERM', forwardTerminate)
|
||||||
|
process.exit(exitCode)
|
||||||
|
|||||||
+2
-2
@@ -8,8 +8,8 @@
|
|||||||
"claude-haha": "./bin/claude-haha"
|
"claude-haha": "./bin/claude-haha"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"claude-haha": "bun run ./bin/claude-haha",
|
"claude-haha": "bun --no-env-file run ./bin/claude-haha",
|
||||||
"start": "bun 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": "bun run scripts/perf/local-index-benchmark.ts",
|
||||||
"perf:local-index:10k": "bun run scripts/perf/local-index-benchmark.ts --sessions 10000 --runs 20",
|
"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",
|
"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 { Command } from '../commands.js'
|
||||||
|
import type { ToolUseContext } from '../Tool.js'
|
||||||
import {
|
import {
|
||||||
getAttributionTexts,
|
getAttributionTexts,
|
||||||
getEnhancedPRAttribution,
|
getEnhancedPRAttribution,
|
||||||
} from '../utils/attribution.js'
|
} from '../utils/attribution.js'
|
||||||
import { getDefaultBranch } from '../utils/git.js'
|
import { getDefaultBranch } from '../utils/git.js'
|
||||||
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
|
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
|
||||||
|
import {
|
||||||
|
resolveDefaultShell,
|
||||||
|
type ShellToolType,
|
||||||
|
} from '../utils/shell/resolveDefaultShell.js'
|
||||||
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'
|
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'
|
||||||
|
|
||||||
const ALLOWED_TOOLS = [
|
const ALLOWED_COMMANDS = [
|
||||||
'Bash(git checkout --branch:*)',
|
'git checkout --branch:*',
|
||||||
'Bash(git checkout -b:*)',
|
'git checkout -b:*',
|
||||||
'Bash(git add:*)',
|
'git add:*',
|
||||||
'Bash(git status:*)',
|
'git status:*',
|
||||||
'Bash(git push:*)',
|
'git push:*',
|
||||||
'Bash(git commit:*)',
|
'git commit:*',
|
||||||
'Bash(gh pr create:*)',
|
'gh pr create:*',
|
||||||
'Bash(gh pr edit:*)',
|
'gh pr edit:*',
|
||||||
'Bash(gh pr view:*)',
|
'gh pr view:*',
|
||||||
'Bash(gh pr merge:*)',
|
'gh pr merge:*',
|
||||||
|
]
|
||||||
|
|
||||||
|
const NON_SHELL_ALLOWED_TOOLS = [
|
||||||
'ToolSearch',
|
'ToolSearch',
|
||||||
'mcp__slack__send_message',
|
'mcp__slack__send_message',
|
||||||
'mcp__claude_ai_Slack__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,
|
defaultBranch: string,
|
||||||
|
shell: ShellToolType,
|
||||||
prAttribution?: string,
|
prAttribution?: string,
|
||||||
): string {
|
): string {
|
||||||
const { commit: commitAttribution, pr: defaultPrAttribution } =
|
const { commit: commitAttribution, pr: defaultPrAttribution } =
|
||||||
@@ -54,6 +74,45 @@ function getPromptContent(
|
|||||||
slackStep = ''
|
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
|
return `${prefix}## Context
|
||||||
|
|
||||||
- \`SAFEUSER\`: ${safeUser}
|
- \`SAFEUSER\`: ${safeUser}
|
||||||
@@ -62,7 +121,7 @@ function getPromptContent(
|
|||||||
- \`git diff HEAD\`: !\`git diff HEAD\`
|
- \`git diff HEAD\`: !\`git diff HEAD\`
|
||||||
- \`git branch --show-current\`: !\`git branch --show-current\`
|
- \`git branch --show-current\`: !\`git branch --show-current\`
|
||||||
- \`git diff ${defaultBranch}...HEAD\`: !\`git diff ${defaultBranch}...HEAD\`
|
- \`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
|
## 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:
|
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\`)
|
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'
|
${commitExample}
|
||||||
Commit message here.${commitAttribution ? `\n\n${commitAttribution}` : ''}
|
|
||||||
EOF
|
|
||||||
)"
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
3. Push the branch to origin
|
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.
|
- IMPORTANT: Keep PR titles short (under 70 characters). Use the body for details.
|
||||||
\`\`\`
|
\`\`\`
|
||||||
gh pr create --title "Short, descriptive title" --body "$(cat <<'EOF'
|
${prExample}
|
||||||
## Summary
|
|
||||||
<1-3 bullet points>
|
|
||||||
|
|
||||||
## Test plan
|
|
||||||
[Bulleted markdown checklist of TODOs for testing the pull request...]${changelogSection}${effectivePrAttribution ? `\n\n${effectivePrAttribution}` : ''}
|
|
||||||
EOF
|
|
||||||
)"
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
You have the capability to call multiple tools in a single response. You MUST do all of the above in a single message.${slackStep}
|
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.`
|
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 = {
|
const command = {
|
||||||
type: 'prompt',
|
type: 'prompt',
|
||||||
name: 'commit-push-pr',
|
name: 'commit-push-pr',
|
||||||
description: 'Commit, push, and open a PR',
|
description: 'Commit, push, and open a PR',
|
||||||
allowedTools: ALLOWED_TOOLS,
|
get allowedTools() {
|
||||||
|
return getCommitPushPrAllowedTools(resolveDefaultShell())
|
||||||
|
},
|
||||||
get contentLength() {
|
get contentLength() {
|
||||||
// Use 'main' as estimate for content length calculation
|
// Use 'main' as estimate for content length calculation
|
||||||
return getPromptContent('main').length
|
return getPromptContent('main', resolveDefaultShell() ?? 'bash').length
|
||||||
},
|
},
|
||||||
progressMessage: 'creating commit and PR',
|
progressMessage: 'creating commit and PR',
|
||||||
source: 'builtin',
|
source: 'builtin',
|
||||||
async getPromptForCommand(args, context) {
|
async getPromptForCommand(args, context) {
|
||||||
// Get default branch and enhanced PR attribution
|
return buildCommitPushPrPrompt(args, context)
|
||||||
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 }]
|
|
||||||
},
|
},
|
||||||
} satisfies Command
|
} satisfies Command
|
||||||
|
|
||||||
|
|||||||
+76
-34
@@ -1,15 +1,26 @@
|
|||||||
import type { Command } from '../commands.js'
|
import type { Command } from '../commands.js'
|
||||||
|
import type { ToolUseContext } from '../Tool.js'
|
||||||
import { getAttributionTexts } from '../utils/attribution.js'
|
import { getAttributionTexts } from '../utils/attribution.js'
|
||||||
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
|
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
|
||||||
|
import {
|
||||||
|
resolveDefaultShell,
|
||||||
|
type ShellToolType,
|
||||||
|
} from '../utils/shell/resolveDefaultShell.js'
|
||||||
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'
|
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'
|
||||||
|
|
||||||
const ALLOWED_TOOLS = [
|
const ALLOWED_COMMANDS = [
|
||||||
'Bash(git add:*)',
|
'git add:*',
|
||||||
'Bash(git status:*)',
|
'git status:*',
|
||||||
'Bash(git commit:*)',
|
'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()
|
const { commit: commitAttribution } = getAttributionTexts()
|
||||||
|
|
||||||
let prefix = ''
|
let prefix = ''
|
||||||
@@ -17,6 +28,18 @@ function getPromptContent(): string {
|
|||||||
prefix = getUndercoverInstructions() + '\n'
|
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
|
return `${prefix}## Context
|
||||||
|
|
||||||
- Current git status: !\`git status\`
|
- 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.)
|
- 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"
|
- 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'
|
${commitExample}
|
||||||
Commit message here.${commitAttribution ? `\n\n${commitAttribution}` : ''}
|
|
||||||
EOF
|
|
||||||
)"
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
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.`
|
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 = {
|
const command = {
|
||||||
type: 'prompt',
|
type: 'prompt',
|
||||||
name: 'commit',
|
name: 'commit',
|
||||||
description: 'Create a git commit',
|
description: 'Create a git commit',
|
||||||
allowedTools: ALLOWED_TOOLS,
|
get allowedTools() {
|
||||||
|
return getCommitAllowedTools(resolveDefaultShell())
|
||||||
|
},
|
||||||
contentLength: 0, // Dynamic content
|
contentLength: 0, // Dynamic content
|
||||||
progressMessage: 'creating commit',
|
progressMessage: 'creating commit',
|
||||||
source: 'builtin',
|
source: 'builtin',
|
||||||
async getPromptForCommand(_args, context) {
|
async getPromptForCommand(_args, context) {
|
||||||
const promptContent = getPromptContent()
|
return buildCommitPrompt(context)
|
||||||
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 }]
|
|
||||||
},
|
},
|
||||||
} satisfies Command
|
} satisfies Command
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { parseFrontmatter } from '../utils/frontmatterParser.js'
|
import { parseFrontmatter } from '../utils/frontmatterParser.js'
|
||||||
import { parseSlashCommandToolsFromFrontmatter } from '../utils/markdownConfigLoader.js'
|
import { parseSlashCommandToolsFromFrontmatter } from '../utils/markdownConfigLoader.js'
|
||||||
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.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'
|
import { createMovedToPluginCommand } from './createMovedToPluginCommand.js'
|
||||||
|
|
||||||
const SECURITY_REVIEW_MARKDOWN = `---
|
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.`
|
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({
|
export default createMovedToPluginCommand({
|
||||||
name: 'security-review',
|
name: 'security-review',
|
||||||
description:
|
description:
|
||||||
@@ -203,41 +257,6 @@ export default createMovedToPluginCommand({
|
|||||||
pluginName: 'security-review',
|
pluginName: 'security-review',
|
||||||
pluginCommand: 'security-review',
|
pluginCommand: 'security-review',
|
||||||
async getPromptWhileMarketplaceIsPrivate(_args, context) {
|
async getPromptWhileMarketplaceIsPrivate(_args, context) {
|
||||||
// Parse frontmatter from the markdown
|
return buildSecurityReviewPrompt(context)
|
||||||
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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { getDenyRuleForTool } from './utils/permissions/permissions.js'
|
||||||
import { hasEmbeddedSearchTools } from './utils/embeddedTools.js'
|
import { hasEmbeddedSearchTools } from './utils/embeddedTools.js'
|
||||||
import { isEnvTruthy } from './utils/envUtils.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 { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js'
|
||||||
import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.js'
|
import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.js'
|
||||||
import {
|
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.
|
* 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 {
|
export function getAllBaseTools(): Tools {
|
||||||
|
const powerShellTool = getPowerShellTool()
|
||||||
return [
|
return [
|
||||||
AgentTool,
|
AgentTool,
|
||||||
TaskOutputTool,
|
TaskOutputTool,
|
||||||
BashTool,
|
...(isBashToolEnabled() ? [BashTool] : []),
|
||||||
// Ant-native builds have bfs/ugrep embedded in the bun binary (same ARGV0
|
// 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
|
// 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.
|
// to these fast tools, so the dedicated Glob/Grep tools are unnecessary.
|
||||||
@@ -240,7 +244,7 @@ export function getAllBaseTools(): Tools {
|
|||||||
...(SendUserFileTool ? [SendUserFileTool] : []),
|
...(SendUserFileTool ? [SendUserFileTool] : []),
|
||||||
...(PushNotificationTool ? [PushNotificationTool] : []),
|
...(PushNotificationTool ? [PushNotificationTool] : []),
|
||||||
...(SubscribePRTool ? [SubscribePRTool] : []),
|
...(SubscribePRTool ? [SubscribePRTool] : []),
|
||||||
...(getPowerShellTool() ? [getPowerShellTool()] : []),
|
...(powerShellTool ? [powerShellTool] : []),
|
||||||
...(SnipTool ? [SnipTool] : []),
|
...(SnipTool ? [SnipTool] : []),
|
||||||
...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),
|
...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),
|
||||||
ListMcpResourcesTool,
|
ListMcpResourcesTool,
|
||||||
@@ -285,7 +289,12 @@ export const getTools = (permissionContext: ToolPermissionContext): Tools => {
|
|||||||
}
|
}
|
||||||
return filterToolsByDenyRules(replSimple, permissionContext)
|
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
|
// When coordinator mode is also active, include AgentTool and TaskStopTool
|
||||||
// so the coordinator gets Task+TaskStop (via useMergedTools filtering) and
|
// so the coordinator gets Task+TaskStop (via useMergedTools filtering) and
|
||||||
// workers get Bash/Read/Edit (via filterToolsForAgent filtering).
|
// 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,
|
areFileEditsInputsEquivalent,
|
||||||
findActualString,
|
findActualString,
|
||||||
getPatchForEdit,
|
getPatchForEdit,
|
||||||
|
preserveIndentationStyle,
|
||||||
preserveQuoteStyle,
|
preserveQuoteStyle,
|
||||||
} from './utils.js'
|
} 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
|
// Additional validation for Claude settings files
|
||||||
const settingsValidationResult = validateInputForSettingsFileEdit(
|
const settingsValidationResult = validateInputForSettingsFileEdit(
|
||||||
fullFilePath,
|
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
|
// Simulate the edit to get the final content using the exact same logic as the tool
|
||||||
return replace_all
|
return replace_all
|
||||||
? file.replaceAll(actualOldString, new_string)
|
? file.replaceAll(actualOldString, actualNewString)
|
||||||
: file.replace(actualOldString, new_string)
|
: file.replace(actualOldString, actualNewString)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -471,11 +478,11 @@ export const FileEditTool = buildTool({
|
|||||||
const actualOldString =
|
const actualOldString =
|
||||||
findActualString(originalFileContents, old_string) || old_string
|
findActualString(originalFileContents, old_string) || old_string
|
||||||
|
|
||||||
// Preserve curly quotes in new_string when the file uses them
|
// Preserve file typography and indentation in new_string when possible
|
||||||
const actualNewString = preserveQuoteStyle(
|
const actualNewString = preserveIndentationStyle(
|
||||||
old_string,
|
old_string,
|
||||||
actualOldString,
|
actualOldString,
|
||||||
new_string,
|
preserveQuoteStyle(old_string, actualOldString, new_string),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 4. Generate patch
|
// 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,
|
fileContent: string,
|
||||||
searchString: string,
|
searchString: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
// First try exact match
|
|
||||||
if (fileContent.includes(searchString)) {
|
|
||||||
return searchString
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with normalized quotes
|
|
||||||
const normalizedSearch = normalizeQuotes(searchString)
|
const normalizedSearch = normalizeQuotes(searchString)
|
||||||
const normalizedFile = normalizeQuotes(fileContent)
|
const normalizedFile = normalizeQuotes(fileContent)
|
||||||
|
const startsWithIndentation = /^[ \t]+(?=\S)/.test(normalizedSearch)
|
||||||
|
|
||||||
const searchIndex = normalizedFile.indexOf(normalizedSearch)
|
// An exact match at the start of a line is stronger than a fuzzy candidate.
|
||||||
if (searchIndex !== -1) {
|
// A raw substring match is not enough here: four spaces also occur as a
|
||||||
// Find the actual string in the file that matches
|
// suffix of an eight-space indentation prefix.
|
||||||
return fileContent.substring(searchIndex, searchIndex + searchString.length)
|
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
|
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,
|
* When old_string matched via quote normalization (curly quotes in file,
|
||||||
* straight quotes from model), apply the same curly quote style to new_string
|
* 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 { PDF_MAX_PAGES_PER_READ } from '../../constants/apiLimits.js'
|
||||||
import type { ToolUseContext } from '../../Tool.js'
|
import type { ToolUseContext } from '../../Tool.js'
|
||||||
import { getEmptyToolPermissionContext } from '../../Tool.js'
|
import { getEmptyToolPermissionContext } from '../../Tool.js'
|
||||||
@@ -6,12 +9,24 @@ import { FileReadTool } from './FileReadTool.js'
|
|||||||
|
|
||||||
function makeToolUseContext(): ToolUseContext {
|
function makeToolUseContext(): ToolUseContext {
|
||||||
return {
|
return {
|
||||||
|
readFileState: new Map(),
|
||||||
|
abortController: new AbortController(),
|
||||||
getAppState: () => ({
|
getAppState: () => ({
|
||||||
toolPermissionContext: getEmptyToolPermissionContext(),
|
toolPermissionContext: getEmptyToolPermissionContext(),
|
||||||
}),
|
}),
|
||||||
} as unknown as ToolUseContext
|
} 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', () => {
|
describe('FileReadTool pages validation', () => {
|
||||||
test('ignores invalid PDF pages values for non-PDF files', async () => {
|
test('ignores invalid PDF pages values for non-PDF files', async () => {
|
||||||
const context = makeToolUseContext()
|
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 }>
|
): 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 →
|
// Lazy: this file is on the startup import chain (main → commands →
|
||||||
// loadSkillsDir → here). A static import would load PowerShellTool.ts
|
// loadSkillsDir → here). A static import would load PowerShellTool.ts
|
||||||
@@ -71,16 +82,35 @@ export async function executeShellCommandsInPrompt(
|
|||||||
context: ToolUseContext,
|
context: ToolUseContext,
|
||||||
slashCommandName: string,
|
slashCommandName: string,
|
||||||
shell?: FrontmatterShell,
|
shell?: FrontmatterShell,
|
||||||
|
dependencies: PromptShellExecutionDependencies = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
let result = text
|
let result = text
|
||||||
|
|
||||||
// Resolve the tool once. `shell === undefined` and `shell === 'bash'` both
|
// An explicit frontmatter shell is a syntax contract, so never silently run
|
||||||
// hit BashTool. PowerShell only when the runtime gate allows — a skill
|
// it through the other interpreter. Built-in commands pass their resolved
|
||||||
// author's frontmatter choice doesn't override the user's opt-in/out.
|
// 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 =
|
const shellTool: PromptShellTool =
|
||||||
shell === 'powershell' && isPowerShellToolEnabled()
|
resolvedShell === 'powershell'
|
||||||
? getPowerShellTool()
|
? dependencies.powerShellTool ?? getPowerShellTool()
|
||||||
: BashTool
|
: 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
|
// 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,
|
// 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) {
|
if (command) {
|
||||||
try {
|
try {
|
||||||
// Check permissions before executing
|
// Check permissions before executing
|
||||||
const permissionResult = await hasPermissionsToUseTool(
|
const permissionResult = await checkPermission(
|
||||||
shellTool,
|
shellTool,
|
||||||
{ command },
|
{ command },
|
||||||
context,
|
context,
|
||||||
@@ -114,7 +144,7 @@ export async function executeShellCommandsInPrompt(
|
|||||||
|
|
||||||
const { data } = await shellTool.call({ command }, context)
|
const { data } = await shellTool.call({ command }, context)
|
||||||
// Reuse the same persistence flow as regular Bash tool calls
|
// Reuse the same persistence flow as regular Bash tool calls
|
||||||
const toolResultBlock = await processToolResultBlock(
|
const toolResultBlock = await processResult(
|
||||||
shellTool,
|
shellTool,
|
||||||
data,
|
data,
|
||||||
randomUUID(),
|
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 { 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.
|
* Resolve the default shell for input-box `!` commands.
|
||||||
*
|
*
|
||||||
* Resolution order (docs/design/ps-shell-selection.md §4.2):
|
* Prefer settings.defaultShell (or Bash when unset), then fall back to the
|
||||||
* settings.defaultShell → 'bash'
|
* other shell only when the preferred executable is unavailable. This keeps
|
||||||
*
|
* the historical Bash default while allowing Windows installations without
|
||||||
* Platform default is 'bash' everywhere — we do NOT auto-flip Windows to
|
* Git Bash to use native PowerShell.
|
||||||
* PowerShell (would break existing Windows users with bash hooks).
|
|
||||||
*/
|
*/
|
||||||
export function resolveDefaultShell(): 'bash' | 'powershell' {
|
export function resolveDefaultShellFromAvailability(
|
||||||
return getInitialSettings().defaultShell ?? 'bash'
|
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 { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js'
|
||||||
import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js'
|
import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js'
|
||||||
import { getPlatform } from '../platform.js'
|
import { getPlatform } from '../platform.js'
|
||||||
|
import { tryFindGitBashPath } from '../windowsPaths.js'
|
||||||
|
|
||||||
export const SHELL_TOOL_NAMES: string[] = [BASH_TOOL_NAME, POWERSHELL_TOOL_NAME]
|
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
|
* Runtime gate for PowerShellTool. On Windows it remains opt-in for external
|
||||||
* Win32-specific path normalizations). Ant defaults on (opt-out via env=0);
|
* users with Git Bash, but becomes the default fallback when Git Bash is
|
||||||
* external defaults off (opt-in via env=1).
|
* unavailable. An explicit env=0 always opts out.
|
||||||
*
|
|
||||||
* 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().
|
|
||||||
*/
|
*/
|
||||||
export function isPowerShellToolEnabled(): boolean {
|
export function isPowerShellToolEnabled(): boolean {
|
||||||
if (getPlatform() !== 'windows') return false
|
return getShellToolAvailability().powershell
|
||||||
return process.env.USER_TYPE === 'ant'
|
|
||||||
? !isEnvDefinedFalsy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
|
|
||||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user