ccundo
Version:
Intelligent undo for Claude Code sessions - Revert individual operations with cascading safety and detailed previews
213 lines (189 loc) • 6.81 kB
JavaScript
import fs from 'fs/promises';
import path from 'path';
import { createReadStream } from 'fs';
import { createInterface } from 'readline';
import { Operation, OperationType } from './Operation.js';
import { UndoTracker } from './UndoTracker.js';
export class ClaudeSessionParser {
constructor() {
this.claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
}
async getCurrentProjectDir() {
const cwd = process.cwd();
// Replace all forward slashes, spaces, and underscores with dashes
const safePath = cwd.replace(/[\s\/_]/g, '-');
return path.join(this.claudeProjectsDir, safePath);
}
async getCurrentSessionFile() {
const projectDir = await this.getCurrentProjectDir();
try {
const files = await fs.readdir(projectDir);
const sessionFiles = files.filter(f => f.endsWith('.jsonl'));
if (sessionFiles.length === 0) return null;
// Get the most recently modified session file
const stats = await Promise.all(
sessionFiles.map(async f => ({
file: f,
path: path.join(projectDir, f),
mtime: (await fs.stat(path.join(projectDir, f))).mtime
}))
);
stats.sort((a, b) => b.mtime - a.mtime);
return stats[0].path;
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
async parseSessionFile(sessionFile) {
const operations = [];
const fileStream = createReadStream(sessionFile);
const rl = createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
try {
const entry = JSON.parse(line);
// Look for tool use messages
if (entry.type === 'assistant' && entry.message?.content) {
for (const content of entry.message.content) {
if (content.type === 'tool_use') {
const operation = this.extractOperation(content, entry.timestamp);
if (operation) {
operations.push(operation);
}
}
}
}
} catch (e) {
// Skip invalid JSON lines
}
}
// Filter out operations that have already been undone
const undoTracker = new UndoTracker();
await undoTracker.init();
const filteredOperations = await undoTracker.filterUndoneOperations(operations, sessionFile);
return filteredOperations;
}
extractOperation(toolUse, timestamp) {
const { name, input } = toolUse;
switch (name) {
case 'Write':
if (input.file_path) {
const op = new Operation(OperationType.FILE_CREATE, {
filePath: input.file_path,
content: input.content || ''
});
op.timestamp = new Date(timestamp);
op.id = toolUse.id;
return op;
}
break;
case 'Edit':
if (input.file_path) {
const op = new Operation(OperationType.FILE_EDIT, {
filePath: input.file_path,
// Note: We only have the string that was replaced, not the full file content
// This will be handled differently in UndoManager
oldString: input.old_string || '',
newString: input.new_string || '',
replaceAll: input.replace_all || false
});
op.timestamp = new Date(timestamp);
op.id = toolUse.id;
return op;
}
break;
case 'MultiEdit':
if (input.file_path) {
// For MultiEdit, we have multiple edits but still just the strings
const op = new Operation(OperationType.FILE_EDIT, {
filePath: input.file_path,
edits: input.edits || [],
isMultiEdit: true
});
op.timestamp = new Date(timestamp);
op.id = toolUse.id;
return op;
}
break;
case 'Bash':
if (input.command) {
const command = input.command;
// Try to detect file operations in bash commands
if (command.includes('rm ') && !command.includes('rmdir')) {
const match = command.match(/rm\s+(?:-[rf]+\s+)?([^\s]+)/);
if (match) {
const op = new Operation(OperationType.FILE_DELETE, {
filePath: match[1],
content: '' // We can't recover content from session history
});
op.timestamp = new Date(timestamp);
op.id = toolUse.id;
return op;
}
} else if (command.includes('mv ')) {
const match = command.match(/mv\s+([^\s]+)\s+([^\s]+)/);
if (match) {
const op = new Operation(OperationType.FILE_RENAME, {
oldPath: match[1],
newPath: match[2]
});
op.timestamp = new Date(timestamp);
op.id = toolUse.id;
return op;
}
} else if (command.includes('mkdir')) {
const match = command.match(/mkdir\s+(?:-p\s+)?([^\s]+)/);
if (match) {
const op = new Operation(OperationType.DIRECTORY_CREATE, {
dirPath: match[1]
});
op.timestamp = new Date(timestamp);
op.id = toolUse.id;
return op;
}
} else {
// Generic bash command
const op = new Operation(OperationType.BASH_COMMAND, {
command: command
});
op.timestamp = new Date(timestamp);
op.id = toolUse.id;
return op;
}
}
break;
}
return null;
}
async getAllSessions() {
try {
const projectDirs = await fs.readdir(this.claudeProjectsDir);
const sessions = [];
for (const projectDir of projectDirs) {
const fullPath = path.join(this.claudeProjectsDir, projectDir);
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
const files = await fs.readdir(fullPath);
const sessionFiles = files.filter(f => f.endsWith('.jsonl'));
for (const sessionFile of sessionFiles) {
const sessionId = sessionFile.replace('.jsonl', '');
// Convert back to proper path - first dash is root, rest are slashes
const projectPath = projectDir.substring(1).replace(/-/g, '/');
sessions.push({
id: sessionId,
project: '/' + projectPath,
file: path.join(fullPath, sessionFile)
});
}
}
}
return sessions;
} catch (error) {
if (error.code === 'ENOENT') return [];
throw error;
}
}
}