Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
18 kB · 584 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585import { chmodSync, writeFileSync as fsWriteFileSync } from 'fs'import { realpath, stat } from 'fs/promises'import { homedir } from 'os'import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep,} from 'path'import { logEvent } from 'src/services/analytics/index.js'import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'import { getCwd } from '../utils/cwd.js'import { logForDebugging } from './debug.js'import { isENOENT, isFsInaccessible } from './errors.js'import { detectEncodingForResolvedPath, detectLineEndingsForString, type LineEndingType,} from './fileRead.js'import { fileReadCache } from './fileReadCache.js'import { getFsImplementation, safeResolvePath } from './fsOperations.js'import { logError } from './log.js'import { expandPath } from './path.js'import { getPlatform } from './platform.js'
export type File = { filename: string content: string}
/** * Check if a path exists asynchronously. */export async function pathExists(path: string): Promise<boolean> { try { await stat(path) return true } catch { return false }}
export const MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 // 0.25MB in bytes
export function readFileSafe(filepath: string): string | null { try { const fs = getFsImplementation() return fs.readFileSync(filepath, { encoding: 'utf8' }) } catch (error) { logError(error) return null }}
/** * Get the normalized modification time of a file in milliseconds. * Uses Math.floor to ensure consistent timestamp comparisons across file operations, * reducing false positives from sub-millisecond precision changes (e.g., from IDE * file watchers that touch files without changing content). */export function getFileModificationTime(filePath: string): number { const fs = getFsImplementation() return Math.floor(fs.statSync(filePath).mtimeMs)}
/** * Async variant of getFileModificationTime. Same floor semantics. * Use this in async paths (getChangedFiles runs every turn on every readFileState * entry — sync statSync there triggers the slow-operation indicator on network/ * slow disks). */export async function getFileModificationTimeAsync( filePath: string,): Promise<number> { const s = await getFsImplementation().stat(filePath) return Math.floor(s.mtimeMs)}
export function writeTextContent( filePath: string, content: string, encoding: BufferEncoding, endings: LineEndingType,): void { let toWrite = content if (endings === 'CRLF') { // Normalize any existing CRLF to LF first so a new_string that already // contains \r\n (raw model output) doesn't become \r\r\n after the join. toWrite = content.replaceAll('\r\n', '\n').split('\n').join('\r\n') }
writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding })}
export function detectFileEncoding(filePath: string): BufferEncoding { try { const fs = getFsImplementation() const { resolvedPath } = safeResolvePath(fs, filePath) return detectEncodingForResolvedPath(resolvedPath) } catch (error) { if (isFsInaccessible(error)) { logForDebugging( `detectFileEncoding failed for expected reason: ${error.code}`, { level: 'debug', }, ) } else { logError(error) } return 'utf8' }}
export function detectLineEndings( filePath: string, encoding: BufferEncoding = 'utf8',): LineEndingType { try { const fs = getFsImplementation() const { resolvedPath } = safeResolvePath(fs, filePath) const { buffer, bytesRead } = fs.readSync(resolvedPath, { length: 4096 })
const content = buffer.toString(encoding, 0, bytesRead) return detectLineEndingsForString(content) } catch (error) { logError(error) return 'LF' }}
export function convertLeadingTabsToSpaces(content: string): string { // The /gm regex scans every line even on no-match; skip it entirely // for the common tab-free case. if (!content.includes('\t')) return content return content.replace(/^\t+/gm, _ => ' '.repeat(_.length))}
export function getAbsoluteAndRelativePaths(path: string | undefined): { absolutePath: string | undefined relativePath: string | undefined} { const absolutePath = path ? expandPath(path) : undefined const relativePath = absolutePath ? relative(getCwd(), absolutePath) : undefined return { absolutePath, relativePath }}
export function getDisplayPath(filePath: string): string { // Use relative path if file is in the current working directory const { relativePath } = getAbsoluteAndRelativePaths(filePath) if (relativePath && !relativePath.startsWith('..')) { return relativePath }
// Use tilde notation for files in home directory const homeDir = homedir() if (filePath.startsWith(homeDir + sep)) { return '~' + filePath.slice(homeDir.length) }
// Otherwise return the absolute path return filePath}
/** * Find files with the same name but different extensions in the same directory * @param filePath The path to the file that doesn't exist * @returns The found file with a different extension, or undefined if none found */
export function findSimilarFile(filePath: string): string | undefined { const fs = getFsImplementation() try { const dir = dirname(filePath) const fileBaseName = basename(filePath, extname(filePath))
// Get all files in the directory const files = fs.readdirSync(dir)
// Find files with the same base name but different extension const similarFiles = files.filter( file => basename(file.name, extname(file.name)) === fileBaseName && join(dir, file.name) !== filePath, )
// Return just the filename of the first match if found const firstMatch = similarFiles[0] if (firstMatch) { return firstMatch.name } return undefined } catch (error) { // Missing dir (ENOENT) is expected; for other errors log and return undefined if (!isENOENT(error)) { logError(error) } return undefined }}
/** * Marker included in file-not-found error messages that contain a cwd note. * UI renderers check for this to show a short "File not found" message. */export const FILE_NOT_FOUND_CWD_NOTE = 'Note: your current working directory is'
/** * Suggests a corrected path under the current working directory when a file/directory * is not found. Detects the "dropped repo folder" pattern where the model constructs * an absolute path missing the repo directory component. * * Example: * cwd = /Users/zeeg/src/currentRepo * requestedPath = /Users/zeeg/src/foobar (doesn't exist) * returns /Users/zeeg/src/currentRepo/foobar (if it exists) * * @param requestedPath - The absolute path that was not found * @returns The corrected path if found under cwd, undefined otherwise */export async function suggestPathUnderCwd( requestedPath: string,): Promise<string | undefined> { const cwd = getCwd() const cwdParent = dirname(cwd)
// Resolve symlinks in the requested path's parent directory (e.g., /tmp -> /private/tmp on macOS) // so the prefix comparison works correctly against the cwd (which is already realpath-resolved). let resolvedPath = requestedPath try { const resolvedDir = await realpath(dirname(requestedPath)) resolvedPath = join(resolvedDir, basename(requestedPath)) } catch { // Parent directory doesn't exist, use the original path }
// Only check if the requested path is under cwd's parent but not under cwd itself. // When cwdParent is the root directory (e.g., '/'), use it directly as the prefix // to avoid a double-separator '//' that would never match. const cwdParentPrefix = cwdParent === sep ? sep : cwdParent + sep if ( !resolvedPath.startsWith(cwdParentPrefix) || resolvedPath.startsWith(cwd + sep) || resolvedPath === cwd ) { return undefined }
// Get the relative path from the parent directory const relFromParent = relative(cwdParent, resolvedPath)
// Check if the same relative path exists under cwd const correctedPath = join(cwd, relFromParent) try { await stat(correctedPath) return correctedPath } catch { return undefined }}
/** * Whether to use the compact line-number prefix format (`N\t` instead of * ` N→`). The padded-arrow format costs 9 bytes/line overhead; at * 1.35B Read calls × 132 lines avg this is 2.18% of fleet uncached input * (bq-queries/read_line_prefix_overhead_verify.sql). * * Ant soak validated no Edit error regression (6.29% vs 6.86% baseline). * Killswitch pattern: GB can disable if issues surface externally. */export function isCompactLinePrefixEnabled(): boolean { // 3P default: killswitch off = compact format enabled. Client-side only — // no server support needed, safe for Bedrock/Vertex/Foundry. return !getFeatureValue_CACHED_MAY_BE_STALE( 'tengu_compact_line_prefix_killswitch', false, )}
/** * Adds cat -n style line numbers to the content. */export function addLineNumbers({ content, // 1-indexed startLine,}: { content: string startLine: number}): string { if (!content) { return '' }
const lines = content.split(/\r?\n/)
if (isCompactLinePrefixEnabled()) { return lines .map((line, index) => `${index + startLine}\t${line}`) .join('\n') }
return lines .map((line, index) => { const numStr = String(index + startLine) if (numStr.length >= 6) { return `${numStr}→${line}` } return `${numStr.padStart(6, ' ')}→${line}` }) .join('\n')}
/** * Inverse of addLineNumbers — strips the `N→` or `N\t` prefix from a single * line. Co-located so format changes here and in addLineNumbers stay in sync. */export function stripLineNumberPrefix(line: string): string { const match = line.match(/^\s*\d+[\u2192\t](.*)$/) return match?.[1] ?? line}
/** * Checks if a directory is empty. * @param dirPath The path to the directory to check * @returns true if the directory is empty or does not exist, false otherwise */export function isDirEmpty(dirPath: string): boolean { try { return getFsImplementation().isDirEmptySync(dirPath) } catch (e) { // ENOENT: directory doesn't exist, consider it empty // Other errors (EPERM on macOS protected folders, etc.): assume not empty return isENOENT(e) }}
/** * Reads a file with caching to avoid redundant I/O operations. * This is the preferred method for FileEditTool operations. */export function readFileSyncCached(filePath: string): string { const { content } = fileReadCache.readFile(filePath) return content}
/** * Writes to a file and flushes the file to disk * @param filePath The path to the file to write to * @param content The content to write to the file * @param options Options for writing the file, including encoding and mode * @deprecated Use `fs.promises.writeFile` with flush option instead for non-blocking writes. * Sync file writes block the event loop and cause performance issues. */export function writeFileSyncAndFlush_DEPRECATED( filePath: string, content: string, options: { encoding: BufferEncoding; mode?: number } = { encoding: 'utf-8' },): void { const fs = getFsImplementation()
// Check if the target file is a symlink to preserve it for all users // Note: We don't use safeResolvePath here because we need to manually handle // symlinks to ensure we write to the target while preserving the symlink itself let targetPath = filePath try { // Try to read the symlink - if successful, it's a symlink const linkTarget = fs.readlinkSync(filePath) // Resolve to absolute path targetPath = isAbsolute(linkTarget) ? linkTarget : resolve(dirname(filePath), linkTarget) logForDebugging(`Writing through symlink: ${filePath} -> ${targetPath}`) } catch { // ENOENT (doesn't exist) or EINVAL (not a symlink) — keep targetPath = filePath }
// Try atomic write first const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}`
// Check if target file exists and get its permissions (single stat, reused in both atomic and fallback paths) let targetMode: number | undefined let targetExists = false try { targetMode = fs.statSync(targetPath).mode targetExists = true logForDebugging(`Preserving file permissions: ${targetMode.toString(8)}`) } catch (e) { if (!isENOENT(e)) throw e if (options.mode !== undefined) { // Use provided mode for new files targetMode = options.mode logForDebugging( `Setting permissions for new file: ${targetMode.toString(8)}`, ) } }
try { logForDebugging(`Writing to temp file: ${tempPath}`)
// Write to temp file with flush and mode (if specified for new file) const writeOptions: { encoding: BufferEncoding flush: boolean mode?: number } = { encoding: options.encoding, flush: true, } // Only set mode in writeFileSync for new files to ensure atomic permission setting if (!targetExists && options.mode !== undefined) { writeOptions.mode = options.mode }
fsWriteFileSync(tempPath, content, writeOptions) logForDebugging( `Temp file written successfully, size: ${content.length} bytes`, )
// For existing files or if mode was not set atomically, apply permissions if (targetExists && targetMode !== undefined) { chmodSync(tempPath, targetMode) logForDebugging(`Applied original permissions to temp file`) }
// Atomic rename (on POSIX systems, this is atomic) // On Windows, this will overwrite the destination if it exists logForDebugging(`Renaming ${tempPath} to ${targetPath}`) fs.renameSync(tempPath, targetPath) logForDebugging(`File ${targetPath} written atomically`) } catch (atomicError) { logForDebugging(`Failed to write file atomically: ${atomicError}`, { level: 'error', }) logEvent('tengu_atomic_write_error', {})
// Clean up temp file on error try { logForDebugging(`Cleaning up temp file: ${tempPath}`) fs.unlinkSync(tempPath) } catch (cleanupError) { logForDebugging(`Failed to clean up temp file: ${cleanupError}`) }
// Fallback to non-atomic write logForDebugging(`Falling back to non-atomic write for ${targetPath}`) try { const fallbackOptions: { encoding: BufferEncoding flush: boolean mode?: number } = { encoding: options.encoding, flush: true, } // Only set mode for new files if (!targetExists && options.mode !== undefined) { fallbackOptions.mode = options.mode }
fsWriteFileSync(targetPath, content, fallbackOptions) logForDebugging( `File ${targetPath} written successfully with non-atomic fallback`, ) } catch (fallbackError) { logForDebugging(`Non-atomic write also failed: ${fallbackError}`) throw fallbackError } }}
export function getDesktopPath(): string { const platform = getPlatform() const homeDir = homedir()
if (platform === 'macos') { return join(homeDir, 'Desktop') }
if (platform === 'windows') { // For WSL, try to access Windows desktop const windowsHome = process.env.USERPROFILE ? process.env.USERPROFILE.replace(/\\/g, '/') : null
if (windowsHome) { const wslPath = windowsHome.replace(/^[A-Z]:/, '') const desktopPath = `/mnt/c${wslPath}/Desktop`
if (getFsImplementation().existsSync(desktopPath)) { return desktopPath } }
// Fallback: try to find desktop in typical Windows user location try { const usersDir = '/mnt/c/Users' const userDirs = getFsImplementation().readdirSync(usersDir)
for (const user of userDirs) { if ( user.name === 'Public' || user.name === 'Default' || user.name === 'Default User' || user.name === 'All Users' ) { continue }
const potentialDesktopPath = join(usersDir, user.name, 'Desktop')
if (getFsImplementation().existsSync(potentialDesktopPath)) { return potentialDesktopPath } } } catch (error) { logError(error) } }
// Linux/unknown platform fallback const desktopPath = join(homeDir, 'Desktop') if (getFsImplementation().existsSync(desktopPath)) { return desktopPath }
// If Desktop folder doesn't exist, fallback to home directory return homeDir}
/** * Validates that a file size is within the specified limit. * Returns true if the file is within the limit, false otherwise. * * @param filePath The path to the file to validate * @param maxSizeBytes The maximum allowed file size in bytes * @returns true if file size is within limit, false otherwise */export function isFileWithinReadSizeLimit( filePath: string, maxSizeBytes: number = MAX_OUTPUT_SIZE,): boolean { try { const stats = getFsImplementation().statSync(filePath) return stats.size <= maxSizeBytes } catch { // If we can't stat the file, return false to indicate validation failure return false }}
/** * Normalize a file path for comparison, handling platform differences. * On Windows, normalizes path separators and converts to lowercase for * case-insensitive comparison. */export function normalizePathForComparison(filePath: string): string { // Use path.normalize() to clean up redundant separators and resolve . and .. let normalized = normalize(filePath)
// On Windows, normalize for case-insensitive comparison: // - Convert forward slashes to backslashes (path.normalize only does this on actual Windows) // - Convert to lowercase (Windows paths are case-insensitive) if (getPlatform() === 'windows') { normalized = normalized.replace(/\//g, '\\').toLowerCase() }
return normalized}
/** * Compare two file paths for equality, handling Windows case-insensitivity. */export function pathsEqual(path1: string, path2: string): boolean { return normalizePathForComparison(path1) === normalizePathForComparison(path2)}