Download packages/core/src/tools/write-file.ts from Jimmi42/openCLI: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/Jimmi42/openCLI/resolve/main/packages/core/src/tools/write-file.ts
- Command line
-
hf download hf://Jimmi42/openCLI/packages/core/src/tools/write-file.ts
-
curl -L -o write-file.ts https://huggingface.co/Jimmi42/openCLI/resolve/main/packages/core/src/tools/write-file.ts
12.5 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import fs from 'fs'; | |
| import path from 'path'; | |
| import * as Diff from 'diff'; | |
| import { Config, ApprovalMode } from '../config/config.js'; | |
| import { | |
| BaseTool, | |
| ToolResult, | |
| FileDiff, | |
| ToolEditConfirmationDetails, | |
| ToolConfirmationOutcome, | |
| ToolCallConfirmationDetails, | |
| } from './tools.js'; | |
| import { SchemaValidator } from '../utils/schemaValidator.js'; | |
| import { makeRelative, shortenPath } from '../utils/paths.js'; | |
| import { getErrorMessage, isNodeError } from '../utils/errors.js'; | |
| import { | |
| ensureCorrectEdit, | |
| ensureCorrectFileContent, | |
| } from '../utils/editCorrector.js'; | |
| import { GeminiClient } from '../core/client.js'; | |
| import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; | |
| import { ModifiableTool, ModifyContext } from './modifiable-tool.js'; | |
| import { getSpecificMimeType } from '../utils/fileUtils.js'; | |
| import { | |
| recordFileOperationMetric, | |
| FileOperation, | |
| } from '../telemetry/metrics.js'; | |
| /** | |
| * Parameters for the WriteFile tool | |
| */ | |
| export interface WriteFileToolParams { | |
| /** | |
| * The absolute path to the file to write to | |
| */ | |
| file_path: string; | |
| /** | |
| * The content to write to the file | |
| */ | |
| content: string; | |
| } | |
| interface GetCorrectedFileContentResult { | |
| originalContent: string; | |
| correctedContent: string; | |
| fileExists: boolean; | |
| error?: { message: string; code?: string }; | |
| } | |
| /** | |
| * Implementation of the WriteFile tool logic | |
| */ | |
| export class WriteFileTool | |
| extends BaseTool<WriteFileToolParams, ToolResult> | |
| implements ModifiableTool<WriteFileToolParams> | |
| { | |
| static readonly Name: string = 'write_file'; | |
| private readonly client: GeminiClient; | |
| constructor(private readonly config: Config) { | |
| super( | |
| WriteFileTool.Name, | |
| 'WriteFile', | |
| 'Writes content to a specified file in the local filesystem.', | |
| { | |
| properties: { | |
| file_path: { | |
| description: | |
| "The absolute path to the file to write to (e.g., '/home/user/project/file.txt'). Relative paths are not supported.", | |
| type: 'string', | |
| }, | |
| content: { | |
| description: 'The content to write to the file.', | |
| type: 'string', | |
| }, | |
| }, | |
| required: ['file_path', 'content'], | |
| type: 'object', | |
| }, | |
| ); | |
| this.client = this.config.getGeminiClient(); | |
| } | |
| private isWithinRoot(pathToCheck: string): boolean { | |
| const normalizedPath = path.normalize(pathToCheck); | |
| const normalizedRoot = path.normalize(this.config.getTargetDir()); | |
| const rootWithSep = normalizedRoot.endsWith(path.sep) | |
| ? normalizedRoot | |
| : normalizedRoot + path.sep; | |
| return ( | |
| normalizedPath === normalizedRoot || | |
| normalizedPath.startsWith(rootWithSep) | |
| ); | |
| } | |
| validateToolParams(params: WriteFileToolParams): string | null { | |
| if ( | |
| this.schema.parameters && | |
| !SchemaValidator.validate( | |
| this.schema.parameters as Record<string, unknown>, | |
| params, | |
| ) | |
| ) { | |
| return 'Parameters failed schema validation.'; | |
| } | |
| const filePath = params.file_path; | |
| if (!path.isAbsolute(filePath)) { | |
| return `File path must be absolute: ${filePath}`; | |
| } | |
| if (!this.isWithinRoot(filePath)) { | |
| return `File path must be within the root directory (${this.config.getTargetDir()}): ${filePath}`; | |
| } | |
| try { | |
| // This check should be performed only if the path exists. | |
| // If it doesn't exist, it's a new file, which is valid for writing. | |
| if (fs.existsSync(filePath)) { | |
| const stats = fs.lstatSync(filePath); | |
| if (stats.isDirectory()) { | |
| return `Path is a directory, not a file: ${filePath}`; | |
| } | |
| } | |
| } catch (statError: unknown) { | |
| // If fs.existsSync is true but lstatSync fails (e.g., permissions, race condition where file is deleted) | |
| // this indicates an issue with accessing the path that should be reported. | |
| return `Error accessing path properties for validation: ${filePath}. Reason: ${statError instanceof Error ? statError.message : String(statError)}`; | |
| } | |
| return null; | |
| } | |
| getDescription(params: WriteFileToolParams): string { | |
| if (!params.file_path || !params.content) { | |
| return `Model did not provide valid parameters for write file tool`; | |
| } | |
| const relativePath = makeRelative( | |
| params.file_path, | |
| this.config.getTargetDir(), | |
| ); | |
| return `Writing to ${shortenPath(relativePath)}`; | |
| } | |
| /** | |
| * Handles the confirmation prompt for the WriteFile tool. | |
| */ | |
| async shouldConfirmExecute( | |
| params: WriteFileToolParams, | |
| abortSignal: AbortSignal, | |
| ): Promise<ToolCallConfirmationDetails | false> { | |
| if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { | |
| return false; | |
| } | |
| const validationError = this.validateToolParams(params); | |
| if (validationError) { | |
| return false; | |
| } | |
| const correctedContentResult = await this._getCorrectedFileContent( | |
| params.file_path, | |
| params.content, | |
| abortSignal, | |
| ); | |
| if (correctedContentResult.error) { | |
| // If file exists but couldn't be read, we can't show a diff for confirmation. | |
| return false; | |
| } | |
| const { originalContent, correctedContent } = correctedContentResult; | |
| const relativePath = makeRelative( | |
| params.file_path, | |
| this.config.getTargetDir(), | |
| ); | |
| const fileName = path.basename(params.file_path); | |
| const fileDiff = Diff.createPatch( | |
| fileName, | |
| originalContent, // Original content (empty if new file or unreadable) | |
| correctedContent, // Content after potential correction | |
| 'Current', | |
| 'Proposed', | |
| DEFAULT_DIFF_OPTIONS, | |
| ); | |
| const confirmationDetails: ToolEditConfirmationDetails = { | |
| type: 'edit', | |
| title: `Confirm Write: ${shortenPath(relativePath)}`, | |
| fileName, | |
| fileDiff, | |
| onConfirm: async (outcome: ToolConfirmationOutcome) => { | |
| if (outcome === ToolConfirmationOutcome.ProceedAlways) { | |
| this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); | |
| } | |
| }, | |
| }; | |
| return confirmationDetails; | |
| } | |
| async execute( | |
| params: WriteFileToolParams, | |
| abortSignal: AbortSignal, | |
| ): Promise<ToolResult> { | |
| const validationError = this.validateToolParams(params); | |
| if (validationError) { | |
| return { | |
| llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, | |
| returnDisplay: `Error: ${validationError}`, | |
| }; | |
| } | |
| const correctedContentResult = await this._getCorrectedFileContent( | |
| params.file_path, | |
| params.content, | |
| abortSignal, | |
| ); | |
| if (correctedContentResult.error) { | |
| const errDetails = correctedContentResult.error; | |
| const errorMsg = `Error checking existing file: ${errDetails.message}`; | |
| return { | |
| llmContent: `Error checking existing file ${params.file_path}: ${errDetails.message}`, | |
| returnDisplay: errorMsg, | |
| }; | |
| } | |
| const { | |
| originalContent, | |
| correctedContent: fileContent, | |
| fileExists, | |
| } = correctedContentResult; | |
| // fileExists is true if the file existed (and was readable or unreadable but caught by readError). | |
| // fileExists is false if the file did not exist (ENOENT). | |
| const isNewFile = | |
| !fileExists || | |
| (correctedContentResult.error !== undefined && | |
| !correctedContentResult.fileExists); | |
| try { | |
| const dirName = path.dirname(params.file_path); | |
| if (!fs.existsSync(dirName)) { | |
| fs.mkdirSync(dirName, { recursive: true }); | |
| } | |
| fs.writeFileSync(params.file_path, fileContent, 'utf8'); | |
| // Generate diff for display result | |
| const fileName = path.basename(params.file_path); | |
| // If there was a readError, originalContent in correctedContentResult is '', | |
| // but for the diff, we want to show the original content as it was before the write if possible. | |
| // However, if it was unreadable, currentContentForDiff will be empty. | |
| const currentContentForDiff = correctedContentResult.error | |
| ? '' // Or some indicator of unreadable content | |
| : originalContent; | |
| const fileDiff = Diff.createPatch( | |
| fileName, | |
| currentContentForDiff, | |
| fileContent, | |
| 'Original', | |
| 'Written', | |
| DEFAULT_DIFF_OPTIONS, | |
| ); | |
| const llmSuccessMessage = isNewFile | |
| ? `Successfully created and wrote to new file: ${params.file_path}` | |
| : `Successfully overwrote file: ${params.file_path}`; | |
| const displayResult: FileDiff = { fileDiff, fileName }; | |
| const lines = fileContent.split('\n').length; | |
| const mimetype = getSpecificMimeType(params.file_path); | |
| const extension = path.extname(params.file_path); // Get extension | |
| if (isNewFile) { | |
| recordFileOperationMetric( | |
| this.config, | |
| FileOperation.CREATE, | |
| lines, | |
| mimetype, | |
| extension, | |
| ); | |
| } else { | |
| recordFileOperationMetric( | |
| this.config, | |
| FileOperation.UPDATE, | |
| lines, | |
| mimetype, | |
| extension, | |
| ); | |
| } | |
| return { | |
| llmContent: llmSuccessMessage, | |
| returnDisplay: displayResult, | |
| }; | |
| } catch (error) { | |
| const errorMsg = `Error writing to file: ${error instanceof Error ? error.message : String(error)}`; | |
| return { | |
| llmContent: `Error writing to file ${params.file_path}: ${errorMsg}`, | |
| returnDisplay: `Error: ${errorMsg}`, | |
| }; | |
| } | |
| } | |
| private async _getCorrectedFileContent( | |
| filePath: string, | |
| proposedContent: string, | |
| abortSignal: AbortSignal, | |
| ): Promise<GetCorrectedFileContentResult> { | |
| let originalContent = ''; | |
| let fileExists = false; | |
| let correctedContent = proposedContent; | |
| try { | |
| originalContent = fs.readFileSync(filePath, 'utf8'); | |
| fileExists = true; // File exists and was read | |
| } catch (err) { | |
| if (isNodeError(err) && err.code === 'ENOENT') { | |
| fileExists = false; | |
| originalContent = ''; | |
| } else { | |
| // File exists but could not be read (permissions, etc.) | |
| fileExists = true; // Mark as existing but problematic | |
| originalContent = ''; // Can't use its content | |
| const error = { | |
| message: getErrorMessage(err), | |
| code: isNodeError(err) ? err.code : undefined, | |
| }; | |
| // Return early as we can't proceed with content correction meaningfully | |
| return { originalContent, correctedContent, fileExists, error }; | |
| } | |
| } | |
| // If readError is set, we have returned. | |
| // So, file was either read successfully (fileExists=true, originalContent set) | |
| // or it was ENOENT (fileExists=false, originalContent=''). | |
| if (fileExists) { | |
| // This implies originalContent is available | |
| const { params: correctedParams } = await ensureCorrectEdit( | |
| originalContent, | |
| { | |
| old_string: originalContent, // Treat entire current content as old_string | |
| new_string: proposedContent, | |
| file_path: filePath, | |
| }, | |
| this.client, | |
| abortSignal, | |
| ); | |
| correctedContent = correctedParams.new_string; | |
| } else { | |
| // This implies new file (ENOENT) | |
| correctedContent = await ensureCorrectFileContent( | |
| proposedContent, | |
| this.client, | |
| abortSignal, | |
| ); | |
| } | |
| return { originalContent, correctedContent, fileExists }; | |
| } | |
| getModifyContext( | |
| abortSignal: AbortSignal, | |
| ): ModifyContext<WriteFileToolParams> { | |
| return { | |
| getFilePath: (params: WriteFileToolParams) => params.file_path, | |
| getCurrentContent: async (params: WriteFileToolParams) => { | |
| const correctedContentResult = await this._getCorrectedFileContent( | |
| params.file_path, | |
| params.content, | |
| abortSignal, | |
| ); | |
| return correctedContentResult.originalContent; | |
| }, | |
| getProposedContent: async (params: WriteFileToolParams) => { | |
| const correctedContentResult = await this._getCorrectedFileContent( | |
| params.file_path, | |
| params.content, | |
| abortSignal, | |
| ); | |
| return correctedContentResult.correctedContent; | |
| }, | |
| createUpdatedParams: ( | |
| _oldContent: string, | |
| modifiedProposedContent: string, | |
| originalParams: WriteFileToolParams, | |
| ) => ({ | |
| ...originalParams, | |
| content: modifiedProposedContent, | |
| }), | |
| }; | |
| } | |
| } | |