source dump of claude code
at main 582 lines 22 kB view raw
1import { execFile } from 'child_process' 2import { execa } from 'execa' 3import { mkdir, stat } from 'fs/promises' 4import * as os from 'os' 5import { join } from 'path' 6import { logEvent } from 'src/services/analytics/index.js' 7import { registerCleanup } from '../cleanupRegistry.js' 8import { getCwd } from '../cwd.js' 9import { logForDebugging } from '../debug.js' 10import { 11 embeddedSearchToolsBinaryPath, 12 hasEmbeddedSearchTools, 13} from '../embeddedTools.js' 14import { getClaudeConfigHomeDir } from '../envUtils.js' 15import { pathExists } from '../file.js' 16import { getFsImplementation } from '../fsOperations.js' 17import { logError } from '../log.js' 18import { getPlatform } from '../platform.js' 19import { ripgrepCommand } from '../ripgrep.js' 20import { subprocessEnv } from '../subprocessEnv.js' 21import { quote } from './shellQuote.js' 22 23const LITERAL_BACKSLASH = '\\' 24const SNAPSHOT_CREATION_TIMEOUT = 10000 // 10 seconds 25 26/** 27 * Creates a shell function that invokes `binaryPath` with a specific argv[0]. 28 * This uses the bun-internal ARGV0 dispatch trick: the bun binary checks its 29 * argv[0] and runs the embedded tool (rg, bfs, ugrep) that matches. 30 * 31 * @param prependArgs - Arguments to inject before the user's args (e.g., 32 * default flags). Injected literally; each element must be a valid shell 33 * word (no spaces/special chars). 34 */ 35function createArgv0ShellFunction( 36 funcName: string, 37 argv0: string, 38 binaryPath: string, 39 prependArgs: string[] = [], 40): string { 41 const quotedPath = quote([binaryPath]) 42 const argSuffix = 43 prependArgs.length > 0 ? `${prependArgs.join(' ')} "$@"` : '"$@"' 44 return [ 45 `function ${funcName} {`, 46 ' if [[ -n $ZSH_VERSION ]]; then', 47 ` ARGV0=${argv0} ${quotedPath} ${argSuffix}`, 48 ' elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "win32" ]]; then', 49 // On Windows (git bash), exec -a does not work, so use ARGV0 env var instead 50 // The bun binary reads from ARGV0 natively to set argv[0] 51 ` ARGV0=${argv0} ${quotedPath} ${argSuffix}`, 52 ' elif [[ $BASHPID != $$ ]]; then', 53 ` exec -a ${argv0} ${quotedPath} ${argSuffix}`, 54 ' else', 55 ` (exec -a ${argv0} ${quotedPath} ${argSuffix})`, 56 ' fi', 57 '}', 58 ].join('\n') 59} 60 61/** 62 * Creates ripgrep shell integration (alias or function) 63 * @returns Object with type and the shell snippet to use 64 */ 65export function createRipgrepShellIntegration(): { 66 type: 'alias' | 'function' 67 snippet: string 68} { 69 const rgCommand = ripgrepCommand() 70 71 // For embedded ripgrep (bun-internal), we need a shell function that sets argv0 72 if (rgCommand.argv0) { 73 return { 74 type: 'function', 75 snippet: createArgv0ShellFunction( 76 'rg', 77 rgCommand.argv0, 78 rgCommand.rgPath, 79 ), 80 } 81 } 82 83 // For regular ripgrep, use a simple alias target 84 const quotedPath = quote([rgCommand.rgPath]) 85 const quotedArgs = rgCommand.rgArgs.map(arg => quote([arg])) 86 const aliasTarget = 87 rgCommand.rgArgs.length > 0 88 ? `${quotedPath} ${quotedArgs.join(' ')}` 89 : quotedPath 90 91 return { type: 'alias', snippet: aliasTarget } 92} 93 94/** 95 * VCS directories to exclude from grep searches. Matches the list in 96 * GrepTool (see GrepTool.ts: VCS_DIRECTORIES_TO_EXCLUDE). 97 */ 98const VCS_DIRECTORIES_TO_EXCLUDE = [ 99 '.git', 100 '.svn', 101 '.hg', 102 '.bzr', 103 '.jj', 104 '.sl', 105] as const 106 107/** 108 * Creates shell integration for `find` and `grep`, backed by bfs and ugrep 109 * embedded in the bun binary (ant-native only). Unlike the rg integration, 110 * this always shadows the system find/grep since bfs/ugrep are drop-in 111 * replacements and we want consistent fast behavior. 112 * 113 * These wrappers replace the GlobTool/GrepTool dedicated tools (which are 114 * removed from the tool registry when embedded search tools are available), 115 * so they're tuned to match those tools' semantics, not GNU find/grep. 116 * 117 * `find` ↔ GlobTool: 118 * - Inject `-regextype findutils-default`: bfs defaults to POSIX BRE for 119 * -regex, but GNU find defaults to emacs-flavor (which supports `\|` 120 * alternation). Without this, `find . -regex '.*\.\(js\|ts\)'` silently 121 * returns zero results. A later user-supplied -regextype still overrides. 122 * - No gitignore filtering: GlobTool passes `--no-ignore` to rg. bfs has no 123 * gitignore support anyway, so this matches by default. 124 * - Hidden files included: both GlobTool (`--hidden`) and bfs's default. 125 * 126 * Caveat: even with findutils-default, Oniguruma (bfs's regex engine) uses 127 * leftmost-first alternation, not POSIX leftmost-longest. Patterns where 128 * one alternative is a prefix of another (e.g., `\(ts\|tsx\)`) may miss 129 * matches that GNU find catches. Workaround: put the longer alternative first. 130 * 131 * `grep` ↔ GrepTool (file filtering) + GNU grep (regex syntax): 132 * - `-G` (basic regex / BRE): GNU grep defaults to BRE where `\|` is 133 * alternation. ugrep defaults to ERE where `|` is alternation and `\|` is a 134 * literal pipe. Without -G, `grep "foo\|bar"` silently returns zero results. 135 * User-supplied `-E`, `-F`, or `-P` later in argv overrides this. 136 * - `--ignore-files`: respect .gitignore (GrepTool uses rg's default, which 137 * respects gitignore). Override with `grep --no-ignore-files`. 138 * - `--hidden`: include hidden files (GrepTool passes `--hidden` to rg). 139 * Override with `grep --no-hidden`. 140 * - `--exclude-dir` for VCS dirs: GrepTool passes `--glob '!.git'` etc. to rg. 141 * - `-I`: skip binary files. rg's recursion silently skips binary matches 142 * by default (different from direct-file-arg behavior); ugrep doesn't, so 143 * we inject -I to match. Override with `grep -a`. 144 * 145 * Not replicated from GrepTool: 146 * - `--max-columns 500`: ugrep's `--width` hard-truncates output which could 147 * break pipelines; rg's version replaces the line with a placeholder. 148 * - Read deny rules / plugin cache exclusions: require toolPermissionContext 149 * which isn't available at shell-snapshot creation time. 150 * 151 * Returns null if embedded search tools are not available in this build. 152 */ 153export function createFindGrepShellIntegration(): string | null { 154 if (!hasEmbeddedSearchTools()) { 155 return null 156 } 157 const binaryPath = embeddedSearchToolsBinaryPath() 158 return [ 159 // User shell configs may define aliases like `alias find=gfind` or 160 // `alias grep=ggrep` (common on macOS with Homebrew GNU tools). The 161 // snapshot sources user aliases before these function definitions, and 162 // bash expands aliases before function lookup — so a renaming alias 163 // would silently bypass the embedded bfs/ugrep dispatch. Clear them first 164 // (same fix the rg integration uses). 165 'unalias find 2>/dev/null || true', 166 'unalias grep 2>/dev/null || true', 167 createArgv0ShellFunction('find', 'bfs', binaryPath, [ 168 '-regextype', 169 'findutils-default', 170 ]), 171 createArgv0ShellFunction('grep', 'ugrep', binaryPath, [ 172 '-G', 173 '--ignore-files', 174 '--hidden', 175 '-I', 176 ...VCS_DIRECTORIES_TO_EXCLUDE.map(d => `--exclude-dir=${d}`), 177 ]), 178 ].join('\n') 179} 180 181function getConfigFile(shellPath: string): string { 182 const fileName = shellPath.includes('zsh') 183 ? '.zshrc' 184 : shellPath.includes('bash') 185 ? '.bashrc' 186 : '.profile' 187 188 const configPath = join(os.homedir(), fileName) 189 190 return configPath 191} 192 193/** 194 * Generates user-specific snapshot content (functions, options, aliases) 195 * This content is derived from the user's shell configuration file 196 */ 197function getUserSnapshotContent(configFile: string): string { 198 const isZsh = configFile.endsWith('.zshrc') 199 200 let content = '' 201 202 // User functions 203 if (isZsh) { 204 content += ` 205 echo "# Functions" >> "$SNAPSHOT_FILE" 206 207 # Force autoload all functions first 208 typeset -f > /dev/null 2>&1 209 210 # Now get user function names - filter completion functions (single underscore prefix) 211 # but keep double-underscore helpers (e.g. __zsh_like_cd from mise, __pyenv_init) 212 typeset +f | grep -vE '^_[^_]' | while read func; do 213 typeset -f "$func" >> "$SNAPSHOT_FILE" 214 done 215 ` 216 } else { 217 content += ` 218 echo "# Functions" >> "$SNAPSHOT_FILE" 219 220 # Force autoload all functions first 221 declare -f > /dev/null 2>&1 222 223 # Now get user function names - filter completion functions (single underscore prefix) 224 # but keep double-underscore helpers (e.g. __zsh_like_cd from mise, __pyenv_init) 225 declare -F | cut -d' ' -f3 | grep -vE '^_[^_]' | while read func; do 226 # Encode the function to base64, preserving all special characters 227 encoded_func=$(declare -f "$func" | base64 ) 228 # Write the function definition to the snapshot 229 echo "eval ${LITERAL_BACKSLASH}"${LITERAL_BACKSLASH}$(echo '$encoded_func' | base64 -d)${LITERAL_BACKSLASH}" > /dev/null 2>&1" >> "$SNAPSHOT_FILE" 230 done 231 ` 232 } 233 234 // Shell options 235 if (isZsh) { 236 content += ` 237 echo "# Shell Options" >> "$SNAPSHOT_FILE" 238 setopt | sed 's/^/setopt /' | head -n 1000 >> "$SNAPSHOT_FILE" 239 ` 240 } else { 241 content += ` 242 echo "# Shell Options" >> "$SNAPSHOT_FILE" 243 shopt -p | head -n 1000 >> "$SNAPSHOT_FILE" 244 set -o | grep "on" | awk '{print "set -o " $1}' | head -n 1000 >> "$SNAPSHOT_FILE" 245 echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE" 246 ` 247 } 248 249 // User aliases 250 content += ` 251 echo "# Aliases" >> "$SNAPSHOT_FILE" 252 # Filter out winpty aliases on Windows to avoid "stdin is not a tty" errors 253 # Git Bash automatically creates aliases like "alias node='winpty node.exe'" for 254 # programs that need Win32 Console in mintty, but winpty fails when there's no TTY 255 if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then 256 alias | grep -v "='winpty " | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE" 257 else 258 alias | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE" 259 fi 260 ` 261 262 return content 263} 264 265/** 266 * Generates Claude Code specific snapshot content 267 * This content is always included regardless of user configuration 268 */ 269async function getClaudeCodeSnapshotContent(): Promise<string> { 270 // Get the appropriate PATH based on platform 271 let pathValue = process.env.PATH 272 if (getPlatform() === 'windows') { 273 // On Windows with git-bash, read the Cygwin PATH 274 const cygwinResult = await execa('echo $PATH', { 275 shell: true, 276 reject: false, 277 }) 278 if (cygwinResult.exitCode === 0 && cygwinResult.stdout) { 279 pathValue = cygwinResult.stdout.trim() 280 } 281 // Fall back to process.env.PATH if we can't get Cygwin PATH 282 } 283 284 const rgIntegration = createRipgrepShellIntegration() 285 286 let content = '' 287 288 // Check if rg is available, if not create an alias/function to bundled ripgrep 289 // We use a subshell to unalias rg before checking, so that user aliases like 290 // `alias rg='rg --smart-case'` don't shadow the real binary check. The subshell 291 // ensures we don't modify the user's aliases in the parent shell. 292 content += ` 293 # Check for rg availability 294 echo "# Check for rg availability" >> "$SNAPSHOT_FILE" 295 echo "if ! (unalias rg 2>/dev/null; command -v rg) >/dev/null 2>&1; then" >> "$SNAPSHOT_FILE" 296 ` 297 298 if (rgIntegration.type === 'function') { 299 // For embedded ripgrep, write the function definition using heredoc 300 content += ` 301 cat >> "$SNAPSHOT_FILE" << 'RIPGREP_FUNC_END' 302 ${rgIntegration.snippet} 303RIPGREP_FUNC_END 304 ` 305 } else { 306 // For regular ripgrep, write a simple alias 307 const escapedSnippet = rgIntegration.snippet.replace(/'/g, "'\\''") 308 content += ` 309 echo ' alias rg='"'${escapedSnippet}'" >> "$SNAPSHOT_FILE" 310 ` 311 } 312 313 content += ` 314 echo "fi" >> "$SNAPSHOT_FILE" 315 ` 316 317 // For ant-native builds, shadow find/grep with bfs/ugrep embedded in the bun 318 // binary. Unlike rg (which only activates if system rg is absent), we always 319 // shadow find/grep since bfs/ugrep are drop-in replacements and we want 320 // consistent fast behavior in Claude's shell. 321 const findGrepIntegration = createFindGrepShellIntegration() 322 if (findGrepIntegration !== null) { 323 content += ` 324 # Shadow find/grep with embedded bfs/ugrep (ant-native only) 325 echo "# Shadow find/grep with embedded bfs/ugrep" >> "$SNAPSHOT_FILE" 326 cat >> "$SNAPSHOT_FILE" << 'FIND_GREP_FUNC_END' 327${findGrepIntegration} 328FIND_GREP_FUNC_END 329 ` 330 } 331 332 // Add PATH to the file 333 content += ` 334 335 # Add PATH to the file 336 echo "export PATH=${quote([pathValue || ''])}" >> "$SNAPSHOT_FILE" 337 ` 338 339 return content 340} 341 342/** 343 * Creates the appropriate shell script for capturing environment 344 */ 345async function getSnapshotScript( 346 shellPath: string, 347 snapshotFilePath: string, 348 configFileExists: boolean, 349): Promise<string> { 350 const configFile = getConfigFile(shellPath) 351 const isZsh = configFile.endsWith('.zshrc') 352 353 // Generate the user content and Claude Code content 354 const userContent = configFileExists 355 ? getUserSnapshotContent(configFile) 356 : !isZsh 357 ? // we need to manually force alias expansion in bash - normally `getUserSnapshotContent` takes care of this 358 'echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"' 359 : '' 360 const claudeCodeContent = await getClaudeCodeSnapshotContent() 361 362 const script = `SNAPSHOT_FILE=${quote([snapshotFilePath])} 363 ${configFileExists ? `source "${configFile}" < /dev/null` : '# No user config file to source'} 364 365 # First, create/clear the snapshot file 366 echo "# Snapshot file" >| "$SNAPSHOT_FILE" 367 368 # When this file is sourced, we first unalias to avoid conflicts 369 # This is necessary because aliases get "frozen" inside function definitions at definition time, 370 # which can cause unexpected behavior when functions use commands that conflict with aliases 371 echo "# Unset all aliases to avoid conflicts with functions" >> "$SNAPSHOT_FILE" 372 echo "unalias -a 2>/dev/null || true" >> "$SNAPSHOT_FILE" 373 374 ${userContent} 375 376 ${claudeCodeContent} 377 378 # Exit silently on success, only report errors 379 if [ ! -f "$SNAPSHOT_FILE" ]; then 380 echo "Error: Snapshot file was not created at $SNAPSHOT_FILE" >&2 381 exit 1 382 fi 383 ` 384 385 return script 386} 387 388/** 389 * Creates and saves the shell environment snapshot by loading the user's shell configuration 390 * 391 * This function is a critical part of Claude CLI's shell integration strategy. It: 392 * 393 * 1. Identifies the user's shell config file (.zshrc, .bashrc, etc.) 394 * 2. Creates a temporary script that sources this configuration file 395 * 3. Captures the resulting shell environment state including: 396 * - Functions defined in the user's shell configuration 397 * - Shell options and settings that affect command behavior 398 * - Aliases that the user has defined 399 * 400 * The snapshot is saved to a temporary file that can be sourced by subsequent shell 401 * commands, ensuring they run with the user's expected environment, aliases, and functions. 402 * 403 * This approach allows Claude CLI to execute commands as if they were run in the user's 404 * interactive shell, while avoiding the overhead of creating a new login shell for each command. 405 * It handles both Bash and Zsh shells with their different syntax for functions, options, and aliases. 406 * 407 * If the snapshot creation fails (e.g., timeout, permissions issues), the CLI will still 408 * function but without the user's custom shell environment, potentially missing aliases 409 * and functions the user relies on. 410 * 411 * @returns Promise that resolves to the snapshot file path or undefined if creation failed 412 */ 413export const createAndSaveSnapshot = async ( 414 binShell: string, 415): Promise<string | undefined> => { 416 const shellType = binShell.includes('zsh') 417 ? 'zsh' 418 : binShell.includes('bash') 419 ? 'bash' 420 : 'sh' 421 422 logForDebugging(`Creating shell snapshot for ${shellType} (${binShell})`) 423 424 return new Promise(async resolve => { 425 try { 426 const configFile = getConfigFile(binShell) 427 logForDebugging(`Looking for shell config file: ${configFile}`) 428 const configFileExists = await pathExists(configFile) 429 430 if (!configFileExists) { 431 logForDebugging( 432 `Shell config file not found: ${configFile}, creating snapshot with Claude Code defaults only`, 433 ) 434 } 435 436 // Create unique snapshot path with timestamp and random ID 437 const timestamp = Date.now() 438 const randomId = Math.random().toString(36).substring(2, 8) 439 const snapshotsDir = join(getClaudeConfigHomeDir(), 'shell-snapshots') 440 logForDebugging(`Snapshots directory: ${snapshotsDir}`) 441 const shellSnapshotPath = join( 442 snapshotsDir, 443 `snapshot-${shellType}-${timestamp}-${randomId}.sh`, 444 ) 445 446 // Ensure snapshots directory exists 447 await mkdir(snapshotsDir, { recursive: true }) 448 449 const snapshotScript = await getSnapshotScript( 450 binShell, 451 shellSnapshotPath, 452 configFileExists, 453 ) 454 logForDebugging(`Creating snapshot at: ${shellSnapshotPath}`) 455 logForDebugging(`Execution timeout: ${SNAPSHOT_CREATION_TIMEOUT}ms`) 456 execFile( 457 binShell, 458 ['-c', '-l', snapshotScript], 459 { 460 env: { 461 ...((process.env.CLAUDE_CODE_DONT_INHERIT_ENV 462 ? {} 463 : subprocessEnv()) as typeof process.env), 464 SHELL: binShell, 465 GIT_EDITOR: 'true', 466 CLAUDECODE: '1', 467 }, 468 timeout: SNAPSHOT_CREATION_TIMEOUT, 469 maxBuffer: 1024 * 1024, // 1MB buffer 470 encoding: 'utf8', 471 }, 472 async (error, stdout, stderr) => { 473 if (error) { 474 const execError = error as Error & { 475 killed?: boolean 476 signal?: string 477 code?: number 478 } 479 logForDebugging(`Shell snapshot creation failed: ${error.message}`) 480 logForDebugging(`Error details:`) 481 logForDebugging(` - Error code: ${execError?.code}`) 482 logForDebugging(` - Error signal: ${execError?.signal}`) 483 logForDebugging(` - Error killed: ${execError?.killed}`) 484 logForDebugging(` - Shell path: ${binShell}`) 485 logForDebugging(` - Config file: ${getConfigFile(binShell)}`) 486 logForDebugging(` - Config file exists: ${configFileExists}`) 487 logForDebugging(` - Working directory: ${getCwd()}`) 488 logForDebugging(` - Claude home: ${getClaudeConfigHomeDir()}`) 489 logForDebugging(`Full snapshot script:\n${snapshotScript}`) 490 if (stdout) { 491 logForDebugging( 492 `stdout output (${stdout.length} chars):\n${stdout}`, 493 ) 494 } else { 495 logForDebugging(`No stdout output captured`) 496 } 497 if (stderr) { 498 logForDebugging( 499 `stderr output (${stderr.length} chars): ${stderr}`, 500 ) 501 } else { 502 logForDebugging(`No stderr output captured`) 503 } 504 logError( 505 new Error(`Failed to create shell snapshot: ${error.message}`), 506 ) 507 // Convert signal name to number if present 508 const signalNumber = execError?.signal 509 ? os.constants.signals[ 510 execError.signal as keyof typeof os.constants.signals 511 ] 512 : undefined 513 logEvent('tengu_shell_snapshot_failed', { 514 stderr_length: stderr?.length || 0, 515 has_error_code: !!execError?.code, 516 error_signal_number: signalNumber, 517 error_killed: execError?.killed, 518 }) 519 resolve(undefined) 520 } else { 521 let snapshotSize: number | undefined 522 try { 523 snapshotSize = (await stat(shellSnapshotPath)).size 524 } catch { 525 // Snapshot file not found 526 } 527 528 if (snapshotSize !== undefined) { 529 logForDebugging( 530 `Shell snapshot created successfully (${snapshotSize} bytes)`, 531 ) 532 533 // Register cleanup to remove snapshot on graceful shutdown 534 registerCleanup(async () => { 535 try { 536 await getFsImplementation().unlink(shellSnapshotPath) 537 logForDebugging( 538 `Cleaned up session snapshot: ${shellSnapshotPath}`, 539 ) 540 } catch (error) { 541 logForDebugging( 542 `Error cleaning up session snapshot: ${error}`, 543 ) 544 } 545 }) 546 547 resolve(shellSnapshotPath) 548 } else { 549 logForDebugging( 550 `Shell snapshot file not found after creation: ${shellSnapshotPath}`, 551 ) 552 logForDebugging( 553 `Checking if parent directory still exists: ${snapshotsDir}`, 554 ) 555 try { 556 const dirContents = 557 await getFsImplementation().readdir(snapshotsDir) 558 logForDebugging( 559 `Directory contains ${dirContents.length} files`, 560 ) 561 } catch { 562 logForDebugging( 563 `Parent directory does not exist or is not accessible: ${snapshotsDir}`, 564 ) 565 } 566 logEvent('tengu_shell_unknown_error', {}) 567 resolve(undefined) 568 } 569 } 570 }, 571 ) 572 } catch (error) { 573 logForDebugging(`Unexpected error during snapshot creation: ${error}`) 574 if (error instanceof Error) { 575 logForDebugging(`Error stack trace: ${error.stack}`) 576 } 577 logError(error) 578 logEvent('tengu_shell_snapshot_error', {}) 579 resolve(undefined) 580 } 581 }) 582}