| import { dirname, sep } from 'path' |
| import { logEvent } from 'src/services/analytics/index.js' |
| import { z } from 'zod/v4' |
| 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, getPatchForDisplay } from '../../utils/diff.js' |
| import { isEnvTruthy } from '../../utils/envUtils.js' |
| import { isENOENT } from '../../utils/errors.js' |
| import { getFileModificationTime, writeTextContent } from '../../utils/file.js' |
| import { |
| fileHistoryEnabled, |
| fileHistoryTrackEdit, |
| } from '../../utils/fileHistory.js' |
| import { logFileOperation } from '../../utils/fileOperationAnalytics.js' |
| import { readFileSyncWithMetadata } from '../../utils/fileRead.js' |
| import { getFsImplementation } from '../../utils/fsOperations.js' |
| import { |
| fetchSingleFileGitDiff, |
| type ToolUseDiff, |
| } from '../../utils/gitDiff.js' |
| import { lazySchema } from '../../utils/lazySchema.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 { FILE_UNEXPECTEDLY_MODIFIED_ERROR } from '../FileEditTool/constants.js' |
| import { gitDiffSchema, hunkSchema } from '../FileEditTool/types.js' |
| import { FILE_WRITE_TOOL_NAME, getWriteToolDescription } from './prompt.js' |
| import { |
| getToolUseSummary, |
| isResultTruncated, |
| renderToolResultMessage, |
| renderToolUseErrorMessage, |
| renderToolUseMessage, |
| renderToolUseRejectedMessage, |
| userFacingName, |
| } from './UI.js' |
|
|
| const inputSchema = lazySchema(() => |
| z.strictObject({ |
| file_path: z |
| .string() |
| .describe( |
| 'The absolute path to the file to write (must be absolute, not relative)', |
| ), |
| content: z.string().describe('The content to write to the file'), |
| }), |
| ) |
| type InputSchema = ReturnType<typeof inputSchema> |
|
|
| const outputSchema = lazySchema(() => |
| z.object({ |
| type: z |
| .enum(['create', 'update']) |
| .describe( |
| 'Whether a new file was created or an existing file was updated', |
| ), |
| filePath: z.string().describe('The path to the file that was written'), |
| content: z.string().describe('The content that was written to the file'), |
| structuredPatch: z |
| .array(hunkSchema()) |
| .describe('Diff patch showing the changes'), |
| originalFile: z |
| .string() |
| .nullable() |
| .describe( |
| 'The original file content before the write (null for new files)', |
| ), |
| gitDiff: gitDiffSchema().optional(), |
| }), |
| ) |
| type OutputSchema = ReturnType<typeof outputSchema> |
|
|
| export type Output = z.infer<OutputSchema> |
| export type FileWriteToolInput = InputSchema |
|
|
| export const FileWriteTool = buildTool({ |
| name: FILE_WRITE_TOOL_NAME, |
| searchHint: 'create or overwrite files', |
| maxResultSizeChars: 100_000, |
| strict: true, |
| async description() { |
| return 'Write a file to the local filesystem.' |
| }, |
| userFacingName, |
| getToolUseSummary, |
| getActivityDescription(input) { |
| const summary = getToolUseSummary(input) |
| return summary ? `Writing ${summary}` : 'Writing file' |
| }, |
| async prompt() { |
| return getWriteToolDescription() |
| }, |
| renderToolUseMessage, |
| isResultTruncated, |
| get inputSchema(): InputSchema { |
| return inputSchema() |
| }, |
| get outputSchema(): OutputSchema { |
| return outputSchema() |
| }, |
| toAutoClassifierInput(input) { |
| return `${input.file_path}: ${input.content}` |
| }, |
| getPath(input): string { |
| return input.file_path |
| }, |
| backfillObservableInput(input) { |
| |
| |
| 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( |
| FileWriteTool, |
| input, |
| appState.toolPermissionContext, |
| ) |
| }, |
| renderToolUseRejectedMessage, |
| renderToolUseErrorMessage, |
| renderToolResultMessage, |
| extractSearchText() { |
| |
| |
| |
| |
| return '' |
| }, |
| async validateInput({ file_path, content }, toolUseContext: ToolUseContext) { |
| const fullFilePath = expandPath(file_path) |
|
|
| |
| const secretError = checkTeamMemSecrets(fullFilePath, content) |
| if (secretError) { |
| return { result: false, message: secretError, errorCode: 0 } |
| } |
|
|
| |
| const appState = toolUseContext.getAppState() |
| const denyRule = matchingRuleForInput( |
| fullFilePath, |
| appState.toolPermissionContext, |
| 'edit', |
| 'deny', |
| ) |
| if (denyRule !== null) { |
| return { |
| result: false, |
| message: |
| 'File is in a directory that is denied by your permission settings.', |
| errorCode: 1, |
| } |
| } |
|
|
| |
| |
| |
| if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')) { |
| return { result: true } |
| } |
|
|
| const fs = getFsImplementation() |
| let fileMtimeMs: number |
| try { |
| const fileStat = await fs.stat(fullFilePath) |
| fileMtimeMs = fileStat.mtimeMs |
| } catch (e) { |
| if (isENOENT(e)) { |
| return { result: true } |
| } |
| throw e |
| } |
|
|
| const readTimestamp = toolUseContext.readFileState.get(fullFilePath) |
| if (!readTimestamp || readTimestamp.isPartialView) { |
| return { |
| result: false, |
| message: |
| 'File has not been read yet. Read it first before writing to it.', |
| errorCode: 2, |
| } |
| } |
|
|
| |
| |
| |
| const lastWriteTime = Math.floor(fileMtimeMs) |
| if (lastWriteTime > readTimestamp.timestamp) { |
| return { |
| result: false, |
| message: |
| 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', |
| errorCode: 3, |
| } |
| } |
|
|
| return { result: true } |
| }, |
| async call( |
| { file_path, content }, |
| { readFileState, updateFileHistoryState, dynamicSkillDirTriggers }, |
| _, |
| parentMessage, |
| ) { |
| const fullFilePath = expandPath(file_path) |
| const dir = dirname(fullFilePath) |
|
|
| |
| const cwd = getCwd() |
| const newSkillDirs = await discoverSkillDirsForPaths([fullFilePath], cwd) |
| if (newSkillDirs.length > 0) { |
| |
| for (const dir of newSkillDirs) { |
| dynamicSkillDirTriggers?.add(dir) |
| } |
| |
| addSkillDirectories(newSkillDirs).catch(() => {}) |
| } |
|
|
| |
| activateConditionalSkillsForPaths([fullFilePath], cwd) |
|
|
| await diagnosticTracker.beforeFileEdited(fullFilePath) |
|
|
| |
| |
| |
| |
| |
| await getFsImplementation().mkdir(dir) |
| if (fileHistoryEnabled()) { |
| |
| |
| |
| await fileHistoryTrackEdit( |
| updateFileHistoryState, |
| fullFilePath, |
| parentMessage.uuid, |
| ) |
| } |
|
|
| |
| |
| let meta: ReturnType<typeof readFileSyncWithMetadata> | null |
| try { |
| meta = readFileSyncWithMetadata(fullFilePath) |
| } catch (e) { |
| if (isENOENT(e)) { |
| meta = null |
| } else { |
| throw e |
| } |
| } |
|
|
| if (meta !== null) { |
| const lastWriteTime = getFileModificationTime(fullFilePath) |
| const lastRead = readFileState.get(fullFilePath) |
| if (!lastRead || lastWriteTime > lastRead.timestamp) { |
| |
| |
| |
| const isFullRead = |
| lastRead && |
| lastRead.offset === undefined && |
| lastRead.limit === undefined |
| |
| if (!isFullRead || meta.content !== lastRead.content) { |
| throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR) |
| } |
| } |
| } |
|
|
| const enc = meta?.encoding ?? 'utf8' |
| const oldContent = meta?.content ?? null |
|
|
| |
| |
| |
| |
| |
| writeTextContent(fullFilePath, content, enc, 'LF') |
|
|
| |
| const lspManager = getLspServerManager() |
| if (lspManager) { |
| |
| clearDeliveredDiagnosticsForFile(`file://${fullFilePath}`) |
| |
| lspManager.changeFile(fullFilePath, content).catch((err: Error) => { |
| logForDebugging( |
| `LSP: Failed to notify server of file change for ${fullFilePath}: ${err.message}`, |
| ) |
| logError(err) |
| }) |
| |
| lspManager.saveFile(fullFilePath).catch((err: Error) => { |
| logForDebugging( |
| `LSP: Failed to notify server of file save for ${fullFilePath}: ${err.message}`, |
| ) |
| logError(err) |
| }) |
| } |
|
|
| |
| notifyVscodeFileUpdated(fullFilePath, oldContent, content) |
|
|
| |
| readFileState.set(fullFilePath, { |
| content, |
| timestamp: getFileModificationTime(fullFilePath), |
| offset: undefined, |
| limit: undefined, |
| }) |
|
|
| |
| if (fullFilePath.endsWith(`${sep}CLAUDE.md`)) { |
| logEvent('tengu_write_claudemd', {}) |
| } |
|
|
| 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(fullFilePath) |
| if (diff) gitDiff = diff |
| logEvent('tengu_tool_use_diff_computed', { |
| isWriteTool: true, |
| durationMs: Date.now() - startTime, |
| hasDiff: !!diff, |
| }) |
| } |
|
|
| if (oldContent) { |
| const patch = getPatchForDisplay({ |
| filePath: file_path, |
| fileContents: oldContent, |
| edits: [ |
| { |
| old_string: oldContent, |
| new_string: content, |
| replace_all: false, |
| }, |
| ], |
| }) |
|
|
| const data = { |
| type: 'update' as const, |
| filePath: file_path, |
| content, |
| structuredPatch: patch, |
| originalFile: oldContent, |
| ...(gitDiff && { gitDiff }), |
| } |
| |
| countLinesChanged(patch) |
|
|
| logFileOperation({ |
| operation: 'write', |
| tool: 'FileWriteTool', |
| filePath: fullFilePath, |
| type: 'update', |
| }) |
|
|
| return { |
| data, |
| } |
| } |
|
|
| const data = { |
| type: 'create' as const, |
| filePath: file_path, |
| content, |
| structuredPatch: [], |
| originalFile: null, |
| ...(gitDiff && { gitDiff }), |
| } |
|
|
| |
| countLinesChanged([], content) |
|
|
| logFileOperation({ |
| operation: 'write', |
| tool: 'FileWriteTool', |
| filePath: fullFilePath, |
| type: 'create', |
| }) |
|
|
| return { |
| data, |
| } |
| }, |
| mapToolResultToToolResultBlockParam({ filePath, type }, toolUseID) { |
| switch (type) { |
| case 'create': |
| return { |
| tool_use_id: toolUseID, |
| type: 'tool_result', |
| content: `File created successfully at: ${filePath}`, |
| } |
| case 'update': |
| return { |
| tool_use_id: toolUseID, |
| type: 'tool_result', |
| content: `The file ${filePath} has been updated successfully.`, |
| } |
| } |
| }, |
| } satisfies ToolDef<InputSchema, Output>) |
|
|