import { 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 secretsconst 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 settingsconst 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 | nulltry {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 existif (fileContent === null) {// Empty old_string on nonexistent file means new file creation — validif (old_string === '') {return { result: true }}// Try to find a similar file with a different extensionconst 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 emptyif (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 contentreturn {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 timeif (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 === undefinedif (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 normalizationconst 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 falseif (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 filesconst settingsValidationResult = validateInputForSettingsFileEdit(fullFilePath,file,() => {// Simulate the edit to get the final content using the exact same logic as the toolreturn 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 stateconst 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 availableconst cwd = getCwd()if (!isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {const newSkillDirs = await discoverSkillDirsForPaths([absoluteFilePath],cwd,)if (newSkillDirs.length > 0) {// Store discovered dirs for attachment displayfor (const dir of newSkillDirs) {dynamicSkillDirTriggers?.add(dir)}// Don't await - let skill loading happen in the backgroundaddSkillDirectories(newSkillDirs).catch(() => {})}// Activate conditional skills whose path patterns match this fileactivateConditionalSkillsForPaths([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 atomicityconst {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 === undefinedconst contentUnchanged =isFullRead && originalFileContents === lastRead.contentif (!contentUnchanged) {throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR)}}}// 3. Use findActualString to handle quote normalizationconst actualOldString =findActualString(originalFileContents, old_string) || old_string// Preserve curly quotes in new_string when the file uses themconst actualNewString = preserveQuoteStyle(old_string,actualOldString,new_string,)// 4. Generate patchconst { patch, updatedFile } = getPatchForEdit({filePath: absoluteFilePath,fileContents: originalFileContents,oldString: actualOldString,newString: actualNewString,replaceAll: replace_all,})// 5. Write to diskwriteTextContent(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 shownclearDeliveredDiagnosticsForFile(`file://${absoluteFilePath}`)// didChange: Content has been modifiedlspManager.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 viewnotifyVscodeFileUpdated(absoluteFilePath, originalFileContents, updatedFile)// 6. Update read timestamp, to invalidate stale writesreadFileState.set(absoluteFilePath, {content: updatedFile,timestamp: getFileModificationTime(absoluteFilePath),offset: undefined,limit: undefined,})// 7. Log eventsif (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 | undefinedif (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 = difflogEvent('tengu_tool_use_diff_computed', {isEditTool: true,durationMs: Date.now() - startTime,hasDiff: !!diff,})}// 8. Yield resultconst 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 } = dataconst 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: stringfileExists: booleanencoding: BufferEncodinglineEndings: LineEndingType} {try {// eslint-disable-next-line custom-rules/no-sync-fsconst 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}}
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。