@sooink/ai-session-tidy 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +7 -0
- package/LICENSE +21 -0
- package/README.ko.md +171 -0
- package/README.md +169 -0
- package/assets/demo-interactive.gif +0 -0
- package/assets/demo.gif +0 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1917 -0
- package/dist/index.js.map +1 -0
- package/eslint.config.js +29 -0
- package/package.json +54 -0
- package/src/cli.ts +21 -0
- package/src/commands/clean.ts +335 -0
- package/src/commands/config.ts +144 -0
- package/src/commands/list.ts +86 -0
- package/src/commands/scan.ts +200 -0
- package/src/commands/watch.ts +359 -0
- package/src/core/cleaner.test.ts +125 -0
- package/src/core/cleaner.ts +181 -0
- package/src/core/service.ts +236 -0
- package/src/core/trash.test.ts +100 -0
- package/src/core/trash.ts +40 -0
- package/src/core/watcher.test.ts +210 -0
- package/src/core/watcher.ts +194 -0
- package/src/index.ts +5 -0
- package/src/scanners/claude-code.test.ts +112 -0
- package/src/scanners/claude-code.ts +452 -0
- package/src/scanners/cursor.test.ts +140 -0
- package/src/scanners/cursor.ts +133 -0
- package/src/scanners/index.ts +39 -0
- package/src/scanners/types.ts +34 -0
- package/src/utils/config.ts +132 -0
- package/src/utils/logger.ts +29 -0
- package/src/utils/paths.test.ts +95 -0
- package/src/utils/paths.ts +92 -0
- package/src/utils/size.test.ts +80 -0
- package/src/utils/size.ts +50 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +11 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
import { formatSize } from '../utils/size.js';
|
|
8
|
+
import {
|
|
9
|
+
createAllScanners,
|
|
10
|
+
getAvailableScanners,
|
|
11
|
+
runAllScanners,
|
|
12
|
+
} from '../scanners/index.js';
|
|
13
|
+
import type { ScanResult } from '../scanners/types.js';
|
|
14
|
+
|
|
15
|
+
interface ScanOptions {
|
|
16
|
+
verbose?: boolean;
|
|
17
|
+
json?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatTokens(count?: number): string {
|
|
21
|
+
if (!count) return '0';
|
|
22
|
+
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
23
|
+
if (count >= 1000) return `${(count / 1000).toFixed(0)}K`;
|
|
24
|
+
return count.toString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const scanCommand = new Command('scan')
|
|
28
|
+
.description('Scan for orphaned session data from AI coding tools')
|
|
29
|
+
.option('-v, --verbose', 'Enable verbose output')
|
|
30
|
+
.option('--json', 'Output results as JSON')
|
|
31
|
+
.action(async (options: ScanOptions) => {
|
|
32
|
+
const spinner = ora('Scanning for orphaned sessions...').start();
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const allScanners = createAllScanners();
|
|
36
|
+
const availableScanners = await getAvailableScanners(allScanners);
|
|
37
|
+
|
|
38
|
+
if (availableScanners.length === 0) {
|
|
39
|
+
spinner.warn('No AI coding tools detected on this system.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (options.verbose) {
|
|
44
|
+
spinner.text = `Found ${availableScanners.length} tools: ${availableScanners.map((s) => s.name).join(', ')}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const results = await runAllScanners(availableScanners);
|
|
48
|
+
spinner.stop();
|
|
49
|
+
|
|
50
|
+
if (options.json) {
|
|
51
|
+
outputJson(results);
|
|
52
|
+
} else {
|
|
53
|
+
outputTable(results, options.verbose);
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
spinner.fail('Scan failed');
|
|
57
|
+
logger.error(
|
|
58
|
+
error instanceof Error ? error.message : 'Unknown error occurred'
|
|
59
|
+
);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function outputJson(results: ScanResult[]): void {
|
|
65
|
+
const allSessions = results.flatMap((r) => r.sessions);
|
|
66
|
+
const output = {
|
|
67
|
+
totalSessions: allSessions.length,
|
|
68
|
+
totalSize: results.reduce((sum, r) => sum + r.totalSize, 0),
|
|
69
|
+
results: results.map((r) => ({
|
|
70
|
+
tool: r.toolName,
|
|
71
|
+
sessionCount: r.sessions.length,
|
|
72
|
+
totalSize: r.totalSize,
|
|
73
|
+
scanDuration: r.scanDuration,
|
|
74
|
+
sessions: r.sessions,
|
|
75
|
+
})),
|
|
76
|
+
};
|
|
77
|
+
console.log(JSON.stringify(output, null, 2));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function outputTable(results: ScanResult[], verbose?: boolean): void {
|
|
81
|
+
const allSessions = results.flatMap((r) => r.sessions);
|
|
82
|
+
const folderSessions = allSessions.filter((s) => s.type === 'session' || s.type === undefined);
|
|
83
|
+
const configEntries = allSessions.filter((s) => s.type === 'config');
|
|
84
|
+
const sessionEnvEntries = allSessions.filter((s) => s.type === 'session-env');
|
|
85
|
+
const todosEntries = allSessions.filter((s) => s.type === 'todos');
|
|
86
|
+
const fileHistoryEntries = allSessions.filter((s) => s.type === 'file-history');
|
|
87
|
+
const totalSize = results.reduce((sum, r) => sum + r.totalSize, 0);
|
|
88
|
+
|
|
89
|
+
if (allSessions.length === 0) {
|
|
90
|
+
logger.success('No orphaned sessions found.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log();
|
|
95
|
+
|
|
96
|
+
// Summary message
|
|
97
|
+
const parts: string[] = [];
|
|
98
|
+
if (folderSessions.length > 0) {
|
|
99
|
+
parts.push(`${folderSessions.length} session folder(s)`);
|
|
100
|
+
}
|
|
101
|
+
if (configEntries.length > 0) {
|
|
102
|
+
parts.push(`${configEntries.length} config entry(ies)`);
|
|
103
|
+
}
|
|
104
|
+
if (sessionEnvEntries.length > 0) {
|
|
105
|
+
parts.push(`${sessionEnvEntries.length} session-env folder(s)`);
|
|
106
|
+
}
|
|
107
|
+
if (todosEntries.length > 0) {
|
|
108
|
+
parts.push(`${todosEntries.length} todos file(s)`);
|
|
109
|
+
}
|
|
110
|
+
if (fileHistoryEntries.length > 0) {
|
|
111
|
+
parts.push(`${fileHistoryEntries.length} file-history folder(s)`);
|
|
112
|
+
}
|
|
113
|
+
logger.warn(`Found ${parts.join(' + ')} (${formatSize(totalSize)})`);
|
|
114
|
+
console.log();
|
|
115
|
+
|
|
116
|
+
// Summary by tool
|
|
117
|
+
const summaryTable = new Table({
|
|
118
|
+
head: [
|
|
119
|
+
chalk.cyan('Tool'),
|
|
120
|
+
chalk.cyan('Sessions'),
|
|
121
|
+
chalk.cyan('Config'),
|
|
122
|
+
chalk.cyan('Env'),
|
|
123
|
+
chalk.cyan('Todos'),
|
|
124
|
+
chalk.cyan('History'),
|
|
125
|
+
chalk.cyan('Size'),
|
|
126
|
+
chalk.cyan('Scan Time'),
|
|
127
|
+
],
|
|
128
|
+
style: { head: [] },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
for (const result of results) {
|
|
132
|
+
if (result.sessions.length > 0) {
|
|
133
|
+
const folders = result.sessions.filter((s) => s.type === 'session' || s.type === undefined).length;
|
|
134
|
+
const configs = result.sessions.filter((s) => s.type === 'config').length;
|
|
135
|
+
const envs = result.sessions.filter((s) => s.type === 'session-env').length;
|
|
136
|
+
const todos = result.sessions.filter((s) => s.type === 'todos').length;
|
|
137
|
+
const histories = result.sessions.filter((s) => s.type === 'file-history').length;
|
|
138
|
+
summaryTable.push([
|
|
139
|
+
result.toolName,
|
|
140
|
+
folders > 0 ? String(folders) : '-',
|
|
141
|
+
configs > 0 ? String(configs) : '-',
|
|
142
|
+
envs > 0 ? String(envs) : '-',
|
|
143
|
+
todos > 0 ? String(todos) : '-',
|
|
144
|
+
histories > 0 ? String(histories) : '-',
|
|
145
|
+
formatSize(result.totalSize),
|
|
146
|
+
`${result.scanDuration.toFixed(0)}ms`,
|
|
147
|
+
]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(summaryTable.toString());
|
|
152
|
+
|
|
153
|
+
// Detailed info (verbose)
|
|
154
|
+
if (verbose && allSessions.length > 0) {
|
|
155
|
+
// Session folders
|
|
156
|
+
if (folderSessions.length > 0) {
|
|
157
|
+
console.log();
|
|
158
|
+
console.log(chalk.bold('Session Folders:'));
|
|
159
|
+
console.log();
|
|
160
|
+
|
|
161
|
+
for (const session of folderSessions) {
|
|
162
|
+
const projectName = session.projectPath.split('/').pop() || session.projectPath;
|
|
163
|
+
console.log(
|
|
164
|
+
` ${chalk.cyan(`[${session.toolName}]`)} ${chalk.white(projectName)} ${chalk.dim(`(${formatSize(session.size)})`)}`
|
|
165
|
+
);
|
|
166
|
+
console.log(` ${chalk.dim('→')} ${session.projectPath}`);
|
|
167
|
+
console.log(` ${chalk.dim('Modified:')} ${session.lastModified.toLocaleDateString()}`);
|
|
168
|
+
console.log();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Config entries
|
|
173
|
+
if (configEntries.length > 0) {
|
|
174
|
+
console.log();
|
|
175
|
+
console.log(chalk.bold('Config Entries (~/.claude.json):'));
|
|
176
|
+
console.log();
|
|
177
|
+
|
|
178
|
+
for (const entry of configEntries) {
|
|
179
|
+
const projectName = entry.projectPath.split('/').pop() || entry.projectPath;
|
|
180
|
+
console.log(
|
|
181
|
+
` ${chalk.yellow('[config]')} ${chalk.white(projectName)}`
|
|
182
|
+
);
|
|
183
|
+
console.log(` ${chalk.dim('→')} ${entry.projectPath}`);
|
|
184
|
+
if (entry.configStats?.lastCost) {
|
|
185
|
+
const cost = `$${entry.configStats.lastCost.toFixed(2)}`;
|
|
186
|
+
const inTokens = formatTokens(entry.configStats.lastTotalInputTokens);
|
|
187
|
+
const outTokens = formatTokens(entry.configStats.lastTotalOutputTokens);
|
|
188
|
+
console.log(` ${chalk.dim(`Cost: ${cost} | Tokens: ${inTokens} in / ${outTokens} out`)}`);
|
|
189
|
+
}
|
|
190
|
+
console.log();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log();
|
|
197
|
+
console.log(
|
|
198
|
+
chalk.dim('Run "ai-session-tidy clean" to remove orphaned sessions.')
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join, resolve } from 'path';
|
|
6
|
+
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
import { formatSize } from '../utils/size.js';
|
|
9
|
+
import { getWatchPaths as getConfigWatchPaths, setWatchPaths, getWatchDelay, getWatchDepth } from '../utils/config.js';
|
|
10
|
+
import {
|
|
11
|
+
createAllScanners,
|
|
12
|
+
getAvailableScanners,
|
|
13
|
+
runAllScanners,
|
|
14
|
+
} from '../scanners/index.js';
|
|
15
|
+
import { Watcher } from '../core/watcher.js';
|
|
16
|
+
import { Cleaner } from '../core/cleaner.js';
|
|
17
|
+
import { serviceManager } from '../core/service.js';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_DELAY_MINUTES = 5;
|
|
20
|
+
const MAX_DELAY_MINUTES = 10;
|
|
21
|
+
|
|
22
|
+
interface RunOptions {
|
|
23
|
+
delay?: string;
|
|
24
|
+
path?: string[];
|
|
25
|
+
noSave?: boolean;
|
|
26
|
+
noTrash?: boolean;
|
|
27
|
+
verbose?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const watchCommand = new Command('watch')
|
|
31
|
+
.description('Watch for project deletions and auto-clean orphaned sessions');
|
|
32
|
+
|
|
33
|
+
// Run subcommand (default) - foreground execution
|
|
34
|
+
const runCommand = new Command('run')
|
|
35
|
+
.description('Run watcher in foreground (default)')
|
|
36
|
+
.option(
|
|
37
|
+
'-p, --path <path>',
|
|
38
|
+
'Path to watch (can be used multiple times, saves to config)',
|
|
39
|
+
(value: string, previous: string[]) => previous.concat([value]),
|
|
40
|
+
[] as string[]
|
|
41
|
+
)
|
|
42
|
+
.option('--no-save', 'Do not save paths to config')
|
|
43
|
+
.option(
|
|
44
|
+
'-d, --delay <minutes>',
|
|
45
|
+
`Delay before cleanup (default: ${DEFAULT_DELAY_MINUTES}, max: ${MAX_DELAY_MINUTES} minutes)`
|
|
46
|
+
)
|
|
47
|
+
.option('--no-trash', 'Permanently delete instead of moving to trash')
|
|
48
|
+
.option('-v, --verbose', 'Enable verbose output')
|
|
49
|
+
.action(runWatcher);
|
|
50
|
+
|
|
51
|
+
// Start subcommand - install and start as OS service
|
|
52
|
+
const startCommand = new Command('start')
|
|
53
|
+
.description('Start watcher as OS service (background + auto-start at login)')
|
|
54
|
+
.action(async () => {
|
|
55
|
+
const supported = serviceManager.isSupported();
|
|
56
|
+
if (!supported) {
|
|
57
|
+
logger.error('Service management is only supported on macOS.');
|
|
58
|
+
logger.info('Use "watch run" to run the watcher in foreground.');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Check current status
|
|
64
|
+
const currentStatus = await serviceManager.status();
|
|
65
|
+
if (currentStatus.status === 'running') {
|
|
66
|
+
logger.info('Watcher service is already running.');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Install and start
|
|
71
|
+
logger.info('Installing watcher service...');
|
|
72
|
+
await serviceManager.install();
|
|
73
|
+
|
|
74
|
+
logger.info('Starting watcher service...');
|
|
75
|
+
await serviceManager.start();
|
|
76
|
+
|
|
77
|
+
// Verify
|
|
78
|
+
const status = await serviceManager.status();
|
|
79
|
+
if (status.status === 'running') {
|
|
80
|
+
logger.success(`Watcher service started (PID: ${status.pid})`);
|
|
81
|
+
logger.info('The watcher will automatically start at login.');
|
|
82
|
+
logger.info(`Logs: ~/.ai-session-tidy/watcher.log`);
|
|
83
|
+
} else {
|
|
84
|
+
logger.warn('Service installed but may not be running yet.');
|
|
85
|
+
logger.info('Check status with: ai-session-tidy watch status');
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
logger.error(`Failed to start service: ${error instanceof Error ? error.message : String(error)}`);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Stop subcommand - stop and uninstall OS service
|
|
93
|
+
const stopCommand = new Command('stop')
|
|
94
|
+
.description('Stop watcher service and disable auto-start')
|
|
95
|
+
.action(async () => {
|
|
96
|
+
const supported = serviceManager.isSupported();
|
|
97
|
+
if (!supported) {
|
|
98
|
+
logger.error('Service management is only supported on macOS.');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const currentStatus = await serviceManager.status();
|
|
104
|
+
if (currentStatus.status === 'not_installed') {
|
|
105
|
+
logger.info('Watcher service is not installed.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
logger.info('Stopping watcher service...');
|
|
110
|
+
await serviceManager.stop();
|
|
111
|
+
|
|
112
|
+
logger.info('Removing service configuration...');
|
|
113
|
+
await serviceManager.uninstall();
|
|
114
|
+
|
|
115
|
+
logger.success('Watcher service stopped and removed.');
|
|
116
|
+
} catch (error) {
|
|
117
|
+
logger.error(`Failed to stop service: ${error instanceof Error ? error.message : String(error)}`);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Status subcommand - show service status
|
|
122
|
+
const statusCommand = new Command('status')
|
|
123
|
+
.description('Show watcher service status')
|
|
124
|
+
.option('-l, --logs [lines]', 'Show recent logs', '20')
|
|
125
|
+
.action(async (options: { logs?: string }) => {
|
|
126
|
+
const supported = serviceManager.isSupported();
|
|
127
|
+
if (!supported) {
|
|
128
|
+
logger.error('Service management is only supported on macOS.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const status = await serviceManager.status();
|
|
134
|
+
|
|
135
|
+
console.log();
|
|
136
|
+
console.log(chalk.bold('Watcher Service Status'));
|
|
137
|
+
console.log('─'.repeat(40));
|
|
138
|
+
console.log(`Label: ${status.label}`);
|
|
139
|
+
console.log(`Status: ${formatStatus(status.status)}`);
|
|
140
|
+
if (status.pid) {
|
|
141
|
+
console.log(`PID: ${status.pid}`);
|
|
142
|
+
}
|
|
143
|
+
console.log(`Plist: ${status.plistPath}`);
|
|
144
|
+
console.log();
|
|
145
|
+
|
|
146
|
+
if (options.logs) {
|
|
147
|
+
const lines = parseInt(options.logs, 10) || 20;
|
|
148
|
+
const logs = await serviceManager.getLogs(lines);
|
|
149
|
+
|
|
150
|
+
if (logs.stdout) {
|
|
151
|
+
console.log(chalk.bold('Recent Logs:'));
|
|
152
|
+
console.log('─'.repeat(40));
|
|
153
|
+
console.log(logs.stdout);
|
|
154
|
+
console.log();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (logs.stderr) {
|
|
158
|
+
console.log(chalk.bold.red('Error Logs:'));
|
|
159
|
+
console.log('─'.repeat(40));
|
|
160
|
+
console.log(logs.stderr);
|
|
161
|
+
console.log();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
logger.error(`Failed to get status: ${error instanceof Error ? error.message : String(error)}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
function formatStatus(status: string): string {
|
|
170
|
+
switch (status) {
|
|
171
|
+
case 'running':
|
|
172
|
+
return chalk.green('running');
|
|
173
|
+
case 'stopped':
|
|
174
|
+
return chalk.yellow('stopped');
|
|
175
|
+
case 'not_installed':
|
|
176
|
+
return chalk.dim('not installed');
|
|
177
|
+
default:
|
|
178
|
+
return status;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Add subcommands
|
|
183
|
+
watchCommand.addCommand(runCommand, { isDefault: true });
|
|
184
|
+
watchCommand.addCommand(startCommand);
|
|
185
|
+
watchCommand.addCommand(stopCommand);
|
|
186
|
+
watchCommand.addCommand(statusCommand);
|
|
187
|
+
|
|
188
|
+
// The main watcher logic (foreground mode)
|
|
189
|
+
async function runWatcher(options: RunOptions): Promise<void> {
|
|
190
|
+
// Priority: CLI option > config > default
|
|
191
|
+
const configDelay = getWatchDelay();
|
|
192
|
+
let delayMinutes = options.delay
|
|
193
|
+
? parseInt(options.delay, 10)
|
|
194
|
+
: (configDelay ?? DEFAULT_DELAY_MINUTES);
|
|
195
|
+
|
|
196
|
+
// Enforce max delay
|
|
197
|
+
if (delayMinutes > MAX_DELAY_MINUTES) {
|
|
198
|
+
logger.warn(`Maximum delay is ${MAX_DELAY_MINUTES} minutes. Using ${MAX_DELAY_MINUTES}.`);
|
|
199
|
+
delayMinutes = MAX_DELAY_MINUTES;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const delayMs = delayMinutes * 60 * 1000;
|
|
203
|
+
|
|
204
|
+
// Determine watch paths
|
|
205
|
+
let watchPaths: string[];
|
|
206
|
+
if (options.path && options.path.length > 0) {
|
|
207
|
+
// Use paths from -p option
|
|
208
|
+
watchPaths = options.path.map((p) => resolve(p.replace(/^~/, homedir())));
|
|
209
|
+
|
|
210
|
+
// Save to config (unless --no-save)
|
|
211
|
+
if (!options.noSave) {
|
|
212
|
+
setWatchPaths(watchPaths);
|
|
213
|
+
logger.info(`Saved watch paths to config.`);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
// Read from config or use defaults
|
|
217
|
+
const configPaths = getConfigWatchPaths();
|
|
218
|
+
if (configPaths && configPaths.length > 0) {
|
|
219
|
+
watchPaths = configPaths;
|
|
220
|
+
logger.info(`Using saved watch paths from config.`);
|
|
221
|
+
} else {
|
|
222
|
+
watchPaths = getDefaultWatchPaths();
|
|
223
|
+
logger.info(`Using default watch paths.`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Filter to existing paths
|
|
228
|
+
const validPaths = watchPaths.filter((p) => existsSync(p));
|
|
229
|
+
if (validPaths.length === 0) {
|
|
230
|
+
logger.error('No valid watch paths found. Use -p to specify paths.');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (validPaths.length < watchPaths.length) {
|
|
235
|
+
const invalidPaths = watchPaths.filter((p) => !existsSync(p));
|
|
236
|
+
logger.warn(`Skipping non-existent paths: ${invalidPaths.join(', ')}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check scanners
|
|
240
|
+
const allScanners = createAllScanners();
|
|
241
|
+
const availableScanners = await getAvailableScanners(allScanners);
|
|
242
|
+
|
|
243
|
+
if (availableScanners.length === 0) {
|
|
244
|
+
logger.warn('No AI coding tools detected on this system.');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const depth = getWatchDepth() ?? 3;
|
|
249
|
+
|
|
250
|
+
logger.info(
|
|
251
|
+
`Watching for project deletions (${availableScanners.map((s) => s.name).join(', ')})`
|
|
252
|
+
);
|
|
253
|
+
logger.info(`Watch paths: ${validPaths.join(', ')}`);
|
|
254
|
+
logger.info(`Cleanup delay: ${String(delayMinutes)} minute(s)`);
|
|
255
|
+
logger.info(`Watch depth: ${String(depth)}`);
|
|
256
|
+
if (process.stdout.isTTY) {
|
|
257
|
+
logger.info(chalk.dim('Press Ctrl+C to stop watching.'));
|
|
258
|
+
}
|
|
259
|
+
console.log();
|
|
260
|
+
|
|
261
|
+
const cleaner = new Cleaner();
|
|
262
|
+
|
|
263
|
+
const watcher = new Watcher({
|
|
264
|
+
watchPaths: validPaths,
|
|
265
|
+
delayMs,
|
|
266
|
+
depth,
|
|
267
|
+
onDelete: async (events) => {
|
|
268
|
+
// Log batch events
|
|
269
|
+
if (events.length === 1) {
|
|
270
|
+
logger.info(`Detected deletion: ${events[0].path}`);
|
|
271
|
+
} else {
|
|
272
|
+
logger.info(`Detected ${events.length} deletions (debounced)`);
|
|
273
|
+
if (options.verbose) {
|
|
274
|
+
for (const event of events) {
|
|
275
|
+
logger.debug(` - ${event.path}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Scan for orphaned sessions (executed only once)
|
|
281
|
+
const results = await runAllScanners(availableScanners);
|
|
282
|
+
const allSessions = results.flatMap((r) => r.sessions);
|
|
283
|
+
|
|
284
|
+
if (allSessions.length === 0) {
|
|
285
|
+
if (options.verbose) {
|
|
286
|
+
logger.debug('No orphaned sessions found after deletion.');
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Clean up
|
|
292
|
+
const cleanResult = await cleaner.clean(allSessions, {
|
|
293
|
+
dryRun: false,
|
|
294
|
+
useTrash: !options.noTrash,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (cleanResult.deletedCount > 0) {
|
|
298
|
+
const action = options.noTrash ? 'Deleted' : 'Moved to trash';
|
|
299
|
+
const parts: string[] = [];
|
|
300
|
+
const { deletedByType } = cleanResult;
|
|
301
|
+
|
|
302
|
+
if (deletedByType.session > 0) {
|
|
303
|
+
parts.push(`${deletedByType.session} session`);
|
|
304
|
+
}
|
|
305
|
+
if (deletedByType.sessionEnv > 0) {
|
|
306
|
+
parts.push(`${deletedByType.sessionEnv} session-env`);
|
|
307
|
+
}
|
|
308
|
+
if (deletedByType.todos > 0) {
|
|
309
|
+
parts.push(`${deletedByType.todos} todos`);
|
|
310
|
+
}
|
|
311
|
+
if (deletedByType.fileHistory > 0) {
|
|
312
|
+
parts.push(`${deletedByType.fileHistory} file-history`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const summary = parts.length > 0 ? parts.join(' + ') : `${cleanResult.deletedCount} item(s)`;
|
|
316
|
+
logger.success(
|
|
317
|
+
`${action}: ${summary} (${formatSize(cleanResult.totalSizeDeleted)})`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (cleanResult.configEntriesRemoved > 0) {
|
|
322
|
+
logger.success(
|
|
323
|
+
`Removed ${cleanResult.configEntriesRemoved} config entry(ies) from ~/.claude.json`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (cleanResult.errors.length > 0) {
|
|
328
|
+
logger.error(
|
|
329
|
+
`Failed to clean ${cleanResult.errors.length} item(s)`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
watcher.start();
|
|
336
|
+
|
|
337
|
+
// Handle Ctrl+C
|
|
338
|
+
process.on('SIGINT', () => {
|
|
339
|
+
console.log();
|
|
340
|
+
logger.info('Stopping watcher...');
|
|
341
|
+
watcher.stop();
|
|
342
|
+
process.exit(0);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Keep process alive
|
|
346
|
+
await new Promise(() => {});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function getDefaultWatchPaths(): string[] {
|
|
350
|
+
const home = homedir();
|
|
351
|
+
return [
|
|
352
|
+
join(home, 'dev'),
|
|
353
|
+
join(home, 'code'),
|
|
354
|
+
join(home, 'projects'),
|
|
355
|
+
join(home, 'Development'),
|
|
356
|
+
join(home, 'Developer'), // macOS Xcode
|
|
357
|
+
join(home, 'Documents'),
|
|
358
|
+
];
|
|
359
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, writeFile, rm, access } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
|
|
6
|
+
import { Cleaner, CleanResult, CleanOptions } from './cleaner.js';
|
|
7
|
+
import type { OrphanedSession } from '../scanners/types.js';
|
|
8
|
+
|
|
9
|
+
describe('Cleaner', () => {
|
|
10
|
+
const testDir = join(tmpdir(), 'cleanertest' + Date.now());
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
await mkdir(testDir, { recursive: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await rm(testDir, { recursive: true, force: true });
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function createSession(
|
|
22
|
+
sessionPath: string,
|
|
23
|
+
size: number = 100
|
|
24
|
+
): OrphanedSession {
|
|
25
|
+
return {
|
|
26
|
+
toolName: 'claude-code',
|
|
27
|
+
sessionPath,
|
|
28
|
+
projectPath: '/deleted/project',
|
|
29
|
+
size,
|
|
30
|
+
lastModified: new Date(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('clean', () => {
|
|
35
|
+
it('does not delete in dry-run mode', async () => {
|
|
36
|
+
const sessionDir = join(testDir, 'session1');
|
|
37
|
+
await mkdir(sessionDir);
|
|
38
|
+
await writeFile(join(sessionDir, 'data.json'), 'content');
|
|
39
|
+
|
|
40
|
+
const sessions = [createSession(sessionDir)];
|
|
41
|
+
const cleaner = new Cleaner();
|
|
42
|
+
|
|
43
|
+
const result = await cleaner.clean(sessions, { dryRun: true });
|
|
44
|
+
|
|
45
|
+
// File should still exist
|
|
46
|
+
await expect(access(sessionDir)).resolves.not.toThrow();
|
|
47
|
+
expect(result.deletedCount).toBe(0);
|
|
48
|
+
expect(result.skippedCount).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('moves to trash (default)', async () => {
|
|
52
|
+
const sessionDir = join(testDir, 'session2');
|
|
53
|
+
await mkdir(sessionDir);
|
|
54
|
+
await writeFile(join(sessionDir, 'data.json'), 'content');
|
|
55
|
+
|
|
56
|
+
const sessions = [createSession(sessionDir, 50)];
|
|
57
|
+
const cleaner = new Cleaner();
|
|
58
|
+
|
|
59
|
+
const result = await cleaner.clean(sessions, { dryRun: false });
|
|
60
|
+
|
|
61
|
+
// File should be deleted
|
|
62
|
+
await expect(access(sessionDir)).rejects.toThrow();
|
|
63
|
+
expect(result.deletedCount).toBe(1);
|
|
64
|
+
expect(result.totalSizeDeleted).toBe(50);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('permanently deletes with --no-trash', async () => {
|
|
68
|
+
const sessionDir = join(testDir, 'session3');
|
|
69
|
+
await mkdir(sessionDir);
|
|
70
|
+
await writeFile(join(sessionDir, 'data.json'), 'content');
|
|
71
|
+
|
|
72
|
+
const sessions = [createSession(sessionDir)];
|
|
73
|
+
const cleaner = new Cleaner();
|
|
74
|
+
|
|
75
|
+
const result = await cleaner.clean(sessions, {
|
|
76
|
+
dryRun: false,
|
|
77
|
+
useTrash: false,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await expect(access(sessionDir)).rejects.toThrow();
|
|
81
|
+
expect(result.deletedCount).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns cleanup statistics', async () => {
|
|
85
|
+
const session1 = join(testDir, 'stat1');
|
|
86
|
+
const session2 = join(testDir, 'stat2');
|
|
87
|
+
await mkdir(session1);
|
|
88
|
+
await mkdir(session2);
|
|
89
|
+
await writeFile(join(session1, 'data.json'), 'a');
|
|
90
|
+
await writeFile(join(session2, 'data.json'), 'b');
|
|
91
|
+
|
|
92
|
+
const sessions = [
|
|
93
|
+
createSession(session1, 100),
|
|
94
|
+
createSession(session2, 200),
|
|
95
|
+
];
|
|
96
|
+
const cleaner = new Cleaner();
|
|
97
|
+
|
|
98
|
+
const result = await cleaner.clean(sessions, { dryRun: false });
|
|
99
|
+
|
|
100
|
+
expect(result.deletedCount).toBe(2);
|
|
101
|
+
expect(result.totalSizeDeleted).toBe(300);
|
|
102
|
+
expect(result.errors).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles already deleted paths with alreadyGoneCount', async () => {
|
|
106
|
+
// Non-existent path (already deleted)
|
|
107
|
+
const sessions = [createSession('/nonexistent/path/12345')];
|
|
108
|
+
const cleaner = new Cleaner();
|
|
109
|
+
|
|
110
|
+
const result = await cleaner.clean(sessions, { dryRun: false });
|
|
111
|
+
|
|
112
|
+
expect(result.deletedCount).toBe(0);
|
|
113
|
+
expect(result.alreadyGoneCount).toBe(1);
|
|
114
|
+
expect(result.errors).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('handles empty session list', async () => {
|
|
118
|
+
const cleaner = new Cleaner();
|
|
119
|
+
const result = await cleaner.clean([], { dryRun: false });
|
|
120
|
+
|
|
121
|
+
expect(result.deletedCount).toBe(0);
|
|
122
|
+
expect(result.totalSizeDeleted).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|