@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.
@@ -0,0 +1,335 @@
1
+ import { basename } from 'path';
2
+
3
+ import { Command } from 'commander';
4
+ import ora from 'ora';
5
+ import chalk from 'chalk';
6
+ import inquirer from 'inquirer';
7
+
8
+ import { logger } from '../utils/logger.js';
9
+ import { formatSize } from '../utils/size.js';
10
+ import { tildify } from '../utils/paths.js';
11
+ import {
12
+ createAllScanners,
13
+ getAvailableScanners,
14
+ runAllScanners,
15
+ } from '../scanners/index.js';
16
+ import { Cleaner } from '../core/cleaner.js';
17
+ import type { OrphanedSession } from '../scanners/types.js';
18
+
19
+ interface CleanOptions {
20
+ force?: boolean;
21
+ dryRun?: boolean;
22
+ noTrash?: boolean;
23
+ verbose?: boolean;
24
+ interactive?: boolean;
25
+ }
26
+
27
+ function formatSessionChoice(session: OrphanedSession): string {
28
+ const projectName = basename(session.projectPath);
29
+ const isConfig = session.type === 'config';
30
+ const toolTag = isConfig
31
+ ? chalk.yellow('[config]')
32
+ : chalk.cyan(`[${session.toolName}]`);
33
+ const name = chalk.white(projectName);
34
+ const size = isConfig ? '' : chalk.dim(`(${formatSize(session.size)})`);
35
+ const path = chalk.dim(`→ ${session.projectPath}`);
36
+ return `${toolTag} ${name} ${size}\n ${path}`;
37
+ }
38
+
39
+ export const cleanCommand = new Command('clean')
40
+ .description('Remove orphaned session data')
41
+ .option('-f, --force', 'Skip confirmation prompt')
42
+ .option('-n, --dry-run', 'Show what would be deleted without deleting')
43
+ .option('-i, --interactive', 'Select sessions to delete interactively')
44
+ .option('--no-trash', 'Permanently delete instead of moving to trash')
45
+ .option('-v, --verbose', 'Enable verbose output')
46
+ .action(async (options: CleanOptions) => {
47
+ const spinner = ora('Scanning for orphaned sessions...').start();
48
+
49
+ try {
50
+ // Scan
51
+ const allScanners = createAllScanners();
52
+ const availableScanners = await getAvailableScanners(allScanners);
53
+
54
+ if (availableScanners.length === 0) {
55
+ spinner.warn('No AI coding tools detected on this system.');
56
+ return;
57
+ }
58
+
59
+ const results = await runAllScanners(availableScanners);
60
+ const allSessions = results.flatMap((r) => r.sessions);
61
+ const totalSize = results.reduce((sum, r) => sum + r.totalSize, 0);
62
+
63
+ spinner.stop();
64
+
65
+ if (allSessions.length === 0) {
66
+ logger.success('No orphaned sessions found.');
67
+ return;
68
+ }
69
+
70
+ // Separate session folders, config entries, and auto-cleanup targets
71
+ const folderSessions = allSessions.filter(
72
+ (s) => s.type === 'session' || s.type === undefined
73
+ );
74
+ const configEntries = allSessions.filter((s) => s.type === 'config');
75
+ const sessionEnvEntries = allSessions.filter(
76
+ (s) => s.type === 'session-env'
77
+ );
78
+ const todosEntries = allSessions.filter((s) => s.type === 'todos');
79
+ const fileHistoryEntries = allSessions.filter(
80
+ (s) => s.type === 'file-history'
81
+ );
82
+
83
+ // Auto-cleanup targets (session-env, todos, file-history)
84
+ const autoCleanEntries = [
85
+ ...sessionEnvEntries,
86
+ ...todosEntries,
87
+ ...fileHistoryEntries,
88
+ ];
89
+
90
+ // Interactive selection targets (excluding auto-cleanup targets)
91
+ const selectableSessions = allSessions.filter(
92
+ (s) =>
93
+ s.type !== 'session-env' &&
94
+ s.type !== 'todos' &&
95
+ s.type !== 'file-history'
96
+ );
97
+
98
+ // Output summary
99
+ console.log();
100
+ const parts: string[] = [];
101
+ if (folderSessions.length > 0) {
102
+ parts.push(`${folderSessions.length} session folder(s)`);
103
+ }
104
+ if (configEntries.length > 0) {
105
+ parts.push(`${configEntries.length} config entry(ies)`);
106
+ }
107
+ if (sessionEnvEntries.length > 0) {
108
+ parts.push(`${sessionEnvEntries.length} session-env folder(s)`);
109
+ }
110
+ if (todosEntries.length > 0) {
111
+ parts.push(`${todosEntries.length} todos file(s)`);
112
+ }
113
+ if (fileHistoryEntries.length > 0) {
114
+ parts.push(`${fileHistoryEntries.length} file-history folder(s)`);
115
+ }
116
+ logger.warn(`Found ${parts.join(' + ')} (${formatSize(totalSize)})`);
117
+
118
+ if (options.verbose && !options.interactive) {
119
+ console.log();
120
+ // Exclude auto-cleanup targets from detailed list
121
+ for (const session of selectableSessions) {
122
+ console.log(
123
+ chalk.dim(` ${session.toolName}: ${session.projectPath}`)
124
+ );
125
+ }
126
+ }
127
+
128
+ // Interactive mode: session selection (excluding auto-cleanup targets)
129
+ let sessionsToClean = allSessions;
130
+ if (options.interactive) {
131
+ if (selectableSessions.length === 0) {
132
+ // Only auto-cleanup targets exist
133
+ if (autoCleanEntries.length > 0) {
134
+ sessionsToClean = autoCleanEntries;
135
+ logger.info(
136
+ `Only ${autoCleanEntries.length} auto-cleanup target(s) found`
137
+ );
138
+ } else {
139
+ logger.info('No selectable sessions found.');
140
+ return;
141
+ }
142
+ } else {
143
+ console.log();
144
+ const { selected } = await inquirer.prompt<{
145
+ selected: OrphanedSession[];
146
+ }>([
147
+ {
148
+ type: 'checkbox',
149
+ name: 'selected',
150
+ message: 'Select sessions to delete:',
151
+ choices: selectableSessions.map((session) => ({
152
+ name: formatSessionChoice(session),
153
+ value: session,
154
+ checked: false,
155
+ })),
156
+ pageSize: 15,
157
+ loop: false,
158
+ },
159
+ ]);
160
+
161
+ if (selected.length === 0) {
162
+ logger.info('No sessions selected. Cancelled.');
163
+ return;
164
+ }
165
+
166
+ // Include selected sessions + auto-cleanup targets
167
+ sessionsToClean = [...selected, ...autoCleanEntries];
168
+ const selectedSize = selected.reduce((sum, s) => sum + s.size, 0);
169
+ console.log();
170
+ if (selected.length > 0) {
171
+ logger.info(
172
+ `Selected: ${selected.length} session(s) (${formatSize(selectedSize)})`
173
+ );
174
+ }
175
+ if (autoCleanEntries.length > 0) {
176
+ const autoSize = autoCleanEntries.reduce((sum, s) => sum + s.size, 0);
177
+ logger.info(
178
+ `+ ${autoCleanEntries.length} auto-cleanup target(s) (${formatSize(autoSize)})`
179
+ );
180
+ }
181
+ }
182
+ }
183
+
184
+ const cleanSize = sessionsToClean.reduce((sum, s) => sum + s.size, 0);
185
+
186
+ // Dry-run mode
187
+ if (options.dryRun) {
188
+ console.log();
189
+ logger.info(
190
+ chalk.yellow('Dry-run mode: No files will be deleted.')
191
+ );
192
+ console.log();
193
+
194
+ const dryRunFolders = sessionsToClean.filter(
195
+ (s) => s.type === 'session' || s.type === undefined
196
+ );
197
+ const dryRunConfigs = sessionsToClean.filter((s) => s.type === 'config');
198
+ const dryRunEnvs = sessionsToClean.filter(
199
+ (s) => s.type === 'session-env'
200
+ );
201
+ const dryRunTodos = sessionsToClean.filter((s) => s.type === 'todos');
202
+ const dryRunHistories = sessionsToClean.filter(
203
+ (s) => s.type === 'file-history'
204
+ );
205
+
206
+ for (const session of dryRunFolders) {
207
+ console.log(
208
+ ` ${chalk.red('Would delete:')} ${session.sessionPath} (${formatSize(session.size)})`
209
+ );
210
+ }
211
+
212
+ if (dryRunConfigs.length > 0) {
213
+ console.log();
214
+ console.log(
215
+ ` ${chalk.yellow('Would remove from ~/.claude.json:')}`
216
+ );
217
+ for (const config of dryRunConfigs) {
218
+ console.log(` - ${config.projectPath}`);
219
+ }
220
+ }
221
+
222
+ // Auto-cleanup targets summary
223
+ const autoCleanParts: string[] = [];
224
+ if (dryRunEnvs.length > 0) {
225
+ autoCleanParts.push(`${dryRunEnvs.length} session-env`);
226
+ }
227
+ if (dryRunTodos.length > 0) {
228
+ autoCleanParts.push(`${dryRunTodos.length} todos`);
229
+ }
230
+ if (dryRunHistories.length > 0) {
231
+ autoCleanParts.push(`${dryRunHistories.length} file-history`);
232
+ }
233
+ if (autoCleanParts.length > 0) {
234
+ const autoSize =
235
+ dryRunEnvs.reduce((sum, s) => sum + s.size, 0) +
236
+ dryRunTodos.reduce((sum, s) => sum + s.size, 0) +
237
+ dryRunHistories.reduce((sum, s) => sum + s.size, 0);
238
+ console.log();
239
+ console.log(
240
+ ` ${chalk.dim(`Would auto-delete: ${autoCleanParts.join(' + ')} (${formatSize(autoSize)})`)}`
241
+ );
242
+ }
243
+ return;
244
+ }
245
+
246
+ // Confirmation prompt (also in interactive mode)
247
+ if (!options.force) {
248
+ console.log();
249
+ const action = options.noTrash ? 'permanently delete' : 'move to trash';
250
+ const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
251
+ {
252
+ type: 'confirm',
253
+ name: 'confirmed',
254
+ message: `${action} ${sessionsToClean.length} session(s) (${formatSize(cleanSize)})?`,
255
+ default: false,
256
+ },
257
+ ]);
258
+
259
+ if (!confirmed) {
260
+ logger.info('Cancelled.');
261
+ return;
262
+ }
263
+ }
264
+
265
+ // Execute cleanup
266
+ const cleanSpinner = ora('Cleaning orphaned sessions...').start();
267
+
268
+ const cleaner = new Cleaner();
269
+ const cleanResult = await cleaner.clean(sessionsToClean, {
270
+ dryRun: false,
271
+ useTrash: !options.noTrash,
272
+ });
273
+
274
+ cleanSpinner.stop();
275
+
276
+ // Output results
277
+ console.log();
278
+ if (cleanResult.deletedCount > 0) {
279
+ const action = options.noTrash ? 'Deleted' : 'Moved to trash';
280
+ const parts: string[] = [];
281
+ const { deletedByType } = cleanResult;
282
+
283
+ if (deletedByType.session > 0) {
284
+ parts.push(`${deletedByType.session} session`);
285
+ }
286
+ if (deletedByType.sessionEnv > 0) {
287
+ parts.push(`${deletedByType.sessionEnv} session-env`);
288
+ }
289
+ if (deletedByType.todos > 0) {
290
+ parts.push(`${deletedByType.todos} todos`);
291
+ }
292
+ if (deletedByType.fileHistory > 0) {
293
+ parts.push(`${deletedByType.fileHistory} file-history`);
294
+ }
295
+
296
+ const summary =
297
+ parts.length > 0
298
+ ? parts.join(' + ')
299
+ : `${cleanResult.deletedCount} item(s)`;
300
+ logger.success(
301
+ `${action}: ${summary} (${formatSize(cleanResult.totalSizeDeleted)})`
302
+ );
303
+ }
304
+
305
+ if (cleanResult.alreadyGoneCount > 0 && options.verbose) {
306
+ logger.info(
307
+ `Skipped ${cleanResult.alreadyGoneCount} already-deleted item(s)`
308
+ );
309
+ }
310
+
311
+ if (cleanResult.configEntriesRemoved > 0) {
312
+ logger.success(
313
+ `Removed ${cleanResult.configEntriesRemoved} config entry(ies) from ~/.claude.json`
314
+ );
315
+ if (cleanResult.backupPath) {
316
+ logger.info(`Backup saved to: ${tildify(cleanResult.backupPath)}`);
317
+ }
318
+ }
319
+
320
+ if (cleanResult.errors.length > 0) {
321
+ logger.error(`Failed to delete ${cleanResult.errors.length} item(s)`);
322
+ if (options.verbose) {
323
+ for (const err of cleanResult.errors) {
324
+ console.log(chalk.red(` ${err.sessionPath}: ${err.error.message}`));
325
+ }
326
+ }
327
+ }
328
+ } catch (error) {
329
+ spinner.fail('Clean failed');
330
+ logger.error(
331
+ error instanceof Error ? error.message : 'Unknown error occurred'
332
+ );
333
+ process.exit(1);
334
+ }
335
+ });
@@ -0,0 +1,144 @@
1
+ import { Command } from 'commander';
2
+ import inquirer from 'inquirer';
3
+
4
+ import { logger } from '../utils/logger.js';
5
+ import {
6
+ loadConfig,
7
+ addWatchPath,
8
+ removeWatchPath,
9
+ getWatchPaths,
10
+ getWatchDelay,
11
+ setWatchDelay,
12
+ getWatchDepth,
13
+ setWatchDepth,
14
+ resetConfig,
15
+ } from '../utils/config.js';
16
+
17
+ export const configCommand = new Command('config').description(
18
+ 'Manage configuration'
19
+ );
20
+
21
+ const pathsCommand = new Command('paths').description('Manage watch paths');
22
+
23
+ pathsCommand
24
+ .command('add <path>')
25
+ .description('Add a watch path')
26
+ .action((path: string) => {
27
+ addWatchPath(path);
28
+ logger.success(`Added: ${path}`);
29
+ });
30
+
31
+ pathsCommand
32
+ .command('remove <path>')
33
+ .description('Remove a watch path')
34
+ .action((path: string) => {
35
+ const removed = removeWatchPath(path);
36
+ if (removed) {
37
+ logger.success(`Removed: ${path}`);
38
+ } else {
39
+ logger.warn(`Path not found: ${path}`);
40
+ }
41
+ });
42
+
43
+ pathsCommand
44
+ .command('list')
45
+ .description('List watch paths')
46
+ .action(() => {
47
+ const paths = getWatchPaths();
48
+ if (!paths || paths.length === 0) {
49
+ logger.info('No watch paths configured.');
50
+ return;
51
+ }
52
+ console.log();
53
+ for (const p of paths) {
54
+ console.log(` ${p}`);
55
+ }
56
+ });
57
+
58
+ configCommand.addCommand(pathsCommand);
59
+
60
+ const DEFAULT_DELAY_MINUTES = 5;
61
+ const MAX_DELAY_MINUTES = 10;
62
+
63
+ configCommand
64
+ .command('delay [minutes]')
65
+ .description(`Get or set watch delay in minutes (default: ${DEFAULT_DELAY_MINUTES}, max: ${MAX_DELAY_MINUTES})`)
66
+ .action((minutes?: string) => {
67
+ if (minutes === undefined) {
68
+ // Get current delay
69
+ const delay = getWatchDelay() ?? DEFAULT_DELAY_MINUTES;
70
+ console.log(`Watch delay: ${String(delay)} minute(s)`);
71
+ } else {
72
+ // Set delay
73
+ const value = parseInt(minutes, 10);
74
+ if (isNaN(value) || value < 1) {
75
+ logger.error('Invalid delay value. Must be a positive number.');
76
+ return;
77
+ }
78
+ if (value > MAX_DELAY_MINUTES) {
79
+ logger.warn(`Maximum delay is ${String(MAX_DELAY_MINUTES)} minutes. Setting to ${String(MAX_DELAY_MINUTES)}.`);
80
+ setWatchDelay(MAX_DELAY_MINUTES);
81
+ return;
82
+ }
83
+ setWatchDelay(value);
84
+ logger.success(`Watch delay set to ${String(value)} minute(s)`);
85
+ }
86
+ });
87
+
88
+ const DEFAULT_DEPTH = 3;
89
+ const MAX_DEPTH = 5;
90
+
91
+ configCommand
92
+ .command('depth [level]')
93
+ .description('Get or set watch depth (default: 3, max: 5)')
94
+ .action((level?: string) => {
95
+ if (level === undefined) {
96
+ const depth = getWatchDepth() ?? DEFAULT_DEPTH;
97
+ console.log(`Watch depth: ${String(depth)}`);
98
+ } else {
99
+ const value = parseInt(level, 10);
100
+ if (isNaN(value) || value < 1) {
101
+ logger.error('Invalid depth value. Must be a positive number.');
102
+ return;
103
+ }
104
+ if (value > MAX_DEPTH) {
105
+ logger.warn(`Maximum depth is ${String(MAX_DEPTH)}. Setting to ${String(MAX_DEPTH)}.`);
106
+ }
107
+ setWatchDepth(value);
108
+ const actualValue = Math.min(value, MAX_DEPTH);
109
+ logger.success(`Watch depth set to ${String(actualValue)}`);
110
+ }
111
+ });
112
+
113
+ configCommand
114
+ .command('show')
115
+ .description('Show all configuration')
116
+ .action(() => {
117
+ const config = loadConfig();
118
+ console.log(JSON.stringify(config, null, 2));
119
+ });
120
+
121
+ configCommand
122
+ .command('reset')
123
+ .description('Reset configuration to defaults')
124
+ .option('-f, --force', 'Skip confirmation prompt')
125
+ .action(async (options: { force?: boolean }) => {
126
+ if (!options.force) {
127
+ const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
128
+ {
129
+ type: 'confirm',
130
+ name: 'confirmed',
131
+ message: 'Reset all configuration to defaults?',
132
+ default: false,
133
+ },
134
+ ]);
135
+
136
+ if (!confirmed) {
137
+ logger.info('Cancelled.');
138
+ return;
139
+ }
140
+ }
141
+
142
+ resetConfig();
143
+ logger.success('Configuration reset to defaults.');
144
+ });
@@ -0,0 +1,86 @@
1
+ import { Command } from 'commander';
2
+ import Table from 'cli-table3';
3
+ import chalk from 'chalk';
4
+
5
+ import { logger } from '../utils/logger.js';
6
+ import { formatSize } from '../utils/size.js';
7
+ import {
8
+ createAllScanners,
9
+ getAvailableScanners,
10
+ } from '../scanners/index.js';
11
+ import {
12
+ getClaudeProjectsDir,
13
+ getCursorWorkspaceDir,
14
+ } from '../utils/paths.js';
15
+
16
+ interface ListOptions {
17
+ verbose?: boolean;
18
+ }
19
+
20
+ export const listCommand = new Command('list')
21
+ .description('List detected AI coding tools and their data locations')
22
+ .option('-v, --verbose', 'Show detailed information')
23
+ .action(async (options: ListOptions) => {
24
+ const allScanners = createAllScanners();
25
+ const availableScanners = await getAvailableScanners(allScanners);
26
+
27
+ console.log();
28
+ console.log(chalk.bold('AI Coding Tools Status:'));
29
+ console.log();
30
+
31
+ const table = new Table({
32
+ head: [
33
+ chalk.cyan('Tool'),
34
+ chalk.cyan('Status'),
35
+ chalk.cyan('Data Location'),
36
+ ],
37
+ style: { head: [] },
38
+ });
39
+
40
+ const toolLocations: Record<string, string> = {
41
+ 'claude-code': getClaudeProjectsDir(),
42
+ cursor: getCursorWorkspaceDir(),
43
+ };
44
+
45
+ for (const scanner of allScanners) {
46
+ const isAvailable = availableScanners.some((s) => s.name === scanner.name);
47
+ const status = isAvailable
48
+ ? chalk.green('Available')
49
+ : chalk.dim('Not found');
50
+ const location = toolLocations[scanner.name] || 'Unknown';
51
+
52
+ table.push([scanner.name, status, isAvailable ? location : chalk.dim(location)]);
53
+ }
54
+
55
+ console.log(table.toString());
56
+
57
+ // Additional info (verbose)
58
+ if (options.verbose && availableScanners.length > 0) {
59
+ console.log();
60
+ console.log(chalk.bold('Tool Details:'));
61
+ console.log();
62
+
63
+ for (const scanner of availableScanners) {
64
+ console.log(chalk.cyan(`${scanner.name}:`));
65
+
66
+ switch (scanner.name) {
67
+ case 'claude-code':
68
+ console.log(' Session format: Encoded project path directories');
69
+ console.log(' Path encoding: /path/to/project → -path-to-project');
70
+ break;
71
+ case 'cursor':
72
+ console.log(' Session format: Hash-named directories with workspace.json');
73
+ console.log(' Project info: Stored in workspace.json "folder" field');
74
+ break;
75
+ }
76
+ console.log();
77
+ }
78
+ }
79
+
80
+ // Summary
81
+ console.log(
82
+ chalk.dim(
83
+ `${availableScanners.length} of ${allScanners.length} tools detected on this system.`
84
+ )
85
+ );
86
+ });