Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
21 kB · 625 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626import { dirname, isAbsolute, sep } from 'path'import { logEvent } from 'src/services/analytics/index.js'import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'import { diagnosticTracker } from '../../services/diagnosticTracking.js'import { clearDeliveredDiagnosticsForFile } from '../../services/lsp/LSPDiagnosticRegistry.js'import { getLspServerManager } from '../../services/lsp/manager.js'import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js'import { checkTeamMemSecrets } from '../../services/teamMemorySync/teamMemSecretGuard.js'import { activateConditionalSkillsForPaths, addSkillDirectories, discoverSkillDirsForPaths,} from '../../skills/loadSkillsDir.js'import type { ToolUseContext } from '../../Tool.js'import { buildTool, type ToolDef } from '../../Tool.js'import { getCwd } from '../../utils/cwd.js'import { logForDebugging } from '../../utils/debug.js'import { countLinesChanged } from '../../utils/diff.js'import { isEnvTruthy } from '../../utils/envUtils.js'import { isENOENT } from '../../utils/errors.js'import { FILE_NOT_FOUND_CWD_NOTE, findSimilarFile, getFileModificationTime, suggestPathUnderCwd, writeTextContent,} from '../../utils/file.js'import { fileHistoryEnabled, fileHistoryTrackEdit,} from '../../utils/fileHistory.js'import { logFileOperation } from '../../utils/fileOperationAnalytics.js'import { type LineEndingType, readFileSyncWithMetadata,} from '../../utils/fileRead.js'import { formatFileSize } from '../../utils/format.js'import { getFsImplementation } from '../../utils/fsOperations.js'import { fetchSingleFileGitDiff, type ToolUseDiff,} from '../../utils/gitDiff.js'import { logError } from '../../utils/log.js'import { expandPath } from '../../utils/path.js'import { checkWritePermissionForTool, matchingRuleForInput,} from '../../utils/permissions/filesystem.js'import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js'import { validateInputForSettingsFileEdit } from '../../utils/settings/validateEditTool.js'import { NOTEBOOK_EDIT_TOOL_NAME } from '../NotebookEditTool/constants.js'import { FILE_EDIT_TOOL_NAME, FILE_UNEXPECTEDLY_MODIFIED_ERROR,} from './constants.js'import { getEditToolDescription } from './prompt.js'import { type FileEditInput, type FileEditOutput, inputSchema, outputSchema,} from './types.js'import { getToolUseSummary, renderToolResultMessage, renderToolUseErrorMessage, renderToolUseMessage, renderToolUseRejectedMessage, userFacingName,} from './UI.js'import { areFileEditsInputsEquivalent, findActualString, getPatchForEdit, preserveQuoteStyle,} from './utils.js'
// V8/Bun string length limit is ~2^30 characters (~1 billion). For typical// ASCII/Latin-1 files, 1 byte on disk = 1 character, so 1 GiB in stat bytes// ≈ 1 billion characters ≈ the runtime string limit. Multi-byte UTF-8 files// can be larger on disk per character, but 1 GiB is a safe byte-level guard// that prevents OOM without being unnecessarily restrictive.const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB (stat bytes)
export const FileEditTool = buildTool({ name: FILE_EDIT_TOOL_NAME, searchHint: 'modify file contents in place', maxResultSizeChars: 100_000, strict: true, async description() { return 'A tool for editing files' }, async prompt() { return getEditToolDescription() }, userFacingName, getToolUseSummary, getActivityDescription(input) { const summary = getToolUseSummary(input) return summary ? `Editing ${summary}` : 'Editing file' }, get inputSchema() { return inputSchema() }, get outputSchema() { return outputSchema() }, toAutoClassifierInput(input) { return `${input.file_path}: ${input.new_string}` }, getPath(input): string { return input.file_path }, backfillObservableInput(input) { // hooks.mdx documents file_path as absolute; expand so hook allowlists // can't be bypassed via ~ or relative paths. if (typeof input.file_path === 'string') { input.file_path = expandPath(input.file_path) } }, async preparePermissionMatcher({ file_path }) { return pattern => matchWildcardPattern(pattern, file_path) }, async checkPermissions(input, context): Promise<PermissionDecision> { const appState = context.getAppState() return checkWritePermissionForTool( FileEditTool, input, appState.toolPermissionContext, ) }, renderToolUseMessage, renderToolResultMessage, renderToolUseRejectedMessage, renderToolUseErrorMessage, async validateInput(input: FileEditInput, toolUseContext: ToolUseContext) { const { file_path, old_string, new_string, replace_all = false } = input // Use expandPath for consistent path normalization (especially on Windows // where "/" vs "\" can cause readFileState lookup mismatches) const fullFilePath = expandPath(file_path)
// Reject edits to team memory files that introduce secrets const secretError = checkTeamMemSecrets(fullFilePath, new_string) if (secretError) { return { result: false, message: secretError, errorCode: 0 } } if (old_string === new_string) { return { result: false, behavior: 'ask', message: 'No changes to make: old_string and new_string are exactly the same.', errorCode: 1, } }
// Check if path should be ignored based on permission settings const appState = toolUseContext.getAppState() const denyRule = matchingRuleForInput( fullFilePath, appState.toolPermissionContext, 'edit', 'deny', ) if (denyRule !== null) { return { result: false, behavior: 'ask', message: 'File is in a directory that is denied by your permission settings.', errorCode: 2, } }
// SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks. // On Windows, fs.existsSync() on UNC paths triggers SMB authentication which could // leak credentials to malicious servers. Let the permission check handle UNC paths. if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')) { return { result: true } }
const fs = getFsImplementation()
// Prevent OOM on multi-GB files. try { const { size } = await fs.stat(fullFilePath) if (size > MAX_EDIT_FILE_SIZE) { return { result: false, behavior: 'ask', message: `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`, errorCode: 10, } } } catch (e) { if (!isENOENT(e)) { throw e } }
// Read the file as bytes first so we can detect encoding from the buffer // instead of calling detectFileEncoding (which does its own sync readSync // and would fail with a wasted ENOENT when the file doesn't exist). let fileContent: string | null try { const fileBuffer = await fs.readFileBytes(fullFilePath) const encoding: BufferEncoding = fileBuffer.length >= 2 && fileBuffer[0] === 0xff && fileBuffer[1] === 0xfe ? 'utf16le' : 'utf8' fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n') } catch (e) { if (isENOENT(e)) { fileContent = null } else { throw e } }
// File doesn't exist if (fileContent === null) { // Empty old_string on nonexistent file means new file creation — valid if (old_string === '') { return { result: true } } // Try to find a similar file with a different extension const similarFilename = findSimilarFile(fullFilePath) const cwdSuggestion = await suggestPathUnderCwd(fullFilePath) let message = `File does not exist. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.`
if (cwdSuggestion) { message += ` Did you mean ${cwdSuggestion}?` } else if (similarFilename) { message += ` Did you mean ${similarFilename}?` }
return { result: false, behavior: 'ask', message, errorCode: 4, } }
// File exists with empty old_string — only valid if file is empty if (old_string === '') { // Only reject if the file has content (for file creation attempt) if (fileContent.trim() !== '') { return { result: false, behavior: 'ask', message: 'Cannot create new file - file already exists.', errorCode: 3, } }
// Empty file with empty old_string is valid - we're replacing empty with content return { result: true, } }
if (fullFilePath.endsWith('.ipynb')) { return { result: false, behavior: 'ask', message: `File is a Jupyter Notebook. Use the ${NOTEBOOK_EDIT_TOOL_NAME} to edit this file.`, errorCode: 5, } }
const readTimestamp = toolUseContext.readFileState.get(fullFilePath) if (!readTimestamp || readTimestamp.isPartialView) { return { result: false, behavior: 'ask', message: 'File has not been read yet. Read it first before writing to it.', meta: { isFilePathAbsolute: String(isAbsolute(file_path)), }, errorCode: 6, } }
// Check if file exists and get its last modified time if (readTimestamp) { const lastWriteTime = getFileModificationTime(fullFilePath) if (lastWriteTime > readTimestamp.timestamp) { // Timestamp indicates modification, but on Windows timestamps can change // without content changes (cloud sync, antivirus, etc.). For full reads, // compare content as a fallback to avoid false positives. const isFullRead = readTimestamp.offset === undefined && readTimestamp.limit === undefined if (isFullRead && fileContent === readTimestamp.content) { // Content unchanged, safe to proceed } else { return { result: false, behavior: 'ask', message: 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', errorCode: 7, } } } }
const file = fileContent
// Use findActualString to handle quote normalization const actualOldString = findActualString(file, old_string) if (!actualOldString) { return { result: false, behavior: 'ask', message: `String to replace not found in file.\nString: ${old_string}`, meta: { isFilePathAbsolute: String(isAbsolute(file_path)), }, errorCode: 8, } }
const matches = file.split(actualOldString).length - 1
// Check if we have multiple matches but replace_all is false if (matches > 1 && !replace_all) { return { result: false, behavior: 'ask', message: `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${old_string}`, meta: { isFilePathAbsolute: String(isAbsolute(file_path)), actualOldString, }, errorCode: 9, } }
// Additional validation for Claude settings files const settingsValidationResult = validateInputForSettingsFileEdit( fullFilePath, file, () => { // Simulate the edit to get the final content using the exact same logic as the tool return replace_all ? file.replaceAll(actualOldString, new_string) : file.replace(actualOldString, new_string) }, )
if (settingsValidationResult !== null) { return settingsValidationResult }
return { result: true, meta: { actualOldString } } }, inputsEquivalent(input1, input2) { return areFileEditsInputsEquivalent( { file_path: input1.file_path, edits: [ { old_string: input1.old_string, new_string: input1.new_string, replace_all: input1.replace_all ?? false, }, ], }, { file_path: input2.file_path, edits: [ { old_string: input2.old_string, new_string: input2.new_string, replace_all: input2.replace_all ?? false, }, ], }, ) }, async call( input: FileEditInput, { readFileState, userModified, updateFileHistoryState, dynamicSkillDirTriggers, }, _, parentMessage, ) { const { file_path, old_string, new_string, replace_all = false } = input
// 1. Get current state const fs = getFsImplementation() const absoluteFilePath = expandPath(file_path)
// Discover skills from this file's path (fire-and-forget, non-blocking) // Skip in simple mode - no skills available const cwd = getCwd() if (!isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { const newSkillDirs = await discoverSkillDirsForPaths( [absoluteFilePath], cwd, ) if (newSkillDirs.length > 0) { // Store discovered dirs for attachment display for (const dir of newSkillDirs) { dynamicSkillDirTriggers?.add(dir) } // Don't await - let skill loading happen in the background addSkillDirectories(newSkillDirs).catch(() => {}) }
// Activate conditional skills whose path patterns match this file activateConditionalSkillsForPaths([absoluteFilePath], cwd) }
await diagnosticTracker.beforeFileEdited(absoluteFilePath)
// Ensure parent directory exists before the atomic read-modify-write section. // These awaits must stay OUTSIDE the critical section below — a yield between // the staleness check and writeTextContent lets concurrent edits interleave. await fs.mkdir(dirname(absoluteFilePath)) if (fileHistoryEnabled()) { // Backup captures pre-edit content — safe to call before the staleness // check (idempotent v1 backup keyed on content hash; if staleness fails // later we just have an unused backup, not corrupt state). await fileHistoryTrackEdit( updateFileHistoryState, absoluteFilePath, parentMessage.uuid, ) }
// 2. Load current state and confirm no changes since last read // Please avoid async operations between here and writing to disk to preserve atomicity const { content: originalFileContents, fileExists, encoding, lineEndings: endings, } = readFileForEdit(absoluteFilePath)
if (fileExists) { const lastWriteTime = getFileModificationTime(absoluteFilePath) const lastRead = readFileState.get(absoluteFilePath) if (!lastRead || lastWriteTime > lastRead.timestamp) { // Timestamp indicates modification, but on Windows timestamps can change // without content changes (cloud sync, antivirus, etc.). For full reads, // compare content as a fallback to avoid false positives. const isFullRead = lastRead && lastRead.offset === undefined && lastRead.limit === undefined const contentUnchanged = isFullRead && originalFileContents === lastRead.content if (!contentUnchanged) { throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR) } } }
// 3. Use findActualString to handle quote normalization const actualOldString = findActualString(originalFileContents, old_string) || old_string
// Preserve curly quotes in new_string when the file uses them const actualNewString = preserveQuoteStyle( old_string, actualOldString, new_string, )
// 4. Generate patch const { patch, updatedFile } = getPatchForEdit({ filePath: absoluteFilePath, fileContents: originalFileContents, oldString: actualOldString, newString: actualNewString, replaceAll: replace_all, })
// 5. Write to disk writeTextContent(absoluteFilePath, updatedFile, encoding, endings)
// Notify LSP servers about file modification (didChange) and save (didSave) const lspManager = getLspServerManager() if (lspManager) { // Clear previously delivered diagnostics so new ones will be shown clearDeliveredDiagnosticsForFile(`file://${absoluteFilePath}`) // didChange: Content has been modified lspManager .changeFile(absoluteFilePath, updatedFile) .catch((err: Error) => { logForDebugging( `LSP: Failed to notify server of file change for ${absoluteFilePath}: ${err.message}`, ) logError(err) }) // didSave: File has been saved to disk (triggers diagnostics in TypeScript server) lspManager.saveFile(absoluteFilePath).catch((err: Error) => { logForDebugging( `LSP: Failed to notify server of file save for ${absoluteFilePath}: ${err.message}`, ) logError(err) }) }
// Notify VSCode about the file change for diff view notifyVscodeFileUpdated(absoluteFilePath, originalFileContents, updatedFile)
// 6. Update read timestamp, to invalidate stale writes readFileState.set(absoluteFilePath, { content: updatedFile, timestamp: getFileModificationTime(absoluteFilePath), offset: undefined, limit: undefined, })
// 7. Log events if (absoluteFilePath.endsWith(`${sep}CLAUDE.md`)) { logEvent('tengu_write_claudemd', {}) } countLinesChanged(patch)
logFileOperation({ operation: 'edit', tool: 'FileEditTool', filePath: absoluteFilePath, })
logEvent('tengu_edit_string_lengths', { oldStringBytes: Buffer.byteLength(old_string, 'utf8'), newStringBytes: Buffer.byteLength(new_string, 'utf8'), replaceAll: replace_all, })
let gitDiff: ToolUseDiff | undefined if ( isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && getFeatureValue_CACHED_MAY_BE_STALE('tengu_quartz_lantern', false) ) { const startTime = Date.now() const diff = await fetchSingleFileGitDiff(absoluteFilePath) if (diff) gitDiff = diff logEvent('tengu_tool_use_diff_computed', { isEditTool: true, durationMs: Date.now() - startTime, hasDiff: !!diff, }) }
// 8. Yield result const data = { filePath: file_path, oldString: actualOldString, newString: new_string, originalFile: originalFileContents, structuredPatch: patch, userModified: userModified ?? false, replaceAll: replace_all, ...(gitDiff && { gitDiff }), } return { data, } }, mapToolResultToToolResultBlockParam(data: FileEditOutput, toolUseID) { const { filePath, userModified, replaceAll } = data const modifiedNote = userModified ? '. The user modified your proposed changes before accepting them. ' : ''
if (replaceAll) { return { tool_use_id: toolUseID, type: 'tool_result', content: `The file ${filePath} has been updated${modifiedNote}. All occurrences were successfully replaced.`, } }
return { tool_use_id: toolUseID, type: 'tool_result', content: `The file ${filePath} has been updated successfully${modifiedNote}.`, } },} satisfies ToolDef<ReturnType<typeof inputSchema>, FileEditOutput>)
// --
function readFileForEdit(absoluteFilePath: string): { content: string fileExists: boolean encoding: BufferEncoding lineEndings: LineEndingType} { try { // eslint-disable-next-line custom-rules/no-sync-fs const meta = readFileSyncWithMetadata(absoluteFilePath) return { content: meta.content, fileExists: true, encoding: meta.encoding, lineEndings: meta.lineEndings, } } catch (e) { if (isENOENT(e)) { return { content: '', fileExists: false, encoding: 'utf8', lineEndings: 'LF', } } throw e }}