filemayor 2.1.0 → 4.0.5

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/index.js CHANGED
@@ -1,685 +1,895 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * ═══════════════════════════════════════════════════════════════════
5
- *
6
- * ███████╗██╗██╗ ███████╗███╗ ███╗ █████╗ ██╗ ██╗ ██████╗ ██████╗
7
- * ██╔════╝██║██║ ██╔════╝████╗ ████║██╔══██╗╚██╗ ██╔╝██╔═══██╗██╔══██╗
8
- * █████╗ ██║██║ █████╗ ██╔████╔██║███████║ ╚████╔╝ ██║ ██║██████╔╝
9
- * ██╔══╝ ██║██║ ██╔══╝ ██║╚██╔╝██║██╔══██║ ╚██╔╝ ██║ ██║██╔══██╗
10
- * ██║ ██║███████╗███████╗██║ ╚═╝ ██║██║ ██║ ██║ ╚██████╔╝██║ ██║
11
- * ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
12
- *
13
- * Your Digital Life Organizer — CLI Edition
14
- * Created by Lehlohonolo Goodwill Nchefu (Chevza)
15
- * Works everywhere: terminals, servers, data centers, CI/CD
16
- *
17
- * ═══════════════════════════════════════════════════════════════════
18
- */
19
-
20
- 'use strict';
21
-
22
- const path = require('path');
23
- const os = require('os');
24
- const fs = require('fs');
25
-
26
- // Core modules
27
- const { scan, scanByCategory, scanSummary } = require('./core/scanner');
28
- const { organize, generatePlan, rollback, loadJournal } = require('./core/organizer');
29
- const { findJunk, clean } = require('./core/cleaner');
30
- const { FileWatcher } = require('./core/watcher');
31
- const { loadConfig, createConfigFile } = require('./core/config');
32
- const { analyzeDirectory } = require('./core/analyzer');
33
- const reporter = require('./core/reporter');
34
- const { formatBytes } = require('./core/scanner');
35
- const { getCategories } = require('./core/categories');
36
- const { checkPermissions } = require('./core/security');
37
- const { activateLicense, deactivateLicense, getLicenseInfo, checkProFeature, checkBulkLimit } = require('./core/license');
38
-
39
- const { c, banner, Spinner, success, error, warn, info } = reporter;
40
-
41
- // ─── Version ──────────────────────────────────────────────────────
42
- const VERSION = '2.1.0';
43
-
44
- // ─── Path Helpers ─────────────────────────────────────────────────
45
-
46
- /**
47
- * Expand ~ to user home directory (cross-platform).
48
- * On Unix shells, ~ is expanded by the shell before reaching Node.
49
- * On Windows PowerShell, it is NOT — so we handle it here.
50
- */
51
- function expandTilde(p) {
52
- if (!p) return p;
53
- if (p === '~') return os.homedir();
54
- if (p.startsWith('~/') || p.startsWith('~\\')) {
55
- return path.join(os.homedir(), p.slice(2));
56
- }
57
- return p;
58
- }
59
-
60
- // ─── Argument Parser ──────────────────────────────────────────────
61
-
62
- function parseArgs(argv) {
63
- const args = {
64
- command: null,
65
- target: null,
66
- flags: {},
67
- positional: []
68
- };
69
-
70
- const raw = argv.slice(2);
71
- let i = 0;
72
-
73
- while (i < raw.length) {
74
- const arg = raw[i];
75
-
76
- if (arg === '--help' || arg === '-h') {
77
- args.flags.help = true;
78
- } else if (arg === '--version' || arg === '-V') {
79
- args.flags.version = true;
80
- } else if (arg === '--dry-run') {
81
- args.flags.dryRun = true;
82
- } else if (arg === '--json') {
83
- args.flags.format = 'json';
84
- } else if (arg === '--csv') {
85
- args.flags.format = 'csv';
86
- } else if (arg === '--minimal') {
87
- args.flags.format = 'minimal';
88
- } else if (arg === '--yes' || arg === '-y') {
89
- args.flags.yes = true;
90
- } else if (arg === '--verbose' || arg === '-v') {
91
- args.flags.verbose = true;
92
- } else if (arg === '--quiet' || arg === '-q') {
93
- args.flags.quiet = true;
94
- } else if (arg === '--no-color') {
95
- args.flags.noColor = true;
96
- } else if (arg === '--recursive' || arg === '-r') {
97
- args.flags.recursive = true;
98
- } else if (arg === '--depth' && raw[i + 1]) {
99
- args.flags.depth = parseInt(raw[++i], 10);
100
- } else if (arg === '--naming' && raw[i + 1]) {
101
- args.flags.naming = raw[++i];
102
- } else if (arg === '--output' && raw[i + 1]) {
103
- args.flags.outputDir = raw[++i];
104
- } else if (arg === '--config' && raw[i + 1]) {
105
- args.flags.config = raw[++i];
106
- } else if (arg === '--duplicates' && raw[i + 1]) {
107
- args.flags.duplicates = raw[++i];
108
- } else if (arg === '--extensions' && raw[i + 1]) {
109
- args.flags.extensions = raw[++i].split(',');
110
- } else if (arg === '--category' && raw[i + 1]) {
111
- args.flags.category = raw[++i].split(',');
112
- } else if (arg === '--min-size' && raw[i + 1]) {
113
- args.flags.minSize = parseSizeArg(raw[++i]);
114
- } else if (arg === '--max-size' && raw[i + 1]) {
115
- args.flags.maxSize = parseSizeArg(raw[++i]);
116
- } else if (arg.startsWith('--')) {
117
- // Unknown flag with potential value
118
- const key = arg.slice(2).replace(/-([a-z])/g, (_, l) => l.toUpperCase());
119
- if (raw[i + 1] && !raw[i + 1].startsWith('-')) {
120
- args.flags[key] = raw[++i];
121
- } else {
122
- args.flags[key] = true;
123
- }
124
- } else if (arg.startsWith('-') && arg.length === 2) {
125
- args.flags[arg.slice(1)] = true;
126
- } else if (!args.command) {
127
- args.command = arg;
128
- } else if (!args.target) {
129
- args.target = expandTilde(arg);
130
- } else {
131
- args.positional.push(arg);
132
- }
133
-
134
- i++;
135
- }
136
-
137
- return args;
138
- }
139
-
140
- function parseSizeArg(str) {
141
- const match = str.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/i);
142
- if (!match) return 0;
143
- const num = parseFloat(match[1]);
144
- const unit = (match[2] || 'b').toLowerCase();
145
- const multipliers = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3, tb: 1024 ** 4 };
146
- return Math.floor(num * (multipliers[unit] || 1));
147
- }
148
-
149
- // ─── Help Text ────────────────────────────────────────────────────
150
-
151
- function printHelp() {
152
- console.log(`
153
- ${banner()}
154
- ${c('bold', 'USAGE')}
155
- ${c('cyan', 'filemayor')} ${c('yellow', '<command>')} ${c('dim', '[path]')} ${c('dim', '[options]')}
156
-
157
- ${c('bold', 'COMMANDS')}
158
- ${c('yellow', 'scan')} ${c('dim', '<path>')} Scan directory and report contents
159
- ${c('yellow', 'analyze')} ${c('dim', '<path>')} Deep analysis (duplicates, bloat, savings)
160
- ${c('yellow', 'organize')} ${c('dim', '<path>')} Organize files into categories
161
- ${c('yellow', 'clean')} ${c('dim', '<path>')} Find and remove junk files
162
- ${c('yellow', 'watch')} ${c('dim', '<path>')} Watch directory for changes ${c('magenta', '[PRO]')}
163
- ${c('yellow', 'init')} Create .filemayor.yml config
164
- ${c('yellow', 'undo')} ${c('dim', '<path>')} Undo last organization
165
- ${c('yellow', 'info')} System info and version
166
- ${c('yellow', 'license')} ${c('dim', '<action>')} Manage license (activate|status|deactivate)
167
-
168
- ${c('bold', 'OPTIONS')}
169
- ${c('dim', '--dry-run')} Preview changes without executing
170
- ${c('dim', '--json')} Output as JSON
171
- ${c('dim', '--csv')} Output as CSV
172
- ${c('dim', '--minimal')} Minimal output (paths only)
173
- ${c('dim', '--yes, -y')} Skip confirmation prompts
174
- ${c('dim', '--verbose, -v')} Detailed logging
175
- ${c('dim', '--quiet, -q')} Suppress output (exit code only)
176
- ${c('dim', '--no-color')} Disable colored output
177
- ${c('dim', '--config <path>')} Custom config file
178
- ${c('dim', '--depth <n>')} Max recursion depth
179
- ${c('dim', '--naming <type>')} Naming: original|category_prefix|date_prefix|clean
180
- ${c('dim', '--duplicates <s>')} Duplicates: rename|skip|overwrite
181
- ${c('dim', '--extensions <e>')} Filter by extensions (comma-separated)
182
- ${c('dim', '--output <path>')} Output directory for organized files
183
- ${c('dim', '--category <c>')} Filter by category (comma-separated)
184
- ${c('dim', '--min-size <s>')} Minimum file size (e.g., 1mb)
185
- ${c('dim', '--max-size <s>')} Maximum file size (e.g., 100mb)
186
- ${c('dim', '--help, -h')} Show this help
187
- ${c('dim', '--version, -V')} Show version
188
-
189
- ${c('bold', 'EXAMPLES')}
190
- ${c('dim', '$')} filemayor scan ~/Downloads
191
- ${c('dim', '$')} filemayor organize ~/Downloads --dry-run
192
- ${c('dim', '$')} filemayor organize ~/Downloads --naming category_prefix
193
- ${c('dim', '$')} filemayor clean /var/tmp --yes
194
- ${c('dim', '$')} filemayor scan . --json | jq '.files | length'
195
- ${c('dim', '$')} filemayor watch ~/Downloads
196
- ${c('dim', '$')} filemayor init
197
-
198
- ${c('bold', 'INSTALL')}
199
- ${c('dim', '$')} npm install -g filemayor
200
-
201
- ${c('dim', `FileMayor v${VERSION} https://filemayor.com`)}
202
- `);
203
- }
204
-
205
- // ─── Commands ─────────────────────────────────────────────────────
206
-
207
- async function cmdScan(target, flags, config) {
208
- const targetPath = path.resolve(target || '.');
209
- const format = flags.format || config.output.format;
210
-
211
- const spinner = new Spinner(`Scanning ${c('cyan', targetPath)}...`);
212
- if (format === 'table' && !flags.quiet) spinner.start();
213
-
214
- try {
215
- const options = {
216
- maxDepth: flags.depth || config.scanner.maxDepth,
217
- includeHidden: config.scanner.includeHidden,
218
- ignore: config.organize.ignore,
219
- extensions: flags.extensions || null,
220
- minSize: flags.minSize || config.scanner.minSize,
221
- maxSize: flags.maxSize || config.scanner.maxSize || Infinity,
222
- followSymlinks: config.scanner.followSymlinks,
223
- };
224
-
225
- const result = scan(targetPath, options);
226
-
227
- spinner.stop();
228
-
229
- if (flags.quiet) {
230
- process.exit(result.files.length > 0 ? 0 : 1);
231
- return;
232
- }
233
-
234
- console.log(reporter.formatScanReport(result, format));
235
- } catch (err) {
236
- spinner.fail(err.message);
237
- process.exit(1);
238
- }
239
- }
240
-
241
- async function cmdAnalyze(target, flags, config) {
242
- const targetPath = path.resolve(target || '.');
243
- const format = flags.format || config.output.format;
244
-
245
- const spinner = new Spinner(`Analyzing ${c('cyan', targetPath)}...`);
246
- if (format === 'table' && !flags.quiet) spinner.start();
247
-
248
- try {
249
- const options = {
250
- maxDepth: flags.depth || config.scanner.maxDepth,
251
- minSize: flags.minSize || 1024,
252
- };
253
-
254
- const result = analyzeDirectory(targetPath, options);
255
-
256
- spinner.stop();
257
-
258
- if (flags.quiet) {
259
- process.exit(result.summary.potentialSavings > 0 ? 0 : 1);
260
- return;
261
- }
262
-
263
- console.log(reporter.formatAnalyzeReport(result, format));
264
- } catch (err) {
265
- spinner.fail(err.message);
266
- process.exit(1);
267
- }
268
- }
269
-
270
- async function cmdOrganize(target, flags, config) {
271
- const targetPath = path.resolve(target || '.');
272
- const format = flags.format || config.output.format;
273
- const dryRun = flags.dryRun || false;
274
-
275
- const spinner = new Spinner(`${dryRun ? 'Planning' : 'Organizing'} ${c('cyan', targetPath)}...`);
276
- if (format === 'table' && !flags.quiet) spinner.start();
277
-
278
- try {
279
- const result = organize(targetPath, {
280
- dryRun,
281
- naming: flags.naming || config.organize.naming,
282
- outputDir: flags.outputDir || null,
283
- duplicateStrategy: flags.duplicates || config.organize.duplicates,
284
- scanOptions: {
285
- maxDepth: flags.depth || config.organize.maxDepth,
286
- includeHidden: config.organize.includeHidden,
287
- ignore: config.organize.ignore,
288
- extensions: flags.extensions || null,
289
- },
290
- onProgress: format === 'table' ? (p) => {
291
- spinner.update(`${dryRun ? 'Planning' : 'Organizing'} ${p.percent}% — ${p.file}`);
292
- } : null,
293
- });
294
-
295
- spinner.stop();
296
-
297
- if (flags.quiet) {
298
- process.exit(0);
299
- return;
300
- }
301
-
302
- console.log(reporter.formatOrganizeReport(result, format));
303
-
304
- if (!dryRun && result.execSummary) {
305
- const exec = result.execSummary;
306
- if (exec.failed > 0) process.exit(1);
307
- }
308
- } catch (err) {
309
- spinner.fail(err.message);
310
- process.exit(1);
311
- }
312
- }
313
-
314
- async function cmdClean(target, flags, config) {
315
- const targetPath = path.resolve(target || '.');
316
- const format = flags.format || config.output.format;
317
- const dryRun = flags.dryRun || !flags.yes;
318
-
319
- // If --yes not passed and not --dry-run, force dry run
320
- if (!flags.yes && !flags.dryRun) {
321
- console.log(warn('Running in dry-run mode. Use --yes to actually delete files.'));
322
- }
323
-
324
- const spinner = new Spinner(`Scanning for junk in ${c('cyan', targetPath)}...`);
325
- if (format === 'table' && !flags.quiet) spinner.start();
326
-
327
- try {
328
- const result = clean(targetPath, {
329
- dryRun,
330
- categories: flags.category || config.clean.categories,
331
- maxDepth: flags.depth || config.clean.maxDepth,
332
- includeDirectories: config.clean.includeDirectories,
333
- onProgress: format === 'table' ? (p) => {
334
- spinner.update(`${p.phase === 'scanning' ? 'Scanning' : 'Deleting'} ${p.current || ''}...`);
335
- } : null,
336
- });
337
-
338
- spinner.stop();
339
-
340
- if (flags.quiet) {
341
- process.exit(result.junk.length > 0 ? 0 : 1);
342
- return;
343
- }
344
-
345
- console.log(reporter.formatCleanReport(result, format));
346
- } catch (err) {
347
- spinner.fail(err.message);
348
- process.exit(1);
349
- }
350
- }
351
-
352
- async function cmdWatch(target, flags, config) {
353
- // Pro feature gate
354
- const gate = checkProFeature('watch', 'Watch Mode');
355
- if (!gate.allowed) {
356
- console.log(gate.message);
357
- process.exit(0);
358
- }
359
-
360
- const targetPath = path.resolve(target || '.');
361
- const format = flags.format || config.output.format;
362
-
363
- console.log(banner());
364
- console.log(info(`Watching ${c('cyan', targetPath)} for changes...`));
365
- console.log(c('dim', ' Press Ctrl+C to stop\n'));
366
-
367
- const watcher = new FileWatcher({
368
- directories: [targetPath, ...(config.watch.directories || [])],
369
- rules: config.watch.rules || [],
370
- debounceMs: config.watch.debounceMs || 500,
371
- recursive: flags.recursive !== false,
372
- autoOrganize: config.watch.autoOrganize || false,
373
- organizeOptions: {
374
- naming: config.organize.naming,
375
- duplicateStrategy: config.organize.duplicates,
376
- },
377
- });
378
-
379
- watcher.on('watching', (data) => {
380
- console.log(success(`Watching: ${data.directory}`));
381
- });
382
-
383
- watcher.on('change', (event) => {
384
- if (format === 'json') {
385
- console.log(JSON.stringify(event));
386
- } else {
387
- const action = event.exists
388
- ? c('green', event.type === 'rename' ? 'NEW' : 'CHG')
389
- : c('red', 'DEL');
390
- const cat = event.category ? c('dim', ` [${event.category}]`) : '';
391
- console.log(` ${action} ${event.filename}${cat} ${c('dim', event.sizeHuman)}`);
392
- }
393
- });
394
-
395
- watcher.on('action', (data) => {
396
- console.log(` ${c('yellow', '→')} ${data.action}: ${path.basename(data.source)} → ${data.destination || ''}`);
397
- });
398
-
399
- watcher.on('error', (err) => {
400
- console.error(error(err.error || err.message));
401
- });
402
-
403
- watcher.start();
404
-
405
- // Handle shutdown
406
- process.on('SIGINT', () => {
407
- console.log('\n');
408
- watcher.stop();
409
- const stats = watcher.getStats();
410
- console.log(info(`Processed ${stats.eventsProcessed} events, ${stats.filesActioned} actions`));
411
- process.exit(0);
412
- });
413
- }
414
-
415
- async function cmdInit(flags) {
416
- try {
417
- const configPath = createConfigFile(process.cwd());
418
- console.log(success(`Created ${c('cyan', configPath)}`));
419
- console.log(c('dim', ' Edit this file to customize FileMayor settings'));
420
- } catch (err) {
421
- console.error(error(err.message));
422
- process.exit(1);
423
- }
424
- }
425
-
426
- async function cmdUndo(target, flags) {
427
- const targetPath = path.resolve(target || '.');
428
- const journalPath = path.join(targetPath, '.filemayor-journal.json');
429
-
430
- const journal = loadJournal(journalPath);
431
- if (journal.length === 0) {
432
- console.log(info('No operations to undo'));
433
- return;
434
- }
435
-
436
- console.log(info(`Found ${journal.length} operations to undo`));
437
-
438
- const spinner = new Spinner('Undoing operations...');
439
- spinner.start();
440
-
441
- const result = rollback(journal, {
442
- onProgress: (p) => {
443
- spinner.update(`Undoing ${p.percent}% — ${p.file}`);
444
- }
445
- });
446
-
447
- spinner.stop();
448
- console.log(success(`Undone ${result.undone} operations`));
449
- if (result.failed > 0) {
450
- console.log(warn(`${result.failed} operations could not be undone`));
451
- for (const err of result.errors) {
452
- console.log(c('dim', ` ${err}`));
453
- }
454
- }
455
-
456
- // Clear journal
457
- try {
458
- fs.writeFileSync(journalPath, '[]', 'utf8');
459
- } catch { /* ignore */ }
460
- }
461
-
462
- async function cmdInfo(config) {
463
- console.log(banner());
464
- console.log(` ${c('bold', 'Version')} ${VERSION}`);
465
- console.log(` ${c('bold', 'Node')} ${process.version}`);
466
- console.log(` ${c('bold', 'Platform')} ${os.platform()} ${os.arch()}`);
467
- console.log(` ${c('bold', 'OS')} ${os.type()} ${os.release()}`);
468
- console.log(` ${c('bold', 'Home')} ${os.homedir()}`);
469
- console.log(` ${c('bold', 'CWD')} ${process.cwd()}`);
470
-
471
- // Config info
472
- console.log(` ${c('bold', 'Config')} ${config._source || 'defaults'}`);
473
-
474
- // Categories
475
- const cats = getCategories();
476
- const catCount = Object.keys(cats).length;
477
- const extCount = Object.values(cats).reduce((sum, cat) => sum + cat.extensions.length, 0);
478
- console.log(` ${c('bold', 'Categories')} ${catCount} categories, ${extCount} extensions`);
479
-
480
- // Permissions
481
- const perms = checkPermissions(process.cwd());
482
- const permStr = `${perms.read ? c('green', 'R') : c('red', '-')}${perms.write ? c('green', 'W') : c('red', '-')}${perms.execute ? c('green', 'X') : c('red', '-')}`;
483
- console.log(` ${c('bold', 'Permissions')} ${permStr} (current directory)`);
484
-
485
- console.log('');
486
- }
487
-
488
- // ─── License Command ──────────────────────────────────────────────
489
-
490
- async function cmdLicense(action, positional, flags) {
491
- const subAction = action || 'status';
492
-
493
- switch (subAction) {
494
- case 'activate': {
495
- const key = positional[0] || flags.key;
496
- if (!key) {
497
- console.error(error('Usage: filemayor license activate <key>'));
498
- console.log(c('dim', ' Example: filemayor license activate FM-PRO-XXXX-XXXX-XXXXX'));
499
- process.exit(1);
500
- }
501
- const result = activateLicense(key);
502
- if (result.success) {
503
- console.log(success(result.message));
504
- console.log(c('dim', ` Tier: ${result.tier}`));
505
- } else {
506
- console.error(error(result.message));
507
- process.exit(1);
508
- }
509
- break;
510
- }
511
-
512
- case 'deactivate':
513
- case 'remove': {
514
- const result = deactivateLicense();
515
- if (result.success) {
516
- console.log(success(result.message));
517
- } else {
518
- console.log(info(result.message));
519
- }
520
- break;
521
- }
522
-
523
- case 'status':
524
- default: {
525
- const li = getLicenseInfo();
526
- console.log(banner());
527
- console.log(c('bold', ' License Status'));
528
- console.log(c('dim', ` ${'─'.repeat(40)}`));
529
- console.log(` ${c('bold', 'Tier:')} ${li.active ? c('green', li.name) : c('yellow', 'Free')}`);
530
- console.log(` ${c('bold', 'Status:')} ${li.active ? c('green', '● Active') : c('dim', '○ No license')}`);
531
- if (li.key) {
532
- const masked = li.key.substring(0, 7) + '****' + li.key.substring(li.key.length - 5);
533
- console.log(` ${c('bold', 'Key:')} ${c('dim', masked)}`);
534
- }
535
- if (li.activatedAt) {
536
- console.log(` ${c('bold', 'Activated:')} ${c('dim', new Date(li.activatedAt).toLocaleDateString())}`);
537
- }
538
- console.log('');
539
- console.log(c('bold', ' Features'));
540
- console.log(c('dim', ` ${''.repeat(40)}`));
541
- const allFeatures = [
542
- { id: 'scan', label: 'Directory Scanning', free: true },
543
- { id: 'organize', label: 'File Organization', free: true },
544
- { id: 'clean', label: 'Junk Cleanup', free: true },
545
- { id: 'undo', label: 'Undo Operations', free: true },
546
- { id: 'watch', label: 'Watch Mode', free: false },
547
- { id: 'sop-ai', label: 'AI SOP Parsing', free: false },
548
- { id: 'bulk-organize', label: 'Bulk Organize (50+ files)', free: false },
549
- { id: 'csv-export', label: 'CSV Export', free: false },
550
- ];
551
- for (const feat of allFeatures) {
552
- const has = li.features.includes('*') || li.features.includes(feat.id);
553
- const icon = has ? c('green', '✓') : c('dim', '○');
554
- const label = has ? feat.label : c('dim', feat.label);
555
- const tag = !feat.free && !has ? c('magenta', ' [PRO]') : '';
556
- console.log(` ${icon} ${label}${tag}`);
557
- }
558
- console.log('');
559
- if (!li.active) {
560
- console.log(c('dim', ' Get a license: https://filemayor.lemonsqueezy.com/checkout/buy/7fdcc87f-0660-4c1c-b3db-99f94773b71a'));
561
- console.log(c('dim', ' Activate: filemayor license activate <key>'));
562
- console.log('');
563
- }
564
- break;
565
- }
566
- }
567
- }
568
-
569
- // ─── Main Entry Point ─────────────────────────────────────────────
570
-
571
- async function main() {
572
- const args = parseArgs(process.argv);
573
-
574
- // Version
575
- if (args.flags.version) {
576
- console.log(VERSION);
577
- return;
578
- }
579
-
580
- // Colors
581
- if (args.flags.noColor || process.env.NO_COLOR) {
582
- reporter.setColors(false);
583
- }
584
-
585
- // Load config
586
- let config;
587
- try {
588
- const configResult = loadConfig({
589
- configPath: args.flags.config || null,
590
- cliOverrides: {},
591
- });
592
- config = configResult.config;
593
- config._source = configResult.source;
594
-
595
- // Show validation warnings
596
- if (configResult.validation.warnings.length > 0 && args.flags.verbose) {
597
- for (const w of configResult.validation.warnings) {
598
- console.error(warn(`Config: ${w}`));
599
- }
600
- }
601
- if (!configResult.validation.valid) {
602
- for (const e of configResult.validation.errors) {
603
- console.error(error(`Config: ${e}`));
604
- }
605
- }
606
- } catch (err) {
607
- console.error(error(`Config error: ${err.message}`));
608
- process.exit(1);
609
- }
610
-
611
- // No command — show help
612
- if (!args.command || args.flags.help) {
613
- printHelp();
614
- return;
615
- }
616
-
617
- // Route commands
618
- switch (args.command) {
619
- case 'scan':
620
- case 's':
621
- await cmdScan(args.target, args.flags, config);
622
- break;
623
-
624
- case 'analyze':
625
- case 'a':
626
- await cmdAnalyze(args.target, args.flags, config);
627
- break;
628
-
629
- case 'organize':
630
- case 'org':
631
- case 'o':
632
- await cmdOrganize(args.target, args.flags, config);
633
- break;
634
-
635
- case 'clean':
636
- case 'cl':
637
- case 'c':
638
- await cmdClean(args.target, args.flags, config);
639
- break;
640
-
641
- case 'watch':
642
- case 'w':
643
- await cmdWatch(args.target, args.flags, config);
644
- break;
645
-
646
- case 'init':
647
- case 'i':
648
- await cmdInit(args.flags);
649
- break;
650
-
651
- case 'undo':
652
- case 'u':
653
- await cmdUndo(args.target, args.flags);
654
- break;
655
-
656
- case 'info':
657
- await cmdInfo(config);
658
- break;
659
-
660
- case 'license':
661
- case 'lic':
662
- case 'l':
663
- await cmdLicense(args.target, args.positional, args.flags);
664
- break;
665
-
666
- case 'help':
667
- printHelp();
668
- break;
669
-
670
- default:
671
- console.error(error(`Unknown command: "${args.command}"`));
672
- console.log(c('dim', 'Run "filemayor --help" for usage'));
673
- process.exit(1);
674
- }
675
- }
676
-
677
- // ─── Run ──────────────────────────────────────────────────────────
678
-
679
- main().catch(err => {
680
- console.error(error(err.message));
681
- if (process.env.DEBUG) {
682
- console.error(err.stack);
683
- }
684
- process.exit(1);
685
- });
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ *
6
+ * ███████╗██╗██╗ ███████╗███╗ ███╗ █████╗ ██╗ ██╗ ██████╗ ██████╗
7
+ * ██╔════╝██║██║ ██╔════╝████╗ ████║██╔══██╗╚██╗ ██╔╝██╔═══██╗██╔══██╗
8
+ * █████╗ ██║██║ █████╗ ██╔████╔██║███████║ ╚████╔╝ ██║ ██║██████╔╝
9
+ * ██╔══╝ ██║██║ ██╔══╝ ██║╚██╔╝██║██╔══██║ ╚██╔╝ ██║ ██║██╔══██╗
10
+ * ██║ ██║███████╗███████╗██║ ╚═╝ ██║██║ ██║ ██║ ╚██████╔╝██║ ██║
11
+ * ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
12
+ *
13
+ * Your Digital Life Organizer — CLI Edition
14
+ * Created by Lehlohonolo Goodwill Nchefu (Chevza)
15
+ * Works everywhere: terminals, servers, data centers, CI/CD
16
+ *
17
+ * ═══════════════════════════════════════════════════════════════════
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ // ─── Load .env (zero-dependency) ─────────────────────────────────
23
+ try {
24
+ const _fs = require('fs');
25
+ const _path = require('path');
26
+ const envPath = _path.join(__dirname, '..', '.env');
27
+ _fs.readFileSync(envPath, 'utf-8').split('\n').forEach(line => {
28
+ const trimmed = line.trim();
29
+ if (trimmed && !trimmed.startsWith('#')) {
30
+ const [key, ...vals] = trimmed.split('=');
31
+ if (key && vals.length && !process.env[key.trim()]) {
32
+ process.env[key.trim()] = vals.join('=').trim();
33
+ }
34
+ }
35
+ });
36
+ } catch { /* .env not found — that's fine, user may set env vars directly */ }
37
+
38
+ const path = require('path');
39
+ const os = require('os');
40
+ const fs = require('fs');
41
+
42
+ // Core modules
43
+ const { scan, scanByCategory, scanSummary } = require('./core/scanner');
44
+ const { organize, generatePlan, rollback, loadJournal } = require('./core/organizer');
45
+ const { findJunk, clean } = require('./core/cleaner');
46
+ const { FileWatcher } = require('./core/watcher');
47
+ const { loadConfig, createConfigFile } = require('./core/config');
48
+ const { analyzeDirectory } = require('./core/analyzer');
49
+ const reporter = require('./core/reporter');
50
+ const { formatBytes } = require('./core/scanner');
51
+ const { getCategories } = require('./core/categories');
52
+ const { checkPermissions } = require('./core/security');
53
+ const { activateLicense, deactivateLicense, getLicenseInfo, checkProFeature, checkBulkLimit } = require('./core/license');
54
+ const { ExplainEngine, CureEngine, ApplyEngine, DedupeEngine } = require('./core/index');
55
+ const { ping: telemetryPing } = require('./core/telemetry');
56
+
57
+ const { c, banner, Spinner, success, error, warn, info } = reporter;
58
+
59
+ // ─── Version ──────────────────────────────────────────────────────
60
+ const VERSION = '4.0.5';
61
+
62
+ // ─── Path Helpers ─────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Expand ~ to user home directory (cross-platform).
66
+ * On Unix shells, ~ is expanded by the shell before reaching Node.
67
+ * On Windows PowerShell, it is NOT — so we handle it here.
68
+ */
69
+ function expandTilde(p) {
70
+ if (!p) return p;
71
+ if (p === '~') return os.homedir();
72
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
73
+ return path.join(os.homedir(), p.slice(2));
74
+ }
75
+ return p;
76
+ }
77
+
78
+ // ─── Argument Parser ──────────────────────────────────────────────
79
+
80
+ function parseArgs(argv) {
81
+ const args = {
82
+ command: null,
83
+ target: null,
84
+ flags: {},
85
+ positional: []
86
+ };
87
+
88
+ const raw = argv.slice(2);
89
+ let i = 0;
90
+
91
+ while (i < raw.length) {
92
+ const arg = raw[i];
93
+
94
+ if (arg === '--help' || arg === '-h') {
95
+ args.flags.help = true;
96
+ } else if (arg === '--version' || arg === '-V') {
97
+ args.flags.version = true;
98
+ } else if (arg === '--dry-run') {
99
+ args.flags.dryRun = true;
100
+ } else if (arg === '--json') {
101
+ args.flags.format = 'json';
102
+ } else if (arg === '--csv') {
103
+ args.flags.format = 'csv';
104
+ } else if (arg === '--minimal') {
105
+ args.flags.format = 'minimal';
106
+ } else if (arg === '--yes' || arg === '-y') {
107
+ args.flags.yes = true;
108
+ } else if (arg === '--verbose' || arg === '-v') {
109
+ args.flags.verbose = true;
110
+ } else if (arg === '--quiet' || arg === '-q') {
111
+ args.flags.quiet = true;
112
+ } else if (arg === '--no-color') {
113
+ args.flags.noColor = true;
114
+ } else if (arg === '--recursive' || arg === '-r') {
115
+ args.flags.recursive = true;
116
+ } else if (arg === '--depth' && raw[i + 1]) {
117
+ args.flags.depth = parseInt(raw[++i], 10);
118
+ } else if (arg === '--naming' && raw[i + 1]) {
119
+ args.flags.naming = raw[++i];
120
+ } else if (arg === '--output' && raw[i + 1]) {
121
+ args.flags.outputDir = raw[++i];
122
+ } else if (arg === '--config' && raw[i + 1]) {
123
+ args.flags.config = raw[++i];
124
+ } else if (arg === '--duplicates' && raw[i + 1]) {
125
+ args.flags.duplicates = raw[++i];
126
+ } else if (arg === '--extensions' && raw[i + 1]) {
127
+ args.flags.extensions = raw[++i].split(',');
128
+ } else if (arg === '--category' && raw[i + 1]) {
129
+ args.flags.category = raw[++i].split(',');
130
+ } else if (arg === '--min-size' && raw[i + 1]) {
131
+ args.flags.minSize = parseSizeArg(raw[++i]);
132
+ } else if (arg === '--max-size' && raw[i + 1]) {
133
+ args.flags.maxSize = parseSizeArg(raw[++i]);
134
+ } else if (arg.startsWith('--')) {
135
+ // Unknown flag with potential value
136
+ const key = arg.slice(2).replace(/-([a-z])/g, (_, l) => l.toUpperCase());
137
+ if (raw[i + 1] && !raw[i + 1].startsWith('-')) {
138
+ args.flags[key] = raw[++i];
139
+ } else {
140
+ args.flags[key] = true;
141
+ }
142
+ } else if (arg.startsWith('-') && arg.length === 2) {
143
+ args.flags[arg.slice(1)] = true;
144
+ } else if (!args.command) {
145
+ args.command = arg;
146
+ } else if (!args.target) {
147
+ args.target = expandTilde(arg);
148
+ } else {
149
+ args.positional.push(arg);
150
+ }
151
+
152
+ i++;
153
+ }
154
+
155
+ return args;
156
+ }
157
+
158
+ function parseSizeArg(str) {
159
+ const match = str.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/i);
160
+ if (!match) return 0;
161
+ const num = parseFloat(match[1]);
162
+ const unit = (match[2] || 'b').toLowerCase();
163
+ const multipliers = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3, tb: 1024 ** 4 };
164
+ return Math.floor(num * (multipliers[unit] || 1));
165
+ }
166
+
167
+ // ─── Help Text ────────────────────────────────────────────────────
168
+
169
+ function printHelp() {
170
+ console.log(`
171
+ ${banner()}
172
+ ${c('bold', 'USAGE')}
173
+ ${c('cyan', 'filemayor')} ${c('yellow', '<command>')} ${c('dim', '[path]')} ${c('dim', '[options]')}
174
+
175
+ ${c('yellow', 'explain')} ${c('dim', '<path>')} Diagnose folder health
176
+ ${c('yellow', 'cure')} ${c('dim', '<path>')} Plan treatment using AI
177
+ ${c('yellow', 'apply')} Execute the cure plan
178
+ ${c('yellow', 'duplicates')} ${c('dim', '<path>')} Find duplicate files
179
+ ${c('yellow', 'dedupe')} ${c('dim', '<path>')} Remove duplicate files
180
+
181
+ ${c('bold', 'COMMANDS')}
182
+ ${c('yellow', 'scan')} ${c('dim', '<path>')} Scan directory and report contents
183
+ ${c('yellow', 'analyze')} ${c('dim', '<path>')} Deep analysis (duplicates, bloat, savings)
184
+ ${c('yellow', 'organize')} ${c('dim', '<path>')} Organize files into categories
185
+ ${c('yellow', 'clean')} ${c('dim', '<path>')} Find and remove junk files
186
+ ${c('yellow', 'watch')} ${c('dim', '<path>')} Watch directory for changes
187
+ ${c('yellow', 'init')} Create .filemayor.yml config
188
+ ${c('yellow', 'undo')} ${c('dim', '<path>')} Undo last organization
189
+ ${c('yellow', 'info')} System info and version
190
+ ${c('yellow', 'license')} ${c('dim', '<action>')} Manage license (activate|status|deactivate)
191
+
192
+ ${c('bold', 'OPTIONS')}
193
+ ${c('dim', '--dry-run')} Preview changes without executing
194
+ ${c('dim', '--json')} Output as JSON
195
+ ${c('dim', '--csv')} Output as CSV
196
+ ${c('dim', '--minimal')} Minimal output (paths only)
197
+ ${c('dim', '--yes, -y')} Skip confirmation prompts
198
+ ${c('dim', '--verbose, -v')} Detailed logging
199
+ ${c('dim', '--quiet, -q')} Suppress output (exit code only)
200
+ ${c('dim', '--no-color')} Disable colored output
201
+ ${c('dim', '--config <path>')} Custom config file
202
+ ${c('dim', '--depth <n>')} Max recursion depth
203
+ ${c('dim', '--naming <type>')} Naming: original|category_prefix|date_prefix|clean
204
+ ${c('dim', '--duplicates <s>')} Duplicates: rename|skip|overwrite
205
+ ${c('dim', '--extensions <e>')} Filter by extensions (comma-separated)
206
+ ${c('dim', '--output <path>')} Output directory for organized files
207
+ ${c('dim', '--category <c>')} Filter by category (comma-separated)
208
+ ${c('dim', '--min-size <s>')} Minimum file size (e.g., 1mb)
209
+ ${c('dim', '--max-size <s>')} Maximum file size (e.g., 100mb)
210
+ ${c('dim', '--help, -h')} Show this help
211
+ ${c('dim', '--version, -V')} Show version
212
+
213
+ ${c('bold', 'EXAMPLES')}
214
+ ${c('dim', '$')} filemayor scan ~/Downloads
215
+ ${c('dim', '$')} filemayor organize ~/Downloads --dry-run
216
+ ${c('dim', '$')} filemayor organize ~/Downloads --naming category_prefix
217
+ ${c('dim', '$')} filemayor clean /var/tmp --yes
218
+ ${c('dim', '$')} filemayor scan . --json | jq '.files | length'
219
+ ${c('dim', '$')} filemayor watch ~/Downloads
220
+ ${c('dim', '$')} filemayor init
221
+
222
+ ${c('bold', 'INSTALL')}
223
+ ${c('dim', '$')} npm install -g filemayor
224
+
225
+ ${c('dim', `FileMayor v${VERSION} https://filemayor.com`)}
226
+ `);
227
+ }
228
+
229
+ // ─── Commands ─────────────────────────────────────────────────────
230
+
231
+ async function cmdScan(target, flags, config) {
232
+ const targetPath = path.resolve(target || '.');
233
+ const format = flags.format || config.output.format;
234
+
235
+ const spinner = new Spinner(`Scanning ${c('cyan', targetPath)}...`);
236
+ if (format === 'table' && !flags.quiet) spinner.start();
237
+
238
+ try {
239
+ const options = {
240
+ maxDepth: flags.depth || config.scanner.maxDepth,
241
+ includeHidden: config.scanner.includeHidden,
242
+ ignore: config.organize.ignore,
243
+ extensions: flags.extensions || null,
244
+ minSize: flags.minSize || config.scanner.minSize,
245
+ maxSize: flags.maxSize || config.scanner.maxSize || Infinity,
246
+ followSymlinks: config.scanner.followSymlinks,
247
+ };
248
+
249
+ const result = scan(targetPath, options);
250
+
251
+ spinner.stop();
252
+
253
+ if (flags.quiet) {
254
+ process.exit(result.files.length > 0 ? 0 : 1);
255
+ return;
256
+ }
257
+
258
+ console.log(reporter.formatScanReport(result, format));
259
+ } catch (err) {
260
+ spinner.fail(err.message);
261
+ process.exit(1);
262
+ }
263
+ }
264
+
265
+ async function cmdAnalyze(target, flags, config) {
266
+ const targetPath = path.resolve(target || '.');
267
+ const format = flags.format || config.output.format;
268
+
269
+ const spinner = new Spinner(`Analyzing ${c('cyan', targetPath)}...`);
270
+ if (format === 'table' && !flags.quiet) spinner.start();
271
+
272
+ try {
273
+ const options = {
274
+ maxDepth: flags.depth || config.scanner.maxDepth,
275
+ minSize: flags.minSize || 1024,
276
+ };
277
+
278
+ const result = analyzeDirectory(targetPath, options);
279
+
280
+ spinner.stop();
281
+
282
+ if (flags.quiet) {
283
+ process.exit(result.summary.potentialSavings > 0 ? 0 : 1);
284
+ return;
285
+ }
286
+
287
+ console.log(reporter.formatAnalyzeReport(result, format));
288
+ } catch (err) {
289
+ spinner.fail(err.message);
290
+ process.exit(1);
291
+ }
292
+ }
293
+
294
+ async function cmdOrganize(target, flags, config) {
295
+ const targetPath = path.resolve(target || '.');
296
+ const format = flags.format || config.output.format;
297
+ const dryRun = flags.dryRun || false;
298
+
299
+ const spinner = new Spinner(`${dryRun ? 'Planning' : 'Organizing'} ${c('cyan', targetPath)}...`);
300
+ if (format === 'table' && !flags.quiet) spinner.start();
301
+
302
+ try {
303
+ // Bulk limit check — generate plan first to count operations
304
+ if (!dryRun) {
305
+ const preview = await organize(targetPath, {
306
+ dryRun: true,
307
+ naming: flags.naming || config.organize.naming,
308
+ outputDir: flags.outputDir || null,
309
+ duplicateStrategy: flags.duplicates || config.organize.duplicates,
310
+ scanOptions: {
311
+ maxDepth: flags.depth || config.organize.maxDepth,
312
+ includeHidden: config.organize.includeHidden,
313
+ ignore: config.organize.ignore,
314
+ extensions: flags.extensions || null,
315
+ },
316
+ });
317
+ const opCount = preview?.plan?.length || 0;
318
+ const gate = checkBulkLimit(opCount);
319
+ if (!gate.allowed) {
320
+ spinner.stop();
321
+ console.log('\n' + gate.message);
322
+ process.exit(0);
323
+ }
324
+ }
325
+
326
+ const result = await organize(targetPath, {
327
+ dryRun,
328
+ naming: flags.naming || config.organize.naming,
329
+ outputDir: flags.outputDir || null,
330
+ duplicateStrategy: flags.duplicates || config.organize.duplicates,
331
+ scanOptions: {
332
+ maxDepth: flags.depth || config.organize.maxDepth,
333
+ includeHidden: config.organize.includeHidden,
334
+ ignore: config.organize.ignore,
335
+ extensions: flags.extensions || null,
336
+ },
337
+ onProgress: format === 'table' ? (p) => {
338
+ spinner.update(`${dryRun ? 'Planning' : 'Organizing'} ${p.percent}% — ${p.file}`);
339
+ } : null,
340
+ });
341
+
342
+ spinner.stop();
343
+
344
+ if (flags.quiet) {
345
+ process.exit(0);
346
+ return;
347
+ }
348
+
349
+ console.log(reporter.formatOrganizeReport(result, format));
350
+
351
+ if (!dryRun && result.execSummary) {
352
+ const exec = result.execSummary;
353
+ if (exec.failed > 0) process.exit(1);
354
+ }
355
+ } catch (err) {
356
+ spinner.fail(err.message);
357
+ process.exit(1);
358
+ }
359
+ }
360
+
361
+ async function cmdClean(target, flags, config) {
362
+ const targetPath = path.resolve(target || '.');
363
+ const format = flags.format || config.output.format;
364
+ const dryRun = flags.dryRun || !flags.yes;
365
+
366
+ // If --yes not passed and not --dry-run, force dry run
367
+ if (!flags.yes && !flags.dryRun) {
368
+ console.log(warn('Running in dry-run mode. Use --yes to actually delete files.'));
369
+ }
370
+
371
+ const spinner = new Spinner(`Scanning for junk in ${c('cyan', targetPath)}...`);
372
+ if (format === 'table' && !flags.quiet) spinner.start();
373
+
374
+ try {
375
+ const result = clean(targetPath, {
376
+ dryRun,
377
+ categories: flags.category || config.clean.categories,
378
+ maxDepth: flags.depth || config.clean.maxDepth,
379
+ includeDirectories: config.clean.includeDirectories,
380
+ onProgress: format === 'table' ? (p) => {
381
+ spinner.update(`${p.phase === 'scanning' ? 'Scanning' : 'Deleting'} ${p.current || ''}...`);
382
+ } : null,
383
+ });
384
+
385
+ spinner.stop();
386
+
387
+ if (flags.quiet) {
388
+ process.exit(result.junk.length > 0 ? 0 : 1);
389
+ return;
390
+ }
391
+
392
+ console.log(reporter.formatCleanReport(result, format));
393
+ } catch (err) {
394
+ spinner.fail(err.message);
395
+ process.exit(1);
396
+ }
397
+ }
398
+
399
+ async function cmdWatch(target, flags, config) {
400
+ const targetPath = path.resolve(target || '.');
401
+ const format = flags.format || config.output.format;
402
+
403
+ console.log(banner());
404
+ console.log(info(`Watching ${c('cyan', targetPath)} for changes...`));
405
+ console.log(c('dim', ' Press Ctrl+C to stop\n'));
406
+
407
+ const watcher = new FileWatcher({
408
+ directories: [targetPath, ...(config.watch.directories || [])],
409
+ rules: config.watch.rules || [],
410
+ debounceMs: config.watch.debounceMs || 500,
411
+ recursive: flags.recursive !== false,
412
+ autoOrganize: config.watch.autoOrganize || false,
413
+ organizeOptions: {
414
+ naming: config.organize.naming,
415
+ duplicateStrategy: config.organize.duplicates,
416
+ },
417
+ });
418
+
419
+ watcher.on('watching', (data) => {
420
+ console.log(success(`Watching: ${data.directory}`));
421
+ });
422
+
423
+ watcher.on('change', (event) => {
424
+ if (format === 'json') {
425
+ console.log(JSON.stringify(event));
426
+ } else {
427
+ const action = event.exists
428
+ ? c('green', event.type === 'rename' ? 'NEW' : 'CHG')
429
+ : c('red', 'DEL');
430
+ const cat = event.category ? c('dim', ` [${event.category}]`) : '';
431
+ console.log(` ${action} ${event.filename}${cat} ${c('dim', event.sizeHuman)}`);
432
+ }
433
+ });
434
+
435
+ watcher.on('action', (data) => {
436
+ console.log(` ${c('yellow', '→')} ${data.action}: ${path.basename(data.source)} ${data.destination || ''}`);
437
+ });
438
+
439
+ watcher.on('error', (err) => {
440
+ console.error(error(err.error || err.message));
441
+ });
442
+
443
+ watcher.start();
444
+
445
+ // Handle shutdown
446
+ process.on('SIGINT', () => {
447
+ console.log('\n');
448
+ watcher.stop();
449
+ const stats = watcher.getStats();
450
+ console.log(info(`Processed ${stats.eventsProcessed} events, ${stats.filesActioned} actions`));
451
+ process.exit(0);
452
+ });
453
+ }
454
+
455
+ async function cmdInit(flags) {
456
+ try {
457
+ const configPath = createConfigFile(process.cwd());
458
+ console.log(success(`Created ${c('cyan', configPath)}`));
459
+ console.log(c('dim', ' Edit this file to customize FileMayor settings'));
460
+ } catch (err) {
461
+ console.error(error(err.message));
462
+ process.exit(1);
463
+ }
464
+ }
465
+
466
+ async function cmdUndo(target, flags) {
467
+ const { JOURNAL_PATH, rollback: fsRollback } = require('./core/fs-abstraction');
468
+
469
+ if (!fs.existsSync(JOURNAL_PATH)) {
470
+ console.log(info('No operations to undo (journal is empty)'));
471
+ return;
472
+ }
473
+
474
+ const spinner = new Spinner('Undoing operations...');
475
+ spinner.start();
476
+
477
+ try {
478
+ const result = await fsRollback(JOURNAL_PATH);
479
+ spinner.stop();
480
+ if (result.count === 0) {
481
+ console.log(info('Nothing to undo — no completed operations found in the journal'));
482
+ } else {
483
+ console.log(success(`Undone ${result.count} operation${result.count === 1 ? '' : 's'}`));
484
+ }
485
+ } catch (err) {
486
+ spinner.fail(err.message);
487
+ process.exit(1);
488
+ }
489
+ }
490
+
491
+ // ==================== DIAGNOSTIC TRIAD (v3.5) ====================
492
+
493
+ let activePlanState = null;
494
+
495
+ async function cmdExplain(target, flags) {
496
+ const targetPath = path.resolve(target || '.');
497
+ const spinner = new Spinner(`Diagnosing ${c('cyan', targetPath)}...`);
498
+ spinner.start();
499
+
500
+ try {
501
+ const engine = new ExplainEngine();
502
+ const result = engine.run(targetPath);
503
+ spinner.stop();
504
+ console.log(reporter.formatExplainReport(result));
505
+ } catch (err) {
506
+ spinner.fail(err.message);
507
+ process.exit(1);
508
+ }
509
+ }
510
+
511
+ async function cmdCure(target, flags) {
512
+ const targetPath = path.resolve(target || '.');
513
+ const prompt = flags.prompt || 'Clean up this folder and group similar files.';
514
+
515
+ if (!process.env.GEMINI_API_KEY) {
516
+ console.log(error('GEMINI_API_KEY environment variable is required for AI curative features.'));
517
+ process.exit(1);
518
+ }
519
+
520
+ const spinner = new Spinner(`Consulting AI for ${c('cyan', targetPath)}...`);
521
+ spinner.start();
522
+
523
+ try {
524
+ const engine = new CureEngine(targetPath, process.env.GEMINI_API_KEY);
525
+ activePlanState = await engine.generatePlan(prompt);
526
+ spinner.stop();
527
+
528
+ // Cache plan locally for 'apply' command
529
+ const cachePath = path.join(os.tmpdir(), 'filemayor-plan.json');
530
+ fs.writeFileSync(cachePath, JSON.stringify(activePlanState), 'utf8');
531
+
532
+ console.log(reporter.formatCureReport(activePlanState));
533
+ } catch (err) {
534
+ spinner.fail(err.message);
535
+ process.exit(1);
536
+ }
537
+ }
538
+
539
+ async function cmdApply(flags) {
540
+ const cachePath = path.join(os.tmpdir(), 'filemayor-plan.json');
541
+ if (!fs.existsSync(cachePath)) {
542
+ console.log(error('No plan found. Run "filemayor cure" first.'));
543
+ process.exit(1);
544
+ }
545
+
546
+ const plan = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
547
+
548
+ // [Sprint B] Logic Guardrail: Check batch volume & destructive patterns
549
+ const LogicGuardrail = require('./core/guardrail');
550
+ const { FileMayorJailer } = require('./core/jailer');
551
+ const guardrail = new LogicGuardrail(50);
552
+ const jailer = new FileMayorJailer(process.cwd());
553
+
554
+ if (plan.plan && Array.isArray(plan.plan)) {
555
+ const approved = await guardrail.verifyBatch(plan.plan);
556
+ if (!approved) {
557
+ console.log(warn('Operation cancelled by user.'));
558
+ return;
559
+ }
560
+
561
+ // [Sprint B] Jailer: Validate every move before execution
562
+ const blocked = [];
563
+ for (const step of plan.plan) {
564
+ const check = jailer.validateMove(step.source, step.destination);
565
+ if (!check.safe) {
566
+ blocked.push({ file: path.basename(step.source), reason: check.error });
567
+ }
568
+ }
569
+ if (blocked.length > 0) {
570
+ console.log(error(`Jailer blocked ${blocked.length} unsafe operations:`));
571
+ for (const b of blocked) {
572
+ console.log(c('dim', ` ✗ ${b.file}: ${b.reason}`));
573
+ }
574
+ plan.plan = plan.plan.filter(step => jailer.validateMove(step.source, step.destination).safe);
575
+ if (plan.plan.length === 0) {
576
+ console.log(error('No safe operations remain. Aborting.'));
577
+ return;
578
+ }
579
+ console.log(info(`Proceeding with ${plan.plan.length} safe operations.`));
580
+ }
581
+ }
582
+
583
+ const spinner = new Spinner(`Executing cure...`);
584
+ spinner.start();
585
+
586
+ try {
587
+ const engine = new ApplyEngine();
588
+ const result = await engine.apply(plan, (p) => {
589
+ spinner.update(`Relocating: ${p.index}/${p.total} — ${path.basename(p.current || '')}`);
590
+ });
591
+ spinner.succeed(`Cure applied! ${result.stats.success} files moved.`);
592
+ fs.unlinkSync(cachePath); // Clear plan
593
+ } catch (err) {
594
+ spinner.fail(err.message);
595
+ process.exit(1);
596
+ }
597
+ }
598
+
599
+ async function cmdDuplicates(target, flags) {
600
+ const targetPath = path.resolve(target || '.');
601
+ const spinner = new Spinner(`Hunting duplicates in ${c('cyan', targetPath)}...`);
602
+ spinner.start();
603
+
604
+ try {
605
+ const engine = new DedupeEngine();
606
+ const result = engine.find(targetPath);
607
+ spinner.stop();
608
+ console.log(reporter.formatDedupeReport(result));
609
+ } catch (err) {
610
+ spinner.fail(err.message);
611
+ process.exit(1);
612
+ }
613
+ }
614
+
615
+ async function cmdDedupe(target, flags) {
616
+ const targetPath = path.resolve(target || '.');
617
+ const spinner = new Spinner(`Deduplicating ${c('cyan', targetPath)}...`);
618
+ spinner.start();
619
+
620
+ try {
621
+ const engine = new DedupeEngine();
622
+ const report = engine.find(targetPath);
623
+ if (report.sets === 0) {
624
+ spinner.succeed('Total order restored: No duplicates found.');
625
+ return;
626
+ }
627
+
628
+ const result = await engine.clean(report);
629
+ spinner.succeed(`Purged ${result.deleted} duplicates. Reclaimed ${result.freedHuman}.`);
630
+ } catch (err) {
631
+ spinner.fail(err.message);
632
+ process.exit(1);
633
+ }
634
+ }
635
+
636
+ async function cmdInfo(config) {
637
+ console.log(banner());
638
+ console.log(` ${c('bold', 'Version')} ${VERSION}`);
639
+ console.log(` ${c('bold', 'Node')} ${process.version}`);
640
+ console.log(` ${c('bold', 'Platform')} ${os.platform()} ${os.arch()}`);
641
+ console.log(` ${c('bold', 'OS')} ${os.type()} ${os.release()}`);
642
+ console.log(` ${c('bold', 'Home')} ${os.homedir()}`);
643
+ console.log(` ${c('bold', 'CWD')} ${process.cwd()}`);
644
+
645
+ // Config info
646
+ console.log(` ${c('bold', 'Config')} ${config._source || 'defaults'}`);
647
+
648
+ // Categories
649
+ const cats = getCategories();
650
+ const catCount = Object.keys(cats).length;
651
+ const extCount = Object.values(cats).reduce((sum, cat) => sum + cat.extensions.length, 0);
652
+ console.log(` ${c('bold', 'Categories')} ${catCount} categories, ${extCount} extensions`);
653
+
654
+ // Permissions
655
+ const perms = checkPermissions(process.cwd());
656
+ const permStr = `${perms.read ? c('green', 'R') : c('red', '-')}${perms.write ? c('green', 'W') : c('red', '-')}${perms.execute ? c('green', 'X') : c('red', '-')}`;
657
+ console.log(` ${c('bold', 'Permissions')} ${permStr} (current directory)`);
658
+
659
+ // Telemetry disclosure
660
+ const telemetryOff = process.env.FILEMAYOR_NO_TELEMETRY === '1';
661
+ console.log(` ${c('bold', 'Telemetry')} ${telemetryOff ? c('dim', 'off (FILEMAYOR_NO_TELEMETRY=1)') : c('dim', 'on — anonymous version+OS+command ping once per version. Set FILEMAYOR_NO_TELEMETRY=1 to disable.')}`);
662
+
663
+ console.log('');
664
+ }
665
+
666
+ // ─── License Command ──────────────────────────────────────────────
667
+
668
+ async function cmdLicense(action, positional, flags) {
669
+ const subAction = action || 'status';
670
+
671
+ switch (subAction) {
672
+ case 'activate': {
673
+ const key = positional[0] || flags.key;
674
+ if (!key) {
675
+ console.error(error('Usage: filemayor license activate <key>'));
676
+ console.log(c('dim', ' Example: filemayor license activate FM-PRO-XXXX-XXXX-XXXXX'));
677
+ process.exit(1);
678
+ }
679
+ const result = activateLicense(key);
680
+ if (result.success) {
681
+ console.log(success(result.message));
682
+ console.log(c('dim', ` Tier: ${result.tier}`));
683
+ } else {
684
+ console.error(error(result.message));
685
+ process.exit(1);
686
+ }
687
+ break;
688
+ }
689
+
690
+ case 'deactivate':
691
+ case 'remove': {
692
+ const result = deactivateLicense();
693
+ if (result.success) {
694
+ console.log(success(result.message));
695
+ } else {
696
+ console.log(info(result.message));
697
+ }
698
+ break;
699
+ }
700
+
701
+ case 'status':
702
+ default: {
703
+ const li = getLicenseInfo();
704
+ console.log(banner());
705
+ console.log(c('bold', ' License Status'));
706
+ console.log(c('dim', ` ${'─'.repeat(40)}`));
707
+ console.log(` ${c('bold', 'Tier:')} ${li.active ? c('green', li.name) : c('yellow', 'Free')}`);
708
+ console.log(` ${c('bold', 'Status:')} ${li.active ? c('green', '● Active') : c('dim', '○ No license')}`);
709
+ if (li.key) {
710
+ const masked = li.key.substring(0, 7) + '****' + li.key.substring(li.key.length - 5);
711
+ console.log(` ${c('bold', 'Key:')} ${c('dim', masked)}`);
712
+ }
713
+ if (li.activatedAt) {
714
+ console.log(` ${c('bold', 'Activated:')} ${c('dim', new Date(li.activatedAt).toLocaleDateString())}`);
715
+ }
716
+ console.log('');
717
+ console.log(c('bold', ' Features'));
718
+ console.log(c('dim', ` ${'─'.repeat(40)}`));
719
+ const allFeatures = [
720
+ { id: 'scan', label: 'Directory Scanning', free: true },
721
+ { id: 'organize', label: 'File Organization', free: true },
722
+ { id: 'clean', label: 'Junk Cleanup', free: true },
723
+ { id: 'undo', label: 'Undo Operations', free: true },
724
+ { id: 'watch', label: 'Watch Mode', free: true },
725
+ { id: 'sop-ai', label: 'AI SOP Parsing', free: true },
726
+ { id: 'bulk-organize', label: 'Bulk Organize', free: true },
727
+ { id: 'csv-export', label: 'CSV Export', free: true },
728
+ ];
729
+ for (const feat of allFeatures) {
730
+ const has = li.features.includes('*') || li.features.includes(feat.id);
731
+ const icon = has ? c('green', '✓') : c('dim', '○');
732
+ const label = has ? feat.label : c('dim', feat.label);
733
+ const tag = !feat.free && !has ? c('magenta', ' [PRO]') : '';
734
+ console.log(` ${icon} ${label}${tag}`);
735
+ }
736
+ console.log('');
737
+ if (!li.active) {
738
+ console.log(c('dim', ' All features are free. License keys are optional: https://filemayor.com'));
739
+ console.log(c('dim', ' Activate: filemayor license activate <key>'));
740
+ console.log('');
741
+ }
742
+ break;
743
+ }
744
+ }
745
+ }
746
+
747
+ // ─── Main Entry Point ─────────────────────────────────────────────
748
+
749
+ async function main() {
750
+ const args = parseArgs(process.argv);
751
+
752
+ // Version
753
+ if (args.flags.version) {
754
+ console.log(VERSION);
755
+ return;
756
+ }
757
+
758
+ // Colors
759
+ if (args.flags.noColor || process.env.NO_COLOR) {
760
+ reporter.setColors(false);
761
+ }
762
+
763
+ // Load config
764
+ let config;
765
+ try {
766
+ const configResult = loadConfig({
767
+ configPath: args.flags.config || null,
768
+ cliOverrides: {},
769
+ });
770
+ config = configResult.config;
771
+ config._source = configResult.source;
772
+
773
+ // Show validation warnings
774
+ if (configResult.validation.warnings.length > 0 && args.flags.verbose) {
775
+ for (const w of configResult.validation.warnings) {
776
+ console.error(warn(`Config: ${w}`));
777
+ }
778
+ }
779
+ if (!configResult.validation.valid) {
780
+ for (const e of configResult.validation.errors) {
781
+ console.error(error(`Config: ${e}`));
782
+ }
783
+ }
784
+ } catch (err) {
785
+ console.error(error(`Config error: ${err.message}`));
786
+ process.exit(1);
787
+ }
788
+
789
+ // No command — show help
790
+ if (!args.command || args.flags.help) {
791
+ printHelp();
792
+ return;
793
+ }
794
+
795
+ // Anonymous first-run ping (fire-and-forget, opt-out with FILEMAYOR_NO_TELEMETRY=1)
796
+ telemetryPing(VERSION, args.command);
797
+
798
+ // Route commands
799
+ switch (args.command) {
800
+ case 'scan':
801
+ case 's':
802
+ await cmdScan(args.target, args.flags, config);
803
+ break;
804
+
805
+ case 'explain':
806
+ case 'dx':
807
+ await cmdExplain(args.target, args.flags);
808
+ break;
809
+
810
+ case 'cure':
811
+ case 'plan':
812
+ await cmdCure(args.target, args.flags);
813
+ break;
814
+
815
+ case 'apply':
816
+ case 'exec':
817
+ await cmdApply(args.flags);
818
+ break;
819
+
820
+ case 'duplicates':
821
+ case 'dupes':
822
+ await cmdDuplicates(args.target, args.flags);
823
+ break;
824
+
825
+ case 'dedupe':
826
+ await cmdDedupe(args.target, args.flags);
827
+ break;
828
+
829
+ case 'analyze':
830
+ case 'a':
831
+ await cmdAnalyze(args.target, args.flags, config);
832
+ break;
833
+
834
+ case 'organize':
835
+ case 'org':
836
+ case 'o':
837
+ await cmdOrganize(args.target, args.flags, config);
838
+ break;
839
+
840
+ case 'clean':
841
+ case 'cl':
842
+ case 'c':
843
+ await cmdClean(args.target, args.flags, config);
844
+ break;
845
+
846
+ case 'watch':
847
+ case 'w':
848
+ await cmdWatch(args.target, args.flags, config);
849
+ break;
850
+
851
+ case 'init':
852
+ case 'i':
853
+ await cmdInit(args.flags);
854
+ break;
855
+
856
+ case 'undo':
857
+ case 'u':
858
+ await cmdUndo(args.target, args.flags);
859
+ break;
860
+
861
+ case 'info':
862
+ await cmdInfo(config);
863
+ break;
864
+
865
+ case 'license':
866
+ case 'lic':
867
+ case 'l':
868
+ await cmdLicense(args.target, args.positional, args.flags);
869
+ break;
870
+
871
+ case 'help':
872
+ printHelp();
873
+ break;
874
+
875
+ case 'mcp':
876
+ // Start the MCP server (STDIO transport)
877
+ require('./mcp-server').startServer();
878
+ return; // Keep process alive — server runs until stdin closes
879
+
880
+ default:
881
+ console.error(error(`Unknown command: "${args.command}"`));
882
+ console.log(c('dim', 'Run "filemayor --help" for usage'));
883
+ process.exit(1);
884
+ }
885
+ }
886
+
887
+ // ─── Run ──────────────────────────────────────────────────────────
888
+
889
+ main().catch(err => {
890
+ console.error(error(err.message));
891
+ if (process.env.DEBUG) {
892
+ console.error(err.stack);
893
+ }
894
+ process.exit(1);
895
+ });