// File Writing Operations with Safety Controls
import * as fs from 'fs';
import { promises as fsAsync } from 'fs';
import * as path from 'path';
import { WorkspaceDetector } from './workspace-detector.js';
import type { Storage } from './storage.js';
export interface FileChange {
type: 'replace' | 'insert' | 'delete';
line?: number;
oldText?: string;
newText: string;
}
export interface FileWriteResult {
success: boolean;
path: string;
message: string;
preview?: string;
requiresApproval: boolean;
}
export interface FileBackup {
path: string;
content: string;
timestamp: Date;
}
export class FileWriter {
private undoStack: Map<string, FileBackup[]> = new Map();
private readonly MAX_UNDO_LEVELS = 10;
private readonly MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB
constructor(
private workspaceDetector: WorkspaceDetector,
private storage: Storage
) {}
/**
* Create a new file
*/
async createFile(
relativePath: string,
content: string,
overwrite: boolean = false
): Promise<FileWriteResult> {
const workspace = this.workspaceDetector.getCurrentWorkspace();
if (!workspace) {
return {
success: false,
path: relativePath,
message: 'No workspace set. Use set_workspace first.',
requiresApproval: false
};
}
// Validate path
const validation = this.validatePath(relativePath);
if (!validation.valid) {
return {
success: false,
path: relativePath,
message: validation.error!,
requiresApproval: false
};
}
const fullPath = path.join(workspace, relativePath);
// Check if file exists
let fileExists = false;
try {
await fsAsync.access(fullPath);
fileExists = true;
} catch {
fileExists = false;
}
if (fileExists && !overwrite) {
return {
success: false,
path: relativePath,
message: `File already exists: ${relativePath}. Use overwrite flag to replace.`,
requiresApproval: false
};
}
// Validate content size
const size = Buffer.byteLength(content);
if (size > this.MAX_FILE_SIZE) {
return {
success: false,
path: relativePath,
message: `File too large: ${(size / 1024).toFixed(1)}KB. Max: ${(this.MAX_FILE_SIZE / 1024).toFixed(1)}KB`,
requiresApproval: false
};
}
// Generate preview
const preview = this.generateCreatePreview(relativePath, content);
return {
success: true,
path: relativePath,
message: 'Ready to create file. Preview shown above.',
preview,
requiresApproval: true
};
}
/**
* Actually write the file after approval
*/
async applyCreateFile(relativePath: string, content: string): Promise<FileWriteResult> {
const workspace = this.workspaceDetector.getCurrentWorkspace();
if (!workspace) {
return {
success: false,
path: relativePath,
message: 'No workspace set',
requiresApproval: false
};
}
const fullPath = path.join(workspace, relativePath);
try {
// Create directory if doesn't exist
const dir = path.dirname(fullPath);
try {
await fsAsync.access(dir);
} catch {
await fsAsync.mkdir(dir, { recursive: true });
}
// Write file
await fsAsync.writeFile(fullPath, content, 'utf8');
// Log decision
const project = this.storage.getCurrentProject();
if (project) {
this.storage.addDecision({
projectId: project.id,
type: 'other',
description: `Created file: ${relativePath}`,
reasoning: 'Generated by Claude with user approval'
});
}
return {
success: true,
path: relativePath,
message: `✅ Created ${relativePath}`,
requiresApproval: false
};
} catch (error) {
return {
success: false,
path: relativePath,
message: `Error creating file: ${error instanceof Error ? error.message : 'Unknown error'}`,
requiresApproval: false
};
}
}
/**
* Modify an existing file
*/
async modifyFile(
relativePath: string,
changes: FileChange[]
): Promise<FileWriteResult> {
const workspace = this.workspaceDetector.getCurrentWorkspace();
if (!workspace) {
return {
success: false,
path: relativePath,
message: 'No workspace set. Use set_workspace first.',
requiresApproval: false
};
}
const fullPath = path.join(workspace, relativePath);
// Check if file exists
try {
await fsAsync.access(fullPath);
} catch {
return {
success: false,
path: relativePath,
message: `File not found: ${relativePath}`,
requiresApproval: false
};
}
try {
// Read current content
const originalContent = await fsAsync.readFile(fullPath, 'utf8');
// Create backup
this.createBackup(relativePath, originalContent);
// Apply changes
const newContent = this.applyChanges(originalContent, changes);
// Generate diff preview
const preview = this.generateDiffPreview(relativePath, originalContent, newContent);
return {
success: true,
path: relativePath,
message: 'Ready to modify file. Preview shown above.',
preview,
requiresApproval: true
};
} catch (error) {
return {
success: false,
path: relativePath,
message: `Error reading file: ${error instanceof Error ? error.message : 'Unknown error'}`,
requiresApproval: false
};
}
}
/**
* Actually apply the modification after approval
*/
async applyModifyFile(
relativePath: string,
changes: FileChange[]
): Promise<FileWriteResult> {
const workspace = this.workspaceDetector.getCurrentWorkspace();
if (!workspace) {
return {
success: false,
path: relativePath,
message: 'No workspace set',
requiresApproval: false
};
}
const fullPath = path.join(workspace, relativePath);
try {
const originalContent = await fsAsync.readFile(fullPath, 'utf8');
const newContent = this.applyChanges(originalContent, changes);
// Write modified file
await fsAsync.writeFile(fullPath, newContent, 'utf8');
// Log decision
const project = this.storage.getCurrentProject();
if (project) {
this.storage.addDecision({
projectId: project.id,
type: 'other',
description: `Modified file: ${relativePath}`,
reasoning: 'Changes applied by Claude with user approval'
});
}
return {
success: true,
path: relativePath,
message: `✅ Modified ${relativePath}`,
requiresApproval: false
};
} catch (error) {
return {
success: false,
path: relativePath,
message: `Error modifying file: ${error instanceof Error ? error.message : 'Unknown error'}`,
requiresApproval: false
};
}
}
/**
* Undo the last change to a file
*/
async undoChange(relativePath: string, steps: number = 1): Promise<FileWriteResult> {
const workspace = this.workspaceDetector.getCurrentWorkspace();
if (!workspace) {
return {
success: false,
path: relativePath,
message: 'No workspace set',
requiresApproval: false
};
}
const backups = this.undoStack.get(relativePath);
if (!backups || backups.length === 0) {
return {
success: false,
path: relativePath,
message: 'No undo history available for this file',
requiresApproval: false
};
}
if (steps > backups.length) {
steps = backups.length;
}
try {
// Get the backup to restore
const backup = backups[backups.length - steps];
const fullPath = path.join(workspace, relativePath);
// Restore the backup
await fsAsync.writeFile(fullPath, backup.content, 'utf8');
// Remove undone items from stack
backups.splice(-steps);
this.undoStack.set(relativePath, backups);
return {
success: true,
path: relativePath,
message: `✅ Reverted ${relativePath} (undid ${steps} change${steps > 1 ? 's' : ''})`,
requiresApproval: false
};
} catch (error) {
return {
success: false,
path: relativePath,
message: `Error undoing change: ${error instanceof Error ? error.message : 'Unknown error'}`,
requiresApproval: false
};
}
}
/**
* Delete a file
*/
async deleteFile(relativePath: string): Promise<FileWriteResult> {
const workspace = this.workspaceDetector.getCurrentWorkspace();
if (!workspace) {
return {
success: false,
path: relativePath,
message: 'No workspace set',
requiresApproval: false
};
}
const fullPath = path.join(workspace, relativePath);
try {
await fsAsync.access(fullPath);
} catch {
return {
success: false,
path: relativePath,
message: `File not found: ${relativePath}`,
requiresApproval: false
};
}
// Create backup before deletion
const content = await fsAsync.readFile(fullPath, 'utf8');
this.createBackup(relativePath, content);
const preview = `⚠️ WARNING: This will DELETE the file!\n\nFile: ${relativePath}\nSize: ${(Buffer.byteLength(content) / 1024).toFixed(1)}KB\n\nThis action can be undone with undo_file_change.`;
return {
success: true,
path: relativePath,
message: 'Ready to delete file. Confirm deletion.',
preview,
requiresApproval: true
};
}
/**
* Actually delete the file after approval
*/
async applyDeleteFile(relativePath: string): Promise<FileWriteResult> {
const workspace = this.workspaceDetector.getCurrentWorkspace();
if (!workspace) {
return {
success: false,
path: relativePath,
message: 'No workspace set',
requiresApproval: false
};
}
const fullPath = path.join(workspace, relativePath);
try {
fs.unlinkSync(fullPath);
return {
success: true,
path: relativePath,
message: `✅ Deleted ${relativePath}`,
requiresApproval: false
};
} catch (error) {
return {
success: false,
path: relativePath,
message: `Error deleting file: ${error instanceof Error ? error.message : 'Unknown error'}`,
requiresApproval: false
};
}
}
// ========== PRIVATE HELPER METHODS ==========
private validatePath(relativePath: string): { valid: boolean; error?: string } {
// Check for path traversal
if (relativePath.includes('..')) {
return { valid: false, error: 'Path traversal not allowed (..)' };
}
// Check for absolute paths
if (path.isAbsolute(relativePath)) {
return { valid: false, error: 'Must use relative paths, not absolute' };
}
// Check for forbidden directories
const forbidden = ['node_modules', '.git', 'dist', 'build', '.next', '.cache'];
for (const dir of forbidden) {
if (relativePath.startsWith(dir + '/') || relativePath === dir) {
return { valid: false, error: `Cannot modify ${dir} directory` };
}
}
// Check for system files (unless specifically allowed)
if (path.basename(relativePath).startsWith('.') && !this.isAllowedHiddenFile(relativePath)) {
return { valid: false, error: 'Cannot modify hidden files (unless configuration)' };
}
return { valid: true };
}
private isAllowedHiddenFile(relativePath: string): boolean {
const allowed = [
'.env.example',
'.gitignore',
'.eslintrc',
'.prettierrc',
'.editorconfig'
];
return allowed.some(file => relativePath.endsWith(file));
}
private createBackup(relativePath: string, content: string): void {
const backups = this.undoStack.get(relativePath) || [];
backups.push({
path: relativePath,
content,
timestamp: new Date()
});
// Keep only last MAX_UNDO_LEVELS backups
if (backups.length > this.MAX_UNDO_LEVELS) {
backups.shift();
}
this.undoStack.set(relativePath, backups);
}
private applyChanges(content: string, changes: FileChange[]): string {
let result = content;
for (const change of changes) {
switch (change.type) {
case 'replace':
if (change.oldText) {
result = result.replace(change.oldText, change.newText);
}
break;
case 'insert':
if (typeof change.line === 'number') {
const lines = result.split('\n');
lines.splice(change.line, 0, change.newText);
result = lines.join('\n');
}
break;
case 'delete':
if (typeof change.line === 'number') {
const lines = result.split('\n');
lines.splice(change.line, 1);
result = lines.join('\n');
}
break;
}
}
return result;
}
private generateCreatePreview(relativePath: string, content: string): string {
const lines = content.split('\n');
const size = Buffer.byteLength(content);
let preview = '📝 Preview: Create New File\n';
preview += '━'.repeat(60) + '\n';
preview += `File: ${relativePath}\n`;
preview += `Size: ${(size / 1024).toFixed(1)}KB\n`;
preview += `Lines: ${lines.length}\n`;
preview += '━'.repeat(60) + '\n';
// Show first 20 lines
const previewLines = lines.slice(0, 20);
preview += previewLines.join('\n');
if (lines.length > 20) {
preview += `\n\n... (${lines.length - 20} more lines)`;
}
preview += '\n' + '━'.repeat(60);
return preview;
}
private generateDiffPreview(
relativePath: string,
oldContent: string,
newContent: string
): string {
const oldLines = oldContent.split('\n');
const newLines = newContent.split('\n');
let preview = '📝 Changes Preview\n';
preview += '━'.repeat(60) + '\n';
preview += `File: ${relativePath}\n`;
preview += '━'.repeat(60) + '\n';
// Simple line-by-line diff
const maxLines = Math.max(oldLines.length, newLines.length);
let changeCount = 0;
for (let i = 0; i < maxLines && changeCount < 20; i++) {
const oldLine = oldLines[i];
const newLine = newLines[i];
if (oldLine !== newLine) {
if (oldLine !== undefined) {
preview += `- ${oldLine}\n`;
}
if (newLine !== undefined) {
preview += `+ ${newLine}\n`;
}
changeCount++;
} else if (changeCount > 0 && changeCount < 20) {
// Show context line
preview += ` ${oldLine}\n`;
}
}
if (changeCount >= 20) {
preview += '\n... (more changes below)';
}
preview += '\n' + '━'.repeat(60);
return preview;
}
}