ccundo
Version:
Intelligent undo for Claude Code sessions - Revert individual operations with cascading safety and detailed previews
606 lines (501 loc) • 21 kB
JavaScript
import { Command } from 'commander';
import chalk from 'chalk';
import inquirer from 'inquirer';
import { SessionTracker } from '../src/core/SessionTracker.js';
import { UndoManager } from '../src/core/UndoManager.js';
import { formatDistance } from '../src/utils/formatting.js';
import { Operation, OperationType } from '../src/core/Operation.js';
import { ClaudeSessionParser } from '../src/core/ClaudeSessionParser.js';
import { OperationPreview } from '../src/core/OperationPreview.js';
import { i18n } from '../src/i18n/i18n.js';
import { UndoTracker } from '../src/core/UndoTracker.js';
import { RedoManager } from '../src/core/RedoManager.js';
// Initialize i18n
await i18n.init();
const program = new Command();
program
.name('ccundo')
.description('Undo individual steps performed by Claude Code within a session')
.version('1.1.1');
program
.command('list')
.description(i18n.t('cmd.list.description'))
.option('-a, --all', i18n.t('opt.all'))
.option('-s, --session <id>', i18n.t('opt.session'))
.option('--claude', i18n.t('opt.claude'), true)
.option('--local', i18n.t('opt.local'))
.action(async (options) => {
try {
let operations = [];
if (!options.local) {
// Default: Read from Claude Code session
const parser = new ClaudeSessionParser();
const sessionFile = await parser.getCurrentSessionFile();
if (!sessionFile) {
console.log(chalk.yellow('No active Claude Code session found in this directory.'));
console.log(chalk.gray('Make sure you are in a directory where Claude Code has been used.'));
return;
}
operations = await parser.parseSessionFile(sessionFile);
console.log(chalk.bold(`\nOperations from Claude Code session:\n`));
} else {
// Use local ccundo tracking
const sessionId = options.session || await SessionTracker.getCurrentSession();
if (!sessionId) {
console.log(chalk.yellow('No local ccundo session found.'));
return;
}
const tracker = new SessionTracker(sessionId);
await tracker.init();
operations = await tracker.getOperations(options.all);
console.log(chalk.bold(`\nOperations in local session ${sessionId}:\n`));
}
if (operations.length === 0) {
console.log(chalk.yellow('No operations found.'));
return;
}
operations.forEach((op, index) => {
const status = op.undone ? chalk.red('[UNDONE]') : chalk.green('[ACTIVE]');
const time = formatDistance(op.timestamp);
console.log(`${index + 1}. ${status} ${chalk.cyan(op.type)} - ${time}`);
console.log(` ID: ${op.id}`);
switch (op.type) {
case 'file_create':
case 'file_edit':
case 'file_delete':
console.log(` File: ${op.data.filePath}`);
break;
case 'file_rename':
console.log(` From: ${op.data.oldPath}`);
console.log(` To: ${op.data.newPath}`);
break;
case 'directory_create':
case 'directory_delete':
console.log(` Directory: ${op.data.dirPath}`);
break;
case 'bash_command':
console.log(` Command: ${op.data.command}`);
break;
}
console.log('');
});
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
}
});
program
.command('undo [operation-id]')
.description('Undo operations from the current Claude Code session')
.option('-s, --session <id>', 'Specify session ID')
.option('-y, --yes', 'Skip confirmation')
.option('--local', 'Use local ccundo tracking instead of Claude sessions')
.action(async (operationId, options) => {
try {
let operations = [];
let sessionFile = null;
if (options.local) {
// Use local ccundo tracking
const sessionId = options.session || await SessionTracker.getCurrentSession();
if (!sessionId) {
console.log(chalk.yellow('No local ccundo session found.'));
return;
}
const tracker = new SessionTracker(sessionId);
await tracker.init();
operations = await tracker.getOperations();
} else {
// Use Claude Code sessions
const parser = new ClaudeSessionParser();
sessionFile = await parser.getCurrentSessionFile();
if (!sessionFile) {
console.log(chalk.yellow('No active Claude Code session found in this directory.'));
return;
}
operations = await parser.parseSessionFile(sessionFile);
}
if (operations.length === 0) {
console.log(chalk.yellow('No operations to undo.'));
return;
}
// Reverse operations so most recent is first
operations.reverse();
let selectedIndex = 0;
if (!operationId) {
const choices = operations.map((op, index) => {
const operationsToUndo = index + 1;
let name = `${op.type} - ${formatDistance(op.timestamp)}`;
if (operationsToUndo > 1) {
name += chalk.red(` (+ ${operationsToUndo - 1} more will be undone)`);
}
return {
name: name,
value: index,
short: `${op.type} (${operationsToUndo} ops)`
};
});
console.log(chalk.yellow('\\n⚠️ Cascading undo: Selecting an operation will undo it and ALL operations that came after it.\\n'));
const answer = await inquirer.prompt([{
type: 'list',
name: 'selectedIndex',
message: 'Select operation to undo:',
choices: choices,
pageSize: 15
}]);
selectedIndex = answer.selectedIndex;
} else {
selectedIndex = operations.findIndex(op => op.id === operationId);
if (selectedIndex === -1) {
console.log(chalk.red(`Operation ${operationId} not found.`));
return;
}
}
const operationsToUndo = operations.slice(0, selectedIndex + 1);
if (!options.yes) {
console.log(chalk.yellow(`\\nThis will undo ${operationsToUndo.length} operation(s):\\n`));
for (let i = 0; i < operationsToUndo.length; i++) {
const op = operationsToUndo[i];
console.log(`${chalk.bold(`${i + 1}.`)} ${chalk.cyan(op.type)} - ${formatDistance(op.timestamp)}`);
const preview = await OperationPreview.generatePreview(op);
console.log(` ${preview.preview.replace(/\\n/g, '\\n ')}`);
console.log('');
}
const confirm = await inquirer.prompt([{
type: 'confirm',
name: 'proceed',
message: `Are you sure you want to undo these ${operationsToUndo.length} operations?`,
default: false
}]);
if (!confirm.proceed) {
console.log(chalk.yellow('Undo cancelled.'));
return;
}
}
const undoManager = new UndoManager();
await undoManager.init();
const undoTracker = new UndoTracker();
await undoTracker.init();
console.log(chalk.cyan(`\\nUndoing ${operationsToUndo.length} operations...\\n`));
let successCount = 0;
let failCount = 0;
for (const operation of operationsToUndo) {
const result = await undoManager.undo(operation);
if (result.success) {
successCount++;
console.log(chalk.green(`✓ ${result.message}`));
if (result.backupPath) {
console.log(chalk.gray(` Backup saved to: ${result.backupPath}`));
}
// Mark operation as undone if using Claude Code sessions
if (sessionFile) {
await undoTracker.markAsUndone(operation.id, sessionFile);
}
} else {
failCount++;
console.log(chalk.red(`✗ ${result.message}`));
}
}
console.log(chalk.bold(`\\nCompleted: ${chalk.green(successCount)} successful, ${chalk.red(failCount)} failed`));
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
}
});
program
.command('redo [operation-id]')
.description(i18n.t('cmd.redo.description'))
.option('-s, --session <id>', i18n.t('opt.session'))
.option('-y, --yes', i18n.t('opt.yes'))
.option('--local', 'Use local ccundo tracking instead of Claude sessions')
.action(async (operationId, options) => {
try {
let operations = [];
let sessionFile = null;
if (options.local) {
// Use local ccundo tracking
const sessionId = options.session || await SessionTracker.getCurrentSession();
if (!sessionId) {
console.log(chalk.yellow('No local ccundo session found.'));
return;
}
const tracker = new SessionTracker(sessionId);
await tracker.init();
// For local tracking, we'd need to implement redo tracking
console.log(chalk.yellow('Redo for local tracking is not yet implemented.'));
return;
} else {
// Use Claude Code sessions
const parser = new ClaudeSessionParser();
sessionFile = await parser.getCurrentSessionFile();
if (!sessionFile) {
console.log(chalk.yellow('No active Claude Code session found in this directory.'));
return;
}
// Get all operations first
const allOperations = await parser.parseSessionFile(sessionFile);
// Then get undone operations by temporarily disabling filtering
const undoTracker = new UndoTracker();
await undoTracker.init();
// Get the original operations without filtering by calling parser method directly
const parser2 = new ClaudeSessionParser();
const originalOperations = [];
// We need to parse the session file without the undo filtering
const { createReadStream } = await import('fs');
const { createInterface } = await import('readline');
const fileStream = createReadStream(sessionFile);
const rl = createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
try {
const entry = JSON.parse(line);
if (entry.type === 'assistant' && entry.message?.content) {
for (const content of entry.message.content) {
if (content.type === 'tool_use') {
const operation = parser2.extractOperation(content, entry.timestamp);
if (operation) {
originalOperations.push(operation);
}
}
}
}
} catch (e) {
// Skip invalid JSON lines
}
}
operations = await undoTracker.getUndoneOperationsList(originalOperations, sessionFile);
}
if (operations.length === 0) {
console.log(chalk.yellow(i18n.t('msg.no_operations_to_redo')));
return;
}
// Operations are already in reverse order (most recent undo first)
let selectedIndex = 0;
if (!operationId) {
const choices = operations.map((op, index) => {
const operationsToRedo = index + 1;
let name = `${op.type} - ${formatDistance(op.timestamp)}`;
if (operationsToRedo > 1) {
name += chalk.green(` (+ ${operationsToRedo - 1} more will be redone)`);
}
return {
name: name,
value: index,
short: `${op.type} (${operationsToRedo} ops)`
};
});
console.log(chalk.yellow('\\n⚠️ Cascading redo: Selecting an operation will redo it and ALL undone operations that came before it.\\n'));
const answer = await inquirer.prompt([{
type: 'list',
name: 'selectedIndex',
message: i18n.t('prompt.select_operation_redo'),
choices: choices,
pageSize: 15
}]);
selectedIndex = answer.selectedIndex;
} else {
selectedIndex = operations.findIndex(op => op.id === operationId);
if (selectedIndex === -1) {
console.log(chalk.red(`Operation ${operationId} not found.`));
return;
}
}
const operationsToRedo = operations.slice(0, selectedIndex + 1);
if (!options.yes) {
console.log(chalk.yellow(`\\n${i18n.t('header.this_will_redo', { count: operationsToRedo.length })}\\n`));
for (let i = 0; i < operationsToRedo.length; i++) {
const op = operationsToRedo[i];
console.log(`${chalk.bold(`${i + 1}.`)} ${chalk.cyan(op.type)} - ${formatDistance(op.timestamp)}`);
console.log('');
}
const confirm = await inquirer.prompt([{
type: 'confirm',
name: 'proceed',
message: i18n.t('prompt.confirm_redo', { count: operationsToRedo.length }),
default: false
}]);
if (!confirm.proceed) {
console.log(chalk.yellow('Redo cancelled.'));
return;
}
}
const redoManager = new RedoManager();
await redoManager.init();
const undoTracker = new UndoTracker();
await undoTracker.init();
console.log(chalk.cyan(`\\n${i18n.t('header.redoing', { count: operationsToRedo.length })}\\n`));
let successCount = 0;
let failCount = 0;
// Redo operations in reverse order (oldest undone operation first)
for (const operation of operationsToRedo.reverse()) {
const result = await redoManager.redo(operation);
if (result.success) {
successCount++;
console.log(chalk.green(`✓ ${result.message}`));
if (result.backupPath) {
console.log(chalk.gray(` Backup saved to: ${result.backupPath}`));
}
// Mark operation as redone (remove from undone list)
if (sessionFile) {
await undoTracker.markAsRedone(operation.id, sessionFile);
}
} else {
failCount++;
console.log(chalk.red(`✗ ${result.message}`));
}
}
console.log(chalk.bold(`\\nCompleted: ${chalk.green(successCount)} successful, ${chalk.red(failCount)} failed`));
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
}
});
program
.command('preview [operation-id]')
.description('Preview what would be undone without making changes')
.option('-s, --session <id>', 'Specify session ID')
.option('--local', 'Use local ccundo tracking instead of Claude sessions')
.action(async (operationId, options) => {
try {
let operations = [];
if (options.local) {
const sessionId = options.session || await SessionTracker.getCurrentSession();
if (!sessionId) {
console.log(chalk.yellow('No local ccundo session found.'));
return;
}
const tracker = new SessionTracker(sessionId);
await tracker.init();
operations = await tracker.getOperations();
} else {
const parser = new ClaudeSessionParser();
const sessionFile = await parser.getCurrentSessionFile();
if (!sessionFile) {
console.log(chalk.yellow('No active Claude Code session found in this directory.'));
return;
}
operations = await parser.parseSessionFile(sessionFile);
}
if (operations.length === 0) {
console.log(chalk.yellow('No operations found.'));
return;
}
operations.reverse();
let selectedIndex = 0;
if (!operationId) {
const choices = operations.map((op, index) => {
const operationsToUndo = index + 1;
let name = `${op.type} - ${formatDistance(op.timestamp)}`;
if (operationsToUndo > 1) {
name += chalk.gray(` (+ ${operationsToUndo - 1} more would be undone)`);
}
return {
name: name,
value: index
};
});
const answer = await inquirer.prompt([{
type: 'list',
name: 'selectedIndex',
message: 'Select operation to preview:',
choices: choices,
pageSize: 15
}]);
selectedIndex = answer.selectedIndex;
} else {
selectedIndex = operations.findIndex(op => op.id === operationId);
if (selectedIndex === -1) {
console.log(chalk.red(`Operation ${operationId} not found.`));
return;
}
}
const operationsToUndo = operations.slice(0, selectedIndex + 1);
console.log(chalk.blue(`\\n📋 Preview: Would undo ${operationsToUndo.length} operation(s):\\n`));
for (let i = 0; i < operationsToUndo.length; i++) {
const op = operationsToUndo[i];
console.log(`${chalk.bold(`${i + 1}.`)} ${chalk.cyan(op.type)} - ${formatDistance(op.timestamp)}`);
const preview = await OperationPreview.generatePreview(op);
console.log(` ${preview.preview.replace(/\\n/g, '\\n ')}`);
console.log('');
}
console.log(chalk.gray('💡 To actually perform these undos, run: ccundo undo'));
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
}
});
program
.command('sessions')
.description('List all available Claude Code sessions')
.option('--local', 'Show local ccundo sessions instead of Claude sessions')
.action(async (options) => {
try {
if (options.local) {
const sessions = await SessionTracker.listSessions();
const currentSession = await SessionTracker.getCurrentSession();
if (sessions.length === 0) {
console.log(chalk.yellow('No local sessions found.'));
return;
}
console.log(chalk.bold('\nAvailable local sessions:\n'));
sessions.forEach(session => {
const isCurrent = session === currentSession;
const marker = isCurrent ? chalk.green('→ ') : ' ';
console.log(`${marker}${session}`);
});
} else {
const parser = new ClaudeSessionParser();
const sessions = await parser.getAllSessions();
if (sessions.length === 0) {
console.log(chalk.yellow('No Claude Code sessions found.'));
return;
}
console.log(chalk.bold('\nAvailable Claude Code sessions:\n'));
const currentDir = process.cwd();
sessions.forEach(session => {
const isCurrent = session.project === currentDir;
const marker = isCurrent ? chalk.green('→ ') : ' ';
console.log(`${marker}${chalk.cyan(session.id)}`);
console.log(` Project: ${session.project}`);
});
}
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
}
});
program
.command('session <id>')
.description(i18n.t('cmd.session.description'))
.action(async (sessionId) => {
try {
await SessionTracker.setCurrentSession(sessionId);
console.log(chalk.green(`Switched to session: ${sessionId}`));
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
}
});
program
.command('language [lang]')
.description(i18n.t('cmd.language.description'))
.action(async (lang) => {
try {
if (!lang) {
// Show current language and available options
const current = i18n.getCurrentLanguage();
const available = i18n.getAvailableLanguages();
console.log(chalk.bold(`\\nCurrent language: ${chalk.cyan(current.name)} (${current.code})\\n`));
console.log(chalk.bold('Available languages:'));
available.forEach(({ code, name }) => {
const marker = code === current.code ? chalk.green('→ ') : ' ';
console.log(`${marker}${code} - ${name}`);
});
console.log(chalk.gray('\\nUsage: ccundo language <code>'));
return;
}
await i18n.setLanguage(lang);
const newLang = i18n.getCurrentLanguage();
console.log(chalk.green(i18n.t('msg.language_set', { language: newLang.name })));
} catch (error) {
const available = i18n.getAvailableLanguages().map(l => l.code).join(', ');
console.error(chalk.red(i18n.t('msg.language_invalid', { languages: available })));
}
});
program.parse(process.argv);