Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
21 kB · 679 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680import type { ChildProcess, ExecFileException } from 'child_process'import { execFile, spawn } from 'child_process'import memoize from 'lodash-es/memoize.js'import { homedir } from 'os'import * as path from 'path'import { logEvent } from 'src/services/analytics/index.js'import { fileURLToPath } from 'url'import { isInBundledMode } from './bundledMode.js'import { logForDebugging } from './debug.js'import { isEnvDefinedFalsy } from './envUtils.js'import { execFileNoThrow } from './execFileNoThrow.js'import { findExecutable } from './findExecutable.js'import { logError } from './log.js'import { getPlatform } from './platform.js'import { countCharInString } from './stringUtils.js'
const __filename = fileURLToPath(import.meta.url)// we use node:path.join instead of node:url.resolve because the former doesn't encode spacesconst __dirname = path.join( __filename, process.env.NODE_ENV === 'test' ? '../../../' : '../',)
type RipgrepConfig = { mode: 'system' | 'builtin' | 'embedded' command: string args: string[] argv0?: string}
const getRipgrepConfig = memoize((): RipgrepConfig => { const userWantsSystemRipgrep = isEnvDefinedFalsy( process.env.USE_BUILTIN_RIPGREP, )
// Try system ripgrep if user wants it if (userWantsSystemRipgrep) { const { cmd: systemPath } = findExecutable('rg', []) if (systemPath !== 'rg') { // SECURITY: Use command name 'rg' instead of systemPath to prevent PATH hijacking // If we used systemPath, a malicious ./rg.exe in current directory could be executed // Using just 'rg' lets the OS resolve it safely with NoDefaultCurrentDirectoryInExePath protection return { mode: 'system', command: 'rg', args: [] } } }
// In bundled (native) mode, ripgrep is statically compiled into bun-internal // and dispatches based on argv[0]. We spawn ourselves with argv0='rg'. if (isInBundledMode()) { return { mode: 'embedded', command: process.execPath, args: ['--no-config'], argv0: 'rg', } }
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep') const command = process.platform === 'win32' ? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe') : path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
return { mode: 'builtin', command, args: [] }})
export function ripgrepCommand(): { rgPath: string rgArgs: string[] argv0?: string} { const config = getRipgrepConfig() return { rgPath: config.command, rgArgs: config.args, argv0: config.argv0, }}
const MAX_BUFFER_SIZE = 20_000_000 // 20MB; large monorepos can have 200k+ files
/** * Check if an error is EAGAIN (resource temporarily unavailable). * This happens in resource-constrained environments (Docker, CI) when * ripgrep tries to spawn too many threads. */function isEagainError(stderr: string): boolean { return ( stderr.includes('os error 11') || stderr.includes('Resource temporarily unavailable') )}
/** * Custom error class for ripgrep timeouts. * This allows callers to distinguish between "no matches" and "timed out". */export class RipgrepTimeoutError extends Error { constructor( message: string, public readonly partialResults: string[], ) { super(message) this.name = 'RipgrepTimeoutError' }}
function ripGrepRaw( args: string[], target: string, abortSignal: AbortSignal, callback: ( error: ExecFileException | null, stdout: string, stderr: string, ) => void, singleThread = false,): ChildProcess { // NB: When running interactively, ripgrep does not require a path as its last // argument, but when run non-interactively, it will hang unless a path or file // pattern is provided
const { rgPath, rgArgs, argv0 } = ripgrepCommand()
// Use single-threaded mode only if explicitly requested for this call's retry const threadArgs = singleThread ? ['-j', '1'] : [] const fullArgs = [...rgArgs, ...threadArgs, ...args, target] // Allow timeout to be configured via env var (in seconds), otherwise use platform defaults // WSL has severe performance penalty for file reads (3-5x slower on WSL2) const defaultTimeout = getPlatform() === 'wsl' ? 60_000 : 20_000 const parsedSeconds = parseInt(process.env.CLAUDE_CODE_GLOB_TIMEOUT_SECONDS || '', 10) || 0 const timeout = parsedSeconds > 0 ? parsedSeconds * 1000 : defaultTimeout
// For embedded ripgrep, use spawn with argv0 (execFile doesn't support argv0 properly) if (argv0) { const child = spawn(rgPath, fullArgs, { argv0, signal: abortSignal, // Prevent visible console window on Windows (no-op on other platforms) windowsHide: true, })
let stdout = '' let stderr = '' let stdoutTruncated = false let stderrTruncated = false
child.stdout?.on('data', (data: Buffer) => { if (!stdoutTruncated) { stdout += data.toString() if (stdout.length > MAX_BUFFER_SIZE) { stdout = stdout.slice(0, MAX_BUFFER_SIZE) stdoutTruncated = true } } })
child.stderr?.on('data', (data: Buffer) => { if (!stderrTruncated) { stderr += data.toString() if (stderr.length > MAX_BUFFER_SIZE) { stderr = stderr.slice(0, MAX_BUFFER_SIZE) stderrTruncated = true } } })
// Set up timeout with SIGKILL escalation. // SIGTERM alone may not kill ripgrep if it's blocked in uninterruptible I/O // (e.g., deep filesystem traversal). If SIGTERM doesn't work within 5 seconds, // escalate to SIGKILL which cannot be caught or ignored. // On Windows, child.kill('SIGTERM') throws; use default signal. let killTimeoutId: ReturnType<typeof setTimeout> | undefined const timeoutId = setTimeout(() => { if (process.platform === 'win32') { child.kill() } else { child.kill('SIGTERM') killTimeoutId = setTimeout(c => c.kill('SIGKILL'), 5_000, child) } }, timeout)
// On Windows, both 'close' and 'error' can fire for the same process // (e.g. when AbortSignal kills the child). Guard against double-callback. let settled = false child.on('close', (code, signal) => { if (settled) return settled = true clearTimeout(timeoutId) clearTimeout(killTimeoutId) if (code === 0 || code === 1) { // 0 = matches found, 1 = no matches (both are success) callback(null, stdout, stderr) } else { const error: ExecFileException = new Error( `ripgrep exited with code ${code}`, ) error.code = code ?? undefined error.signal = signal ?? undefined callback(error, stdout, stderr) } })
child.on('error', (err: NodeJS.ErrnoException) => { if (settled) return settled = true clearTimeout(timeoutId) clearTimeout(killTimeoutId) const error: ExecFileException = err callback(error, stdout, stderr) })
return child }
// For non-embedded ripgrep, use execFile // Use SIGKILL as killSignal because SIGTERM may not terminate ripgrep // when it's blocked in uninterruptible filesystem I/O. // On Windows, SIGKILL throws; use default (undefined) which sends SIGTERM. return execFile( rgPath, fullArgs, { maxBuffer: MAX_BUFFER_SIZE, signal: abortSignal, timeout, killSignal: process.platform === 'win32' ? undefined : 'SIGKILL', }, callback, )}
/** * Stream-count lines from `rg --files` without buffering stdout. * * On large repos (e.g. 247k files, 16MB of paths), calling `ripGrep()` just * to read `.length` materializes the full stdout string plus a 247k-element * array. This counts newline bytes per chunk instead; peak memory is one * stream chunk (~64KB). * * Intentionally minimal: the only caller is telemetry (countFilesRoundedRg), * which swallows all errors. No EAGAIN retry, no stderr capture, no internal * timeout (callers pass AbortSignal.timeout; spawn's signal option kills rg). */async function ripGrepFileCount( args: string[], target: string, abortSignal: AbortSignal,): Promise<number> { await codesignRipgrepIfNecessary() const { rgPath, rgArgs, argv0 } = ripgrepCommand()
return new Promise<number>((resolve, reject) => { const child = spawn(rgPath, [...rgArgs, ...args, target], { argv0, signal: abortSignal, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], })
let lines = 0 child.stdout?.on('data', (chunk: Buffer) => { lines += countCharInString(chunk, '\n') })
// On Windows, both 'close' and 'error' can fire for the same process. let settled = false child.on('close', code => { if (settled) return settled = true if (code === 0 || code === 1) resolve(lines) else reject(new Error(`rg --files exited ${code}`)) }) child.on('error', err => { if (settled) return settled = true reject(err) }) })}
/** * Stream lines from ripgrep as they arrive, calling `onLines` per stdout chunk. * * Unlike `ripGrep()` which buffers the entire stdout, this flushes complete * lines as soon as each chunk arrives — first results paint while rg is still * walking the tree (the fzf `change:reload` pattern). Partial trailing lines * are carried across chunk boundaries. * * Callers that want to stop early (e.g. after N matches) should abort the * signal — spawn's signal option kills rg. No EAGAIN retry, no internal * timeout, stderr is ignored; interactive callers own recovery. */export async function ripGrepStream( args: string[], target: string, abortSignal: AbortSignal, onLines: (lines: string[]) => void,): Promise<void> { await codesignRipgrepIfNecessary() const { rgPath, rgArgs, argv0 } = ripgrepCommand()
return new Promise<void>((resolve, reject) => { const child = spawn(rgPath, [...rgArgs, ...args, target], { argv0, signal: abortSignal, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], })
const stripCR = (l: string) => (l.endsWith('\r') ? l.slice(0, -1) : l) let remainder = '' child.stdout?.on('data', (chunk: Buffer) => { const data = remainder + chunk.toString() const lines = data.split('\n') remainder = lines.pop() ?? '' if (lines.length) onLines(lines.map(stripCR)) })
// On Windows, both 'close' and 'error' can fire for the same process. let settled = false child.on('close', code => { if (settled) return // Abort races close — don't flush a torn tail from a killed process. // Promise still settles: spawn's signal option fires 'error' with // AbortError → reject below. if (abortSignal.aborted) return settled = true if (code === 0 || code === 1) { if (remainder) onLines([stripCR(remainder)]) resolve() } else { reject(new Error(`ripgrep exited with code ${code}`)) } }) child.on('error', err => { if (settled) return settled = true reject(err) }) })}
export async function ripGrep( args: string[], target: string, abortSignal: AbortSignal,): Promise<string[]> { await codesignRipgrepIfNecessary()
// Test ripgrep on first use and cache the result (fire and forget) void testRipgrepOnFirstUse().catch(error => { logError(error) })
return new Promise((resolve, reject) => { const handleResult = ( error: ExecFileException | null, stdout: string, stderr: string, isRetry: boolean, ): void => { // Success case if (!error) { resolve( stdout .trim() .split('\n') .map(line => line.replace(/\r$/, '')) .filter(Boolean), ) return }
// Exit code 1 is normal "no matches" if (error.code === 1) { resolve([]) return }
// Critical errors that indicate ripgrep is broken, not "no matches" // These should be surfaced to the user rather than silently returning empty results const CRITICAL_ERROR_CODES = ['ENOENT', 'EACCES', 'EPERM'] if (CRITICAL_ERROR_CODES.includes(error.code as string)) { reject(error) return }
// If we hit EAGAIN and haven't retried yet, retry with single-threaded mode // Note: We only use -j 1 for this specific retry, not for future calls. // Persisting single-threaded mode globally caused timeouts on large repos // where EAGAIN was just a transient startup error. if (!isRetry && isEagainError(stderr)) { logForDebugging( `rg EAGAIN error detected, retrying with single-threaded mode (-j 1)`, ) logEvent('tengu_ripgrep_eagain_retry', {}) ripGrepRaw( args, target, abortSignal, (retryError, retryStdout, retryStderr) => { handleResult(retryError, retryStdout, retryStderr, true) }, true, // Force single-threaded mode for this retry only ) return }
// For all other errors, try to return partial results if available const hasOutput = stdout && stdout.trim().length > 0 const isTimeout = error.signal === 'SIGTERM' || error.signal === 'SIGKILL' || error.code === 'ABORT_ERR' const isBufferOverflow = error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
let lines: string[] = [] if (hasOutput) { lines = stdout .trim() .split('\n') .map(line => line.replace(/\r$/, '')) .filter(Boolean) // Drop last line for timeouts and buffer overflow - it may be incomplete if (lines.length > 0 && (isTimeout || isBufferOverflow)) { lines = lines.slice(0, -1) } }
logForDebugging( `rg error (signal=${error.signal}, code=${error.code}, stderr: ${stderr}), ${lines.length} results`, )
// code 2 = ripgrep usage error (already handled); ABORT_ERR = caller // explicitly aborted (not an error, just a cancellation — interactive // callers may abort on every keystroke-after-debounce). if (error.code !== 2 && error.code !== 'ABORT_ERR') { logError(error) }
// If we timed out with no results, throw an error so Claude knows the search // didn't complete rather than thinking there were no matches if (isTimeout && lines.length === 0) { reject( new RipgrepTimeoutError( `Ripgrep search timed out after ${getPlatform() === 'wsl' ? 60 : 20} seconds. The search may have matched files but did not complete in time. Try searching a more specific path or pattern.`, lines, ), ) return }
resolve(lines) }
ripGrepRaw(args, target, abortSignal, (error, stdout, stderr) => { handleResult(error, stdout, stderr, false) }) })}
/** * Count files in a directory recursively using ripgrep and round to the nearest power of 10 for privacy * * This is much more efficient than using native Node.js methods for counting files * in large directories since it uses ripgrep's highly optimized file traversal. * * @param path Directory path to count files in * @param abortSignal AbortSignal to cancel the operation * @param ignorePatterns Optional additional patterns to ignore (beyond .gitignore) * @returns Approximate file count rounded to the nearest power of 10 */export const countFilesRoundedRg = memoize( async ( dirPath: string, abortSignal: AbortSignal, ignorePatterns: string[] = [], ): Promise<number | undefined> => { // Skip file counting if we're in the home directory to avoid triggering // macOS TCC permission dialogs for Desktop, Downloads, Documents, etc. if (path.resolve(dirPath) === path.resolve(homedir())) { return undefined }
try { // Build ripgrep arguments: // --files: List files that would be searched (rather than searching them) // --count: Only print a count of matching lines for each file // --no-ignore-parent: Don't respect ignore files in parent directories // --hidden: Search hidden files and directories const args = ['--files', '--hidden']
// Add ignore patterns if provided ignorePatterns.forEach(pattern => { args.push('--glob', `!${pattern}`) })
const count = await ripGrepFileCount(args, dirPath, abortSignal)
// Round to nearest power of 10 for privacy if (count === 0) return 0
const magnitude = Math.floor(Math.log10(count)) const power = Math.pow(10, magnitude)
// Round to nearest power of 10 // e.g., 8 -> 10, 42 -> 100, 350 -> 100, 750 -> 1000 return Math.round(count / power) * power } catch (error) { // AbortSignal.timeout firing is expected on large/slow repos, not an error. if ((error as Error)?.name !== 'AbortError') logError(error) } }, // lodash memoize's default resolver only uses the first argument. // ignorePatterns affect the result, so include them in the cache key. // abortSignal is intentionally excluded — it doesn't affect the count. (dirPath, _abortSignal, ignorePatterns = []) => `${dirPath}|${ignorePatterns.join(',')}`,)
// Singleton to store ripgrep availability statuslet ripgrepStatus: { working: boolean lastTested: number config: RipgrepConfig} | null = null
/** * Get ripgrep status and configuration info * Returns current configuration immediately, with working status if available */export function getRipgrepStatus(): { mode: 'system' | 'builtin' | 'embedded' path: string working: boolean | null // null if not yet tested} { const config = getRipgrepConfig() return { mode: config.mode, path: config.command, working: ripgrepStatus?.working ?? null, }}
/** * Test ripgrep availability on first use and cache the result */const testRipgrepOnFirstUse = memoize(async (): Promise<void> => { // Already tested if (ripgrepStatus !== null) { return }
const config = getRipgrepConfig()
try { let test: { code: number; stdout: string }
// For embedded ripgrep, use Bun.spawn with argv0 if (config.argv0) { // Only Bun embeds ripgrep. // eslint-disable-next-line custom-rules/require-bun-typeof-guard const proc = Bun.spawn([config.command, '--version'], { argv0: config.argv0, stderr: 'ignore', stdout: 'pipe', })
// Bun's ReadableStream has .text() at runtime, but TS types don't reflect it const [stdout, code] = await Promise.all([ (proc.stdout as unknown as Blob).text(), proc.exited, ]) test = { code, stdout, } } else { test = await execFileNoThrow( config.command, [...config.args, '--version'], { timeout: 5000, }, ) }
const working = test.code === 0 && !!test.stdout && test.stdout.startsWith('ripgrep ')
ripgrepStatus = { working, lastTested: Date.now(), config, }
logForDebugging( `Ripgrep first use test: ${working ? 'PASSED' : 'FAILED'} (mode=${config.mode}, path=${config.command})`, )
// Log telemetry for actual ripgrep availability logEvent('tengu_ripgrep_availability', { working: working ? 1 : 0, using_system: config.mode === 'system' ? 1 : 0, }) } catch (error) { ripgrepStatus = { working: false, lastTested: Date.now(), config, } logError(error) }})
let alreadyDoneSignCheck = falseasync function codesignRipgrepIfNecessary() { if (process.platform !== 'darwin' || alreadyDoneSignCheck) { return }
alreadyDoneSignCheck = true
// Only sign the standalone vendored rg binary (npm builds) const config = getRipgrepConfig() if (config.mode !== 'builtin') { return } const builtinPath = config.command
// First, check to see if ripgrep is already signed const lines = ( await execFileNoThrow('codesign', ['-vv', '-d', builtinPath], { preserveOutputOnError: false, }) ).stdout.split('\n')
const needsSigned = lines.find(line => line.includes('linker-signed')) if (!needsSigned) { return }
try { const signResult = await execFileNoThrow('codesign', [ '--sign', '-', '--force', '--preserve-metadata=entitlements,requirements,flags,runtime', builtinPath, ])
if (signResult.code !== 0) { logError( new Error( `Failed to sign ripgrep: ${signResult.stdout} ${signResult.stderr}`, ), ) }
const quarantineResult = await execFileNoThrow('xattr', [ '-d', 'com.apple.quarantine', builtinPath, ])
if (quarantineResult.code !== 0) { logError( new Error( `Failed to remove quarantine: ${quarantineResult.stdout} ${quarantineResult.stderr}`, ), ) } } catch (e) { logError(e) }}