init claude-code
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
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 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
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
|
||||
// In its own file to avoid circular dependencies
|
||||
export const FILE_EDIT_TOOL_NAME = 'Edit'
|
||||
|
||||
// Permission pattern for granting session-level access to the project's .claude/ folder
|
||||
export const CLAUDE_FOLDER_PERMISSION_PATTERN = '/.claude/**'
|
||||
|
||||
// Permission pattern for granting session-level access to the global ~/.claude/ folder
|
||||
export const GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN = '~/.claude/**'
|
||||
|
||||
export const FILE_UNEXPECTEDLY_MODIFIED_ERROR =
|
||||
'File has been unexpectedly modified. Read it again before attempting to write it.'
|
||||
@@ -0,0 +1,28 @@
|
||||
import { isCompactLinePrefixEnabled } from '../../utils/file.js'
|
||||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||||
|
||||
function getPreReadInstruction(): string {
|
||||
return `\n- You must use your \`${FILE_READ_TOOL_NAME}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. `
|
||||
}
|
||||
|
||||
export function getEditToolDescription(): string {
|
||||
return getDefaultEditDescription()
|
||||
}
|
||||
|
||||
function getDefaultEditDescription(): string {
|
||||
const prefixFormat = isCompactLinePrefixEnabled()
|
||||
? 'line number + tab'
|
||||
: 'spaces + line number + arrow'
|
||||
const minimalUniquenessHint =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? `\n- Use the smallest old_string that's clearly unique — usually 2-4 adjacent lines is sufficient. Avoid including 10+ lines of context when less uniquely identifies the target.`
|
||||
: ''
|
||||
return `Performs exact string replacements in files.
|
||||
|
||||
Usage:${getPreReadInstruction()}
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: ${prefixFormat}. Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.${minimalUniquenessHint}
|
||||
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from 'zod/v4'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import { semanticBoolean } from '../../utils/semanticBoolean.js'
|
||||
|
||||
// The input schema with optional replace_all
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
file_path: z.string().describe('The absolute path to the file to modify'),
|
||||
old_string: z.string().describe('The text to replace'),
|
||||
new_string: z
|
||||
.string()
|
||||
.describe(
|
||||
'The text to replace it with (must be different from old_string)',
|
||||
),
|
||||
replace_all: semanticBoolean(
|
||||
z.boolean().default(false).optional(),
|
||||
).describe('Replace all occurrences of old_string (default false)'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
// Parsed output — what call() receives. z.output not z.input: with
|
||||
// semanticBoolean the input side is unknown (preprocess accepts anything).
|
||||
export type FileEditInput = z.output<InputSchema>
|
||||
|
||||
// Individual edit without file_path
|
||||
export type EditInput = Omit<FileEditInput, 'file_path'>
|
||||
|
||||
// Runtime version where replace_all is always defined
|
||||
export type FileEdit = {
|
||||
old_string: string
|
||||
new_string: string
|
||||
replace_all: boolean
|
||||
}
|
||||
|
||||
export const hunkSchema = lazySchema(() =>
|
||||
z.object({
|
||||
oldStart: z.number(),
|
||||
oldLines: z.number(),
|
||||
newStart: z.number(),
|
||||
newLines: z.number(),
|
||||
lines: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
|
||||
export const gitDiffSchema = lazySchema(() =>
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
status: z.enum(['modified', 'added']),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
changes: z.number(),
|
||||
patch: z.string(),
|
||||
repository: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('GitHub owner/repo when available'),
|
||||
}),
|
||||
)
|
||||
|
||||
// Output schema for FileEditTool
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
filePath: z.string().describe('The file path that was edited'),
|
||||
oldString: z.string().describe('The original string that was replaced'),
|
||||
newString: z.string().describe('The new string that replaced it'),
|
||||
originalFile: z
|
||||
.string()
|
||||
.describe('The original file contents before editing'),
|
||||
structuredPatch: z
|
||||
.array(hunkSchema())
|
||||
.describe('Diff patch showing the changes'),
|
||||
userModified: z
|
||||
.boolean()
|
||||
.describe('Whether the user modified the proposed changes'),
|
||||
replaceAll: z.boolean().describe('Whether all occurrences were replaced'),
|
||||
gitDiff: gitDiffSchema().optional(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
|
||||
export type FileEditOutput = z.infer<OutputSchema>
|
||||
|
||||
export { inputSchema, outputSchema }
|
||||
@@ -0,0 +1,775 @@
|
||||
import { type StructuredPatchHunk, structuredPatch } from 'diff'
|
||||
import { logError } from 'src/utils/log.js'
|
||||
import { expandPath } from 'src/utils/path.js'
|
||||
import { countCharInString } from 'src/utils/stringUtils.js'
|
||||
import {
|
||||
DIFF_TIMEOUT_MS,
|
||||
getPatchForDisplay,
|
||||
getPatchFromContents,
|
||||
} from '../../utils/diff.js'
|
||||
import { errorMessage, isENOENT } from '../../utils/errors.js'
|
||||
import {
|
||||
addLineNumbers,
|
||||
convertLeadingTabsToSpaces,
|
||||
readFileSyncCached,
|
||||
} from '../../utils/file.js'
|
||||
import type { EditInput, FileEdit } from './types.js'
|
||||
|
||||
// Claude can't output curly quotes, so we define them as constants here for Claude to use
|
||||
// in the code. We do this because we normalize curly quotes to straight quotes
|
||||
// when applying edits.
|
||||
export const LEFT_SINGLE_CURLY_QUOTE = '‘'
|
||||
export const RIGHT_SINGLE_CURLY_QUOTE = '’'
|
||||
export const LEFT_DOUBLE_CURLY_QUOTE = '“'
|
||||
export const RIGHT_DOUBLE_CURLY_QUOTE = '”'
|
||||
|
||||
/**
|
||||
* Normalizes quotes in a string by converting curly quotes to straight quotes
|
||||
* @param str The string to normalize
|
||||
* @returns The string with all curly quotes replaced by straight quotes
|
||||
*/
|
||||
export function normalizeQuotes(str: string): string {
|
||||
return str
|
||||
.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
|
||||
.replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
|
||||
.replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
|
||||
.replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips trailing whitespace from each line in a string while preserving line endings
|
||||
* @param str The string to process
|
||||
* @returns The string with trailing whitespace removed from each line
|
||||
*/
|
||||
export function stripTrailingWhitespace(str: string): string {
|
||||
// Handle different line endings: CRLF, LF, CR
|
||||
// Use a regex that matches line endings and captures them
|
||||
const lines = str.split(/(\r\n|\n|\r)/)
|
||||
|
||||
let result = ''
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const part = lines[i]
|
||||
if (part !== undefined) {
|
||||
if (i % 2 === 0) {
|
||||
// Even indices are line content
|
||||
result += part.replace(/\s+$/, '')
|
||||
} else {
|
||||
// Odd indices are line endings
|
||||
result += part
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the actual string in the file content that matches the search string,
|
||||
* accounting for quote normalization
|
||||
* @param fileContent The file content to search in
|
||||
* @param searchString The string to search for
|
||||
* @returns The actual string found in the file, or null if not found
|
||||
*/
|
||||
export function findActualString(
|
||||
fileContent: string,
|
||||
searchString: string,
|
||||
): string | null {
|
||||
// First try exact match
|
||||
if (fileContent.includes(searchString)) {
|
||||
return searchString
|
||||
}
|
||||
|
||||
// Try with normalized quotes
|
||||
const normalizedSearch = normalizeQuotes(searchString)
|
||||
const normalizedFile = normalizeQuotes(fileContent)
|
||||
|
||||
const searchIndex = normalizedFile.indexOf(normalizedSearch)
|
||||
if (searchIndex !== -1) {
|
||||
// Find the actual string in the file that matches
|
||||
return fileContent.substring(searchIndex, searchIndex + searchString.length)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* When old_string matched via quote normalization (curly quotes in file,
|
||||
* straight quotes from model), apply the same curly quote style to new_string
|
||||
* so the edit preserves the file's typography.
|
||||
*
|
||||
* Uses a simple open/close heuristic: a quote character preceded by whitespace,
|
||||
* start of string, or opening punctuation is treated as an opening quote;
|
||||
* otherwise it's a closing quote.
|
||||
*/
|
||||
export function preserveQuoteStyle(
|
||||
oldString: string,
|
||||
actualOldString: string,
|
||||
newString: string,
|
||||
): string {
|
||||
// If they're the same, no normalization happened
|
||||
if (oldString === actualOldString) {
|
||||
return newString
|
||||
}
|
||||
|
||||
// Detect which curly quote types were in the file
|
||||
const hasDoubleQuotes =
|
||||
actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
|
||||
actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE)
|
||||
const hasSingleQuotes =
|
||||
actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
|
||||
actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
|
||||
if (!hasDoubleQuotes && !hasSingleQuotes) {
|
||||
return newString
|
||||
}
|
||||
|
||||
let result = newString
|
||||
|
||||
if (hasDoubleQuotes) {
|
||||
result = applyCurlyDoubleQuotes(result)
|
||||
}
|
||||
if (hasSingleQuotes) {
|
||||
result = applyCurlySingleQuotes(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isOpeningContext(chars: string[], index: number): boolean {
|
||||
if (index === 0) {
|
||||
return true
|
||||
}
|
||||
const prev = chars[index - 1]
|
||||
return (
|
||||
prev === ' ' ||
|
||||
prev === '\t' ||
|
||||
prev === '\n' ||
|
||||
prev === '\r' ||
|
||||
prev === '(' ||
|
||||
prev === '[' ||
|
||||
prev === '{' ||
|
||||
prev === '\u2014' || // em dash
|
||||
prev === '\u2013' // en dash
|
||||
)
|
||||
}
|
||||
|
||||
function applyCurlyDoubleQuotes(str: string): string {
|
||||
const chars = [...str]
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
if (chars[i] === '"') {
|
||||
result.push(
|
||||
isOpeningContext(chars, i)
|
||||
? LEFT_DOUBLE_CURLY_QUOTE
|
||||
: RIGHT_DOUBLE_CURLY_QUOTE,
|
||||
)
|
||||
} else {
|
||||
result.push(chars[i]!)
|
||||
}
|
||||
}
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
function applyCurlySingleQuotes(str: string): string {
|
||||
const chars = [...str]
|
||||
const result: string[] = []
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
if (chars[i] === "'") {
|
||||
// Don't convert apostrophes in contractions (e.g., "don't", "it's")
|
||||
// An apostrophe between two letters is a contraction, not a quote
|
||||
const prev = i > 0 ? chars[i - 1] : undefined
|
||||
const next = i < chars.length - 1 ? chars[i + 1] : undefined
|
||||
const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev)
|
||||
const nextIsLetter = next !== undefined && /\p{L}/u.test(next)
|
||||
if (prevIsLetter && nextIsLetter) {
|
||||
// Apostrophe in a contraction — use right single curly quote
|
||||
result.push(RIGHT_SINGLE_CURLY_QUOTE)
|
||||
} else {
|
||||
result.push(
|
||||
isOpeningContext(chars, i)
|
||||
? LEFT_SINGLE_CURLY_QUOTE
|
||||
: RIGHT_SINGLE_CURLY_QUOTE,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
result.push(chars[i]!)
|
||||
}
|
||||
}
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform edits to ensure replace_all always has a boolean value
|
||||
* @param edits Array of edits with optional replace_all
|
||||
* @returns Array of edits with replace_all guaranteed to be boolean
|
||||
*/
|
||||
export function applyEditToFile(
|
||||
originalContent: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean = false,
|
||||
): string {
|
||||
const f = replaceAll
|
||||
? (content: string, search: string, replace: string) =>
|
||||
content.replaceAll(search, () => replace)
|
||||
: (content: string, search: string, replace: string) =>
|
||||
content.replace(search, () => replace)
|
||||
|
||||
if (newString !== '') {
|
||||
return f(originalContent, oldString, newString)
|
||||
}
|
||||
|
||||
const stripTrailingNewline =
|
||||
!oldString.endsWith('\n') && originalContent.includes(oldString + '\n')
|
||||
|
||||
return stripTrailingNewline
|
||||
? f(originalContent, oldString + '\n', newString)
|
||||
: f(originalContent, oldString, newString)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an edit to a file and returns the patch and updated file.
|
||||
* Does not write the file to disk.
|
||||
*/
|
||||
export function getPatchForEdit({
|
||||
filePath,
|
||||
fileContents,
|
||||
oldString,
|
||||
newString,
|
||||
replaceAll = false,
|
||||
}: {
|
||||
filePath: string
|
||||
fileContents: string
|
||||
oldString: string
|
||||
newString: string
|
||||
replaceAll?: boolean
|
||||
}): { patch: StructuredPatchHunk[]; updatedFile: string } {
|
||||
return getPatchForEdits({
|
||||
filePath,
|
||||
fileContents,
|
||||
edits: [
|
||||
{ old_string: oldString, new_string: newString, replace_all: replaceAll },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a list of edits to a file and returns the patch and updated file.
|
||||
* Does not write the file to disk.
|
||||
*
|
||||
* NOTE: The returned patch is to be used for display purposes only - it has spaces instead of tabs
|
||||
*/
|
||||
export function getPatchForEdits({
|
||||
filePath,
|
||||
fileContents,
|
||||
edits,
|
||||
}: {
|
||||
filePath: string
|
||||
fileContents: string
|
||||
edits: FileEdit[]
|
||||
}): { patch: StructuredPatchHunk[]; updatedFile: string } {
|
||||
let updatedFile = fileContents
|
||||
const appliedNewStrings: string[] = []
|
||||
|
||||
// Special case for empty files.
|
||||
if (
|
||||
!fileContents &&
|
||||
edits.length === 1 &&
|
||||
edits[0] &&
|
||||
edits[0].old_string === '' &&
|
||||
edits[0].new_string === ''
|
||||
) {
|
||||
const patch = getPatchForDisplay({
|
||||
filePath,
|
||||
fileContents,
|
||||
edits: [
|
||||
{
|
||||
old_string: fileContents,
|
||||
new_string: updatedFile,
|
||||
replace_all: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
return { patch, updatedFile: '' }
|
||||
}
|
||||
|
||||
// Apply each edit and check if it actually changes the file
|
||||
for (const edit of edits) {
|
||||
// Strip trailing newlines from old_string before checking
|
||||
const oldStringToCheck = edit.old_string.replace(/\n+$/, '')
|
||||
|
||||
// Check if old_string is a substring of any previously applied new_string
|
||||
for (const previousNewString of appliedNewStrings) {
|
||||
if (
|
||||
oldStringToCheck !== '' &&
|
||||
previousNewString.includes(oldStringToCheck)
|
||||
) {
|
||||
throw new Error(
|
||||
'Cannot edit file: old_string is a substring of a new_string from a previous edit.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const previousContent = updatedFile
|
||||
updatedFile =
|
||||
edit.old_string === ''
|
||||
? edit.new_string
|
||||
: applyEditToFile(
|
||||
updatedFile,
|
||||
edit.old_string,
|
||||
edit.new_string,
|
||||
edit.replace_all,
|
||||
)
|
||||
|
||||
// If this edit didn't change anything, throw an error
|
||||
if (updatedFile === previousContent) {
|
||||
throw new Error('String not found in file. Failed to apply edit.')
|
||||
}
|
||||
|
||||
// Track the new string that was applied
|
||||
appliedNewStrings.push(edit.new_string)
|
||||
}
|
||||
|
||||
if (updatedFile === fileContents) {
|
||||
throw new Error(
|
||||
'Original and edited file match exactly. Failed to apply edit.',
|
||||
)
|
||||
}
|
||||
|
||||
// We already have before/after content, so call getPatchFromContents directly.
|
||||
// Previously this went through getPatchForDisplay with edits=[{old:fileContents,new:updatedFile}],
|
||||
// which transforms fileContents twice (once as preparedFileContents, again as escapedOldString
|
||||
// inside the reduce) and runs a no-op full-content .replace(). This saves ~20% on large files.
|
||||
const patch = getPatchFromContents({
|
||||
filePath,
|
||||
oldContent: convertLeadingTabsToSpaces(fileContents),
|
||||
newContent: convertLeadingTabsToSpaces(updatedFile),
|
||||
})
|
||||
|
||||
return { patch, updatedFile }
|
||||
}
|
||||
|
||||
// Cap on edited_text_file attachment snippets. Format-on-save of a large file
|
||||
// previously injected the entire file per turn (observed max 16.1KB, ~14K
|
||||
// tokens/session). 8KB preserves meaningful context while bounding worst case.
|
||||
const DIFF_SNIPPET_MAX_BYTES = 8192
|
||||
|
||||
/**
|
||||
* Used for attachments, to show snippets when files change.
|
||||
*
|
||||
* TODO: Unify this with the other snippet logic.
|
||||
*/
|
||||
export function getSnippetForTwoFileDiff(
|
||||
fileAContents: string,
|
||||
fileBContents: string,
|
||||
): string {
|
||||
const patch = structuredPatch(
|
||||
'file.txt',
|
||||
'file.txt',
|
||||
fileAContents,
|
||||
fileBContents,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
context: 8,
|
||||
timeout: DIFF_TIMEOUT_MS,
|
||||
},
|
||||
)
|
||||
|
||||
if (!patch) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const full = patch.hunks
|
||||
.map(_ => ({
|
||||
startLine: _.oldStart,
|
||||
content: _.lines
|
||||
// Filter out deleted lines AND diff metadata lines
|
||||
.filter(_ => !_.startsWith('-') && !_.startsWith('\\'))
|
||||
.map(_ => _.slice(1))
|
||||
.join('\n'),
|
||||
}))
|
||||
.map(addLineNumbers)
|
||||
.join('\n...\n')
|
||||
|
||||
if (full.length <= DIFF_SNIPPET_MAX_BYTES) {
|
||||
return full
|
||||
}
|
||||
|
||||
// Truncate at the last line boundary that fits within the cap.
|
||||
// Marker format matches BashTool/utils.ts.
|
||||
const cutoff = full.lastIndexOf('\n', DIFF_SNIPPET_MAX_BYTES)
|
||||
const kept =
|
||||
cutoff > 0 ? full.slice(0, cutoff) : full.slice(0, DIFF_SNIPPET_MAX_BYTES)
|
||||
const remaining = countCharInString(full, '\n', kept.length) + 1
|
||||
return `${kept}\n\n... [${remaining} lines truncated] ...`
|
||||
}
|
||||
|
||||
const CONTEXT_LINES = 4
|
||||
|
||||
/**
|
||||
* Gets a snippet from a file showing the context around a patch with line numbers.
|
||||
* @param originalFile The original file content before applying the patch
|
||||
* @param patch The diff hunks to use for determining snippet location
|
||||
* @param newFile The file content after applying the patch
|
||||
* @returns The snippet text with line numbers and the starting line number
|
||||
*/
|
||||
export function getSnippetForPatch(
|
||||
patch: StructuredPatchHunk[],
|
||||
newFile: string,
|
||||
): { formattedSnippet: string; startLine: number } {
|
||||
if (patch.length === 0) {
|
||||
// No changes, return empty snippet
|
||||
return { formattedSnippet: '', startLine: 1 }
|
||||
}
|
||||
|
||||
// Find the first and last changed lines across all hunks
|
||||
let minLine = Infinity
|
||||
let maxLine = -Infinity
|
||||
|
||||
for (const hunk of patch) {
|
||||
if (hunk.oldStart < minLine) {
|
||||
minLine = hunk.oldStart
|
||||
}
|
||||
// For the end line, we need to consider the new lines count since we're showing the new file
|
||||
const hunkEnd = hunk.oldStart + (hunk.newLines || 0) - 1
|
||||
if (hunkEnd > maxLine) {
|
||||
maxLine = hunkEnd
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the range with context
|
||||
const startLine = Math.max(1, minLine - CONTEXT_LINES)
|
||||
const endLine = maxLine + CONTEXT_LINES
|
||||
|
||||
// Split the new file into lines and get the snippet
|
||||
const fileLines = newFile.split(/\r?\n/)
|
||||
const snippetLines = fileLines.slice(startLine - 1, endLine)
|
||||
const snippet = snippetLines.join('\n')
|
||||
|
||||
// Add line numbers
|
||||
const formattedSnippet = addLineNumbers({
|
||||
content: snippet,
|
||||
startLine,
|
||||
})
|
||||
|
||||
return { formattedSnippet, startLine }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a snippet from a file showing the context around a single edit.
|
||||
* This is a convenience function that uses the original algorithm.
|
||||
* @param originalFile The original file content
|
||||
* @param oldString The text to replace
|
||||
* @param newString The text to replace it with
|
||||
* @param contextLines The number of lines to show before and after the change
|
||||
* @returns The snippet and the starting line number
|
||||
*/
|
||||
export function getSnippet(
|
||||
originalFile: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
contextLines: number = 4,
|
||||
): { snippet: string; startLine: number } {
|
||||
// Use the original algorithm from FileEditTool.tsx
|
||||
const before = originalFile.split(oldString)[0] ?? ''
|
||||
const replacementLine = before.split(/\r?\n/).length - 1
|
||||
const newFileLines = applyEditToFile(
|
||||
originalFile,
|
||||
oldString,
|
||||
newString,
|
||||
).split(/\r?\n/)
|
||||
|
||||
// Calculate the start and end line numbers for the snippet
|
||||
const startLine = Math.max(0, replacementLine - contextLines)
|
||||
const endLine =
|
||||
replacementLine + contextLines + newString.split(/\r?\n/).length
|
||||
|
||||
// Get snippet
|
||||
const snippetLines = newFileLines.slice(startLine, endLine)
|
||||
const snippet = snippetLines.join('\n')
|
||||
|
||||
return { snippet, startLine: startLine + 1 }
|
||||
}
|
||||
|
||||
export function getEditsForPatch(patch: StructuredPatchHunk[]): FileEdit[] {
|
||||
return patch.map(hunk => {
|
||||
// Extract the changes from this hunk
|
||||
const contextLines: string[] = []
|
||||
const oldLines: string[] = []
|
||||
const newLines: string[] = []
|
||||
|
||||
// Parse each line and categorize it
|
||||
for (const line of hunk.lines) {
|
||||
if (line.startsWith(' ')) {
|
||||
// Context line - appears in both versions
|
||||
contextLines.push(line.slice(1))
|
||||
oldLines.push(line.slice(1))
|
||||
newLines.push(line.slice(1))
|
||||
} else if (line.startsWith('-')) {
|
||||
// Deleted line - only in old version
|
||||
oldLines.push(line.slice(1))
|
||||
} else if (line.startsWith('+')) {
|
||||
// Added line - only in new version
|
||||
newLines.push(line.slice(1))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
old_string: oldLines.join('\n'),
|
||||
new_string: newLines.join('\n'),
|
||||
replace_all: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains replacements to de-sanitize strings from Claude
|
||||
* Since Claude can't see any of these strings (sanitized in the API)
|
||||
* It'll output the sanitized versions in the edit response
|
||||
*/
|
||||
const DESANITIZATIONS: Record<string, string> = {
|
||||
'<fnr>': '<function_results>',
|
||||
'<n>': '<name>',
|
||||
'</n>': '</name>',
|
||||
'<o>': '<output>',
|
||||
'</o>': '</output>',
|
||||
'<e>': '<error>',
|
||||
'</e>': '</error>',
|
||||
'<s>': '<system>',
|
||||
'</s>': '</system>',
|
||||
'<r>': '<result>',
|
||||
'</r>': '</result>',
|
||||
'< META_START >': '<META_START>',
|
||||
'< META_END >': '<META_END>',
|
||||
'< EOT >': '<EOT>',
|
||||
'< META >': '<META>',
|
||||
'< SOS >': '<SOS>',
|
||||
'\n\nH:': '\n\nHuman:',
|
||||
'\n\nA:': '\n\nAssistant:',
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a match string by applying specific replacements
|
||||
* This helps handle when exact matches fail due to formatting differences
|
||||
* @returns The normalized string and which replacements were applied
|
||||
*/
|
||||
function desanitizeMatchString(matchString: string): {
|
||||
result: string
|
||||
appliedReplacements: Array<{ from: string; to: string }>
|
||||
} {
|
||||
let result = matchString
|
||||
const appliedReplacements: Array<{ from: string; to: string }> = []
|
||||
|
||||
for (const [from, to] of Object.entries(DESANITIZATIONS)) {
|
||||
const beforeReplace = result
|
||||
result = result.replaceAll(from, to)
|
||||
|
||||
if (beforeReplace !== result) {
|
||||
appliedReplacements.push({ from, to })
|
||||
}
|
||||
}
|
||||
|
||||
return { result, appliedReplacements }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the input for the FileEditTool
|
||||
* If the string to replace is not found in the file, try with a normalized version
|
||||
* Returns the normalized input if successful, or the original input if not
|
||||
*/
|
||||
export function normalizeFileEditInput({
|
||||
file_path,
|
||||
edits,
|
||||
}: {
|
||||
file_path: string
|
||||
edits: EditInput[]
|
||||
}): {
|
||||
file_path: string
|
||||
edits: EditInput[]
|
||||
} {
|
||||
if (edits.length === 0) {
|
||||
return { file_path, edits }
|
||||
}
|
||||
|
||||
// Markdown uses two trailing spaces as a hard line break — stripping would
|
||||
// silently change semantics. Skip stripTrailingWhitespace for .md/.mdx.
|
||||
const isMarkdown = /\.(md|mdx)$/i.test(file_path)
|
||||
|
||||
try {
|
||||
const fullPath = expandPath(file_path)
|
||||
|
||||
// Use cached file read to avoid redundant I/O operations.
|
||||
// If the file doesn't exist, readFileSyncCached throws ENOENT which the
|
||||
// catch below handles by returning the original input (no TOCTOU pre-check).
|
||||
const fileContent = readFileSyncCached(fullPath)
|
||||
|
||||
return {
|
||||
file_path,
|
||||
edits: edits.map(({ old_string, new_string, replace_all }) => {
|
||||
const normalizedNewString = isMarkdown
|
||||
? new_string
|
||||
: stripTrailingWhitespace(new_string)
|
||||
|
||||
// If exact string match works, keep it as is
|
||||
if (fileContent.includes(old_string)) {
|
||||
return {
|
||||
old_string,
|
||||
new_string: normalizedNewString,
|
||||
replace_all,
|
||||
}
|
||||
}
|
||||
|
||||
// Try de-sanitize string if exact match fails
|
||||
const { result: desanitizedOldString, appliedReplacements } =
|
||||
desanitizeMatchString(old_string)
|
||||
|
||||
if (fileContent.includes(desanitizedOldString)) {
|
||||
// Apply the same exact replacements to new_string
|
||||
let desanitizedNewString = normalizedNewString
|
||||
for (const { from, to } of appliedReplacements) {
|
||||
desanitizedNewString = desanitizedNewString.replaceAll(from, to)
|
||||
}
|
||||
|
||||
return {
|
||||
old_string: desanitizedOldString,
|
||||
new_string: desanitizedNewString,
|
||||
replace_all,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
old_string,
|
||||
new_string: normalizedNewString,
|
||||
replace_all,
|
||||
}
|
||||
}),
|
||||
}
|
||||
} catch (error) {
|
||||
// If there's any error reading the file, just return original input.
|
||||
// ENOENT is expected when the file doesn't exist yet (e.g., new file).
|
||||
if (!isENOENT(error)) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
return { file_path, edits }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two sets of edits to determine if they are equivalent
|
||||
* by applying both sets to the original content and comparing results.
|
||||
* This handles cases where edits might be different but produce the same outcome.
|
||||
*/
|
||||
export function areFileEditsEquivalent(
|
||||
edits1: FileEdit[],
|
||||
edits2: FileEdit[],
|
||||
originalContent: string,
|
||||
): boolean {
|
||||
// Fast path: check if edits are literally identical
|
||||
if (
|
||||
edits1.length === edits2.length &&
|
||||
edits1.every((edit1, index) => {
|
||||
const edit2 = edits2[index]
|
||||
return (
|
||||
edit2 !== undefined &&
|
||||
edit1.old_string === edit2.old_string &&
|
||||
edit1.new_string === edit2.new_string &&
|
||||
edit1.replace_all === edit2.replace_all
|
||||
)
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Try applying both sets of edits
|
||||
let result1: { patch: StructuredPatchHunk[]; updatedFile: string } | null =
|
||||
null
|
||||
let error1: string | null = null
|
||||
let result2: { patch: StructuredPatchHunk[]; updatedFile: string } | null =
|
||||
null
|
||||
let error2: string | null = null
|
||||
|
||||
try {
|
||||
result1 = getPatchForEdits({
|
||||
filePath: 'temp',
|
||||
fileContents: originalContent,
|
||||
edits: edits1,
|
||||
})
|
||||
} catch (e) {
|
||||
error1 = errorMessage(e)
|
||||
}
|
||||
|
||||
try {
|
||||
result2 = getPatchForEdits({
|
||||
filePath: 'temp',
|
||||
fileContents: originalContent,
|
||||
edits: edits2,
|
||||
})
|
||||
} catch (e) {
|
||||
error2 = errorMessage(e)
|
||||
}
|
||||
|
||||
// If both threw errors, they're equal only if the errors are the same
|
||||
if (error1 !== null && error2 !== null) {
|
||||
// Normalize error messages for comparison
|
||||
return error1 === error2
|
||||
}
|
||||
|
||||
// If one threw an error and the other didn't, they're not equal
|
||||
if (error1 !== null || error2 !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Both succeeded - compare the results
|
||||
return result1!.updatedFile === result2!.updatedFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified function to check if two file edit inputs are equivalent.
|
||||
* Handles file edits (FileEditTool).
|
||||
*/
|
||||
export function areFileEditsInputsEquivalent(
|
||||
input1: {
|
||||
file_path: string
|
||||
edits: FileEdit[]
|
||||
},
|
||||
input2: {
|
||||
file_path: string
|
||||
edits: FileEdit[]
|
||||
},
|
||||
): boolean {
|
||||
// Fast path: different files
|
||||
if (input1.file_path !== input2.file_path) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fast path: literal equality
|
||||
if (
|
||||
input1.edits.length === input2.edits.length &&
|
||||
input1.edits.every((edit1, index) => {
|
||||
const edit2 = input2.edits[index]
|
||||
return (
|
||||
edit2 !== undefined &&
|
||||
edit1.old_string === edit2.old_string &&
|
||||
edit1.new_string === edit2.new_string &&
|
||||
edit1.replace_all === edit2.replace_all
|
||||
)
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Semantic comparison (requires file read). If the file doesn't exist,
|
||||
// compare against empty content (no TOCTOU pre-check).
|
||||
let fileContent = ''
|
||||
try {
|
||||
fileContent = readFileSyncCached(input1.file_path)
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return areFileEditsEquivalent(input1.edits, input2.edits, fileContent)
|
||||
}
|
||||
Reference in New Issue
Block a user