UNPKG

quantum-cli-core

Version:

Quantum CLI Core - Multi-LLM Collaboration System

261 lines (260 loc) 12.4 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 { ApprovalMode } from '../config/config.js'; import { BaseTool, ToolConfirmationOutcome, } 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 { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { getSpecificMimeType } from '../utils/fileUtils.js'; import { recordFileOperationMetric, FileOperation, } from '../telemetry/metrics.js'; /** * Implementation of the WriteFile tool logic */ export class WriteFileTool extends BaseTool { config; static Name = 'write_file'; constructor(config) { super(WriteFileTool.Name, 'WriteFile', `Writes content to a specified file in the local filesystem. The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, { 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.config = config; } /** * Checks if a given path is within the root directory bounds. * This security check prevents writing files outside the designated root directory. * * @param pathToCheck The absolute path to validate * @returns True if the path is within the root directory, false otherwise */ isWithinRoot(pathToCheck) { 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) { if (this.schema.parameters && !SchemaValidator.validate(this.schema.parameters, 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) { // 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) { 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, abortSignal) { 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 = { type: 'edit', title: `Confirm Write: ${shortenPath(relativePath)}`, fileName, fileDiff, onConfirm: async (outcome) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); } }, }; return confirmationDetails; } async execute(params, abortSignal) { 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 llmSuccessMessageParts = [ isNewFile ? `Successfully created and wrote to new file: ${params.file_path}.` : `Successfully overwrote file: ${params.file_path}.`, ]; if (params.modified_by_user) { llmSuccessMessageParts.push(`User modified the \`content\` to be: ${params.content}`); } const displayResult = { 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: llmSuccessMessageParts.join(' '), 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}`, }; } } async _getCorrectedFileContent(filePath, proposedContent, abortSignal) { 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(filePath, originalContent, { old_string: originalContent, // Treat entire current content as old_string new_string: proposedContent, file_path: filePath, }, this.config.getGeminiClient(), abortSignal); correctedContent = correctedParams.new_string; } else { // This implies new file (ENOENT) correctedContent = await ensureCorrectFileContent(proposedContent, this.config.getGeminiClient(), abortSignal); } return { originalContent, correctedContent, fileExists }; } getModifyContext(abortSignal) { return { getFilePath: (params) => params.file_path, getCurrentContent: async (params) => { const correctedContentResult = await this._getCorrectedFileContent(params.file_path, params.content, abortSignal); return correctedContentResult.originalContent; }, getProposedContent: async (params) => { const correctedContentResult = await this._getCorrectedFileContent(params.file_path, params.content, abortSignal); return correctedContentResult.correctedContent; }, createUpdatedParams: (_oldContent, modifiedProposedContent, originalParams) => ({ ...originalParams, content: modifiedProposedContent, modified_by_user: true, }), }; } } //# sourceMappingURL=write-file.js.map