tidyf 1.0.3 → 1.1.0

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.
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import * as p from "@clack/prompts";
13
+ import { existsSync } from "fs";
13
14
  import { isAbsolute, resolve } from "path";
14
15
  import color from "picocolors";
15
16
  import {
@@ -17,16 +18,26 @@ import {
17
18
  initGlobalConfig,
18
19
  parseModelString,
19
20
  resolveConfig,
21
+ resolveConfigWithProfile,
22
+ getRulesPromptWithProfile,
20
23
  } from "../lib/config.ts";
21
24
  import { analyzeFiles, cleanup } from "../lib/opencode.ts";
25
+ import { listProfiles, profileExists } from "../lib/profiles.ts";
26
+ import {
27
+ addMoveToHistory,
28
+ createHistoryEntry,
29
+ saveHistoryEntry,
30
+ } from "../lib/history.ts";
22
31
  import { scanDirectory, scanFolderStructure } from "../lib/scanner.ts";
23
32
  import type {
33
+ FileMetadata,
24
34
  FileMoveProposal,
25
35
  MoveResult,
26
36
  OrganizationProposal,
27
37
  OrganizeOptions,
38
+ DuplicateGroup,
28
39
  } from "../types/organizer.ts";
29
- import { formatFileSize, moveFile } from "../utils/files.ts";
40
+ import { formatFileSize, generateUniqueName, getFileStats, moveFile, computeFileHash } from "../utils/files.ts";
30
41
  import {
31
42
  getCategoryIcon,
32
43
  getFileIcon,
@@ -60,10 +71,63 @@ function displayProposal(proposal: FileMoveProposal, index: number): void {
60
71
  );
61
72
  }
62
73
 
74
+ /**
75
+ * Display file tree for proposals
76
+ */
77
+ function displayFileTree(proposals: FileMoveProposal[]): void {
78
+ const tree = new Map<string, FileMoveProposal[]>();
79
+
80
+ for (const prop of proposals) {
81
+ const parts = prop.destination.split(/[/\\]/);
82
+ let currentPath = "";
83
+
84
+ for (let i = 0; i < parts.length; i++) {
85
+ const part = parts[i];
86
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
87
+
88
+ if (i === parts.length - 1) {
89
+ if (!tree.has(currentPath)) {
90
+ tree.set(currentPath, []);
91
+ }
92
+ tree.get(currentPath)!.push(prop);
93
+ }
94
+ }
95
+ }
96
+
97
+ const sortedPaths = Array.from(tree.keys()).sort();
98
+ const indent = " ";
99
+
100
+ p.log.info(color.bold("Folder structure:"));
101
+ console.log();
102
+
103
+ for (const path of sortedPaths) {
104
+ const props = tree.get(path)!;
105
+ const depth = path.split(/[/\\]/).length - 1;
106
+
107
+ if (depth === 1) {
108
+ console.log(`${color.cyan("├─")} ${color.bold(path)}`);
109
+ } else {
110
+ const parentPath = path.split(/[/\\]/).slice(0, -1).join("/");
111
+ const parentProps = tree.get(parentPath);
112
+ if (!parentProps) {
113
+ console.log(`${"│ ".repeat(depth - 1)}├─ ${color.bold(path)}`);
114
+ }
115
+ }
116
+
117
+ for (const prop of props) {
118
+ const icon = getFileIcon(prop.file.name);
119
+ const size = color.dim(`(${formatFileSize(prop.file.size)})`);
120
+ console.log(` ${"│ ".repeat(depth)} ${icon} ${prop.file.name} ${size}`);
121
+ }
122
+ }
123
+
124
+ console.log();
125
+ }
126
+
63
127
  /**
64
128
  * Display all proposals grouped by category
65
129
  */
66
- function displayAllProposals(proposal: OrganizationProposal): void {
130
+ function displayAllProposals(proposal: OrganizationProposal, useTreeView: boolean = false): void {
67
131
  p.log.info(
68
132
  color.bold(
69
133
  `\nProposed organization for ${proposal.proposals.length} files:\n`,
@@ -74,6 +138,11 @@ function displayAllProposals(proposal: OrganizationProposal): void {
74
138
  p.log.message(color.dim(`Strategy: ${proposal.strategy}\n`));
75
139
  }
76
140
 
141
+ // Show tree view for large file sets
142
+ if (useTreeView) {
143
+ displayFileTree(proposal.proposals);
144
+ }
145
+
77
146
  // Group by category
78
147
  const byCategory = new Map<string, FileMoveProposal[]>();
79
148
  for (const prop of proposal.proposals) {
@@ -84,15 +153,17 @@ function displayAllProposals(proposal: OrganizationProposal): void {
84
153
  byCategory.get(cat)!.push(prop);
85
154
  }
86
155
 
87
- let index = 0;
88
- for (const [category, props] of byCategory) {
89
- p.log.info(
90
- `${getCategoryIcon(category)} ${color.bold(category)} (${props.length} files)`,
91
- );
92
- for (const prop of props) {
93
- displayProposal(prop, index++);
156
+ if (!useTreeView) {
157
+ let index = 0;
158
+ for (const [category, props] of byCategory) {
159
+ p.log.info(
160
+ `${getCategoryIcon(category)} ${color.bold(category)} (${props.length} files)`,
161
+ );
162
+ for (const prop of props) {
163
+ displayProposal(prop, index++);
164
+ }
165
+ console.log();
94
166
  }
95
- console.log();
96
167
  }
97
168
 
98
169
  // Show uncategorized files if any
@@ -118,13 +189,76 @@ function displayAllProposals(proposal: OrganizationProposal): void {
118
189
  }
119
190
  }
120
191
 
192
+ /**
193
+ * Display conflict details for a proposal
194
+ */
195
+ async function displayConflictDetails(proposal: FileMoveProposal): Promise<void> {
196
+ const sourceStats = await getFileStats(proposal.sourcePath);
197
+ const destStats = await getFileStats(proposal.destination);
198
+
199
+ console.log();
200
+ p.log.info(color.bold(`Conflict: ${proposal.file.name}`));
201
+ console.log();
202
+
203
+ p.log.message(color.bold("Source file (to be moved):"));
204
+ p.log.message(` Path: ${proposal.sourcePath}`);
205
+ p.log.message(` Size: ${sourceStats ? color.green(formatFileSize(sourceStats.size)) : color.red("Unknown")}`);
206
+ p.log.message(` Modified: ${sourceStats ? color.cyan(sourceStats.mtime.toLocaleString()) : color.red("Unknown")}`);
207
+
208
+ console.log();
209
+ p.log.message(color.bold("Destination file (existing):"));
210
+ p.log.message(` Path: ${proposal.destination}`);
211
+ p.log.message(` Size: ${destStats ? color.yellow(formatFileSize(destStats.size)) : color.red("Unknown")}`);
212
+ p.log.message(` Modified: ${destStats ? color.cyan(destStats.mtime.toLocaleString()) : color.red("Unknown")}`);
213
+
214
+ console.log();
215
+ if (sourceStats && destStats) {
216
+ const sizeDiff = sourceStats.size - destStats.size;
217
+ const timeDiff = sourceStats.mtime.getTime() - destStats.mtime.getTime();
218
+
219
+ p.log.info(color.bold("Comparison:"));
220
+ p.log.message(` Size difference: ${sizeDiff > 0 ? color.green("+" + formatFileSize(Math.abs(sizeDiff))) : sizeDiff < 0 ? color.red("-" + formatFileSize(Math.abs(sizeDiff))) : color.dim("Same size")}`);
221
+ p.log.message(` Age difference: ${timeDiff > 0 ? color.green("Source is newer") : timeDiff < 0 ? color.yellow("Destination is newer") : color.dim("Same age")}`);
222
+ }
223
+
224
+ console.log();
225
+ }
226
+
227
+ /**
228
+ * Select specific files to move using multiselect
229
+ */
230
+ async function selectFilesToMove(
231
+ proposals: FileMoveProposal[],
232
+ ): Promise<number[]> {
233
+ const options = proposals.map((p, i) => ({
234
+ value: i,
235
+ label: p.file.name,
236
+ hint: `${p.category.name}${p.category.subcategory ? "/" + p.category.subcategory : ""} ${formatFileSize(p.file.size)}`,
237
+ }));
238
+
239
+ const selected = await p.multiselect({
240
+ message: "Select files to move (press Space to select/deselect, Enter to confirm):",
241
+ options,
242
+ required: false,
243
+ });
244
+
245
+ if (p.isCancel(selected)) {
246
+ return [];
247
+ }
248
+
249
+ return selected as number[];
250
+ }
251
+
121
252
  /**
122
253
  * Execute all proposals
123
254
  */
124
255
  async function executeProposals(
125
256
  proposals: FileMoveProposal[],
257
+ sourcePath: string,
258
+ targetPath: string,
126
259
  ): Promise<MoveResult[]> {
127
260
  const results: MoveResult[] = [];
261
+ const historyEntry = createHistoryEntry(sourcePath, targetPath);
128
262
  const s = p.spinner();
129
263
 
130
264
  for (let i = 0; i < proposals.length; i++) {
@@ -139,6 +273,7 @@ async function executeProposals(
139
273
  results.push(result);
140
274
 
141
275
  if (result.status === "completed") {
276
+ addMoveToHistory(historyEntry, prop.sourcePath, prop.destination);
142
277
  s.stop(
143
278
  `${color.green("✓")} ${i + 1}/${proposals.length}: ${prop.file.name}`,
144
279
  );
@@ -153,8 +288,13 @@ async function executeProposals(
153
288
  }
154
289
  }
155
290
 
156
- // Summary
291
+ // Save history if any moves were successful
157
292
  const completed = results.filter((r) => r.status === "completed").length;
293
+ if (completed > 0) {
294
+ saveHistoryEntry(historyEntry);
295
+ }
296
+
297
+ // Summary
158
298
  const failed = results.filter((r) => r.status === "failed").length;
159
299
  const skipped = results.filter((r) => r.status === "skipped").length;
160
300
 
@@ -167,6 +307,38 @@ async function executeProposals(
167
307
  return results;
168
308
  }
169
309
 
310
+ /**
311
+ * Execute all proposals quietly (for JSON mode)
312
+ */
313
+ async function executeProposalsQuiet(
314
+ proposals: FileMoveProposal[],
315
+ sourcePath: string,
316
+ targetPath: string,
317
+ ): Promise<MoveResult[]> {
318
+ const results: MoveResult[] = [];
319
+ const historyEntry = createHistoryEntry(sourcePath, targetPath);
320
+
321
+ for (const prop of proposals) {
322
+ const result = await moveFile(prop.sourcePath, prop.destination, {
323
+ overwrite: false,
324
+ backup: false,
325
+ });
326
+
327
+ results.push(result);
328
+
329
+ if (result.status === "completed") {
330
+ addMoveToHistory(historyEntry, prop.sourcePath, prop.destination);
331
+ }
332
+ }
333
+
334
+ const completed = results.filter((r) => r.status === "completed").length;
335
+ if (completed > 0) {
336
+ saveHistoryEntry(historyEntry);
337
+ }
338
+
339
+ return results;
340
+ }
341
+
170
342
  /**
171
343
  * View details of a specific proposal
172
344
  */
@@ -221,17 +393,143 @@ function resolvePath(inputPath: string): string {
221
393
  return isAbsolute(expanded) ? expanded : resolve(expanded);
222
394
  }
223
395
 
396
+ /**
397
+ * Convert proposal to JSON-serializable format
398
+ */
399
+ function toJsonOutput(proposal: OrganizationProposal, results?: MoveResult[]): object {
400
+ return {
401
+ proposals: proposal.proposals.map(p => ({
402
+ source: p.sourcePath,
403
+ destination: p.destination,
404
+ file: {
405
+ name: p.file.name,
406
+ extension: p.file.extension,
407
+ size: p.file.size,
408
+ mimeType: p.file.mimeType,
409
+ hash: p.file.hash,
410
+ },
411
+ category: p.category,
412
+ conflictExists: p.conflictExists,
413
+ })),
414
+ strategy: proposal.strategy,
415
+ uncategorized: proposal.uncategorized.map(f => ({
416
+ name: f.name,
417
+ path: f.path,
418
+ size: f.size,
419
+ })),
420
+ duplicates: proposal.duplicates?.map(d => ({
421
+ hash: d.hash,
422
+ files: d.files.map(f => ({ name: f.name, path: f.path, size: f.size })),
423
+ wastedBytes: d.wastedBytes,
424
+ })),
425
+ analyzedAt: proposal.analyzedAt.toISOString(),
426
+ results: results?.map(r => ({
427
+ source: r.source,
428
+ destination: r.destination,
429
+ status: r.status,
430
+ error: r.error,
431
+ })),
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Detect duplicate files by computing content hashes
437
+ */
438
+ async function detectDuplicates(files: FileMetadata[]): Promise<DuplicateGroup[]> {
439
+ const hashMap = new Map<string, FileMetadata[]>();
440
+
441
+ for (const file of files) {
442
+ if (file.hash) {
443
+ const existing = hashMap.get(file.hash) || [];
444
+ existing.push(file);
445
+ hashMap.set(file.hash, existing);
446
+ }
447
+ }
448
+
449
+ const duplicates: DuplicateGroup[] = [];
450
+ for (const [hash, groupFiles] of hashMap) {
451
+ if (groupFiles.length > 1) {
452
+ const totalSize = groupFiles.reduce((sum, f) => sum + f.size, 0);
453
+ const wastedBytes = totalSize - groupFiles[0].size;
454
+ duplicates.push({ hash, files: groupFiles, wastedBytes });
455
+ }
456
+ }
457
+
458
+ return duplicates.sort((a, b) => b.wastedBytes - a.wastedBytes);
459
+ }
460
+
461
+ /**
462
+ * Display duplicate file groups
463
+ */
464
+ function displayDuplicates(duplicates: DuplicateGroup[]): void {
465
+ if (duplicates.length === 0) return;
466
+
467
+ const totalWasted = duplicates.reduce((sum, d) => sum + d.wastedBytes, 0);
468
+
469
+ p.log.warn(color.yellow(`\n⚠ Found ${duplicates.length} duplicate groups (${formatFileSize(totalWasted)} wasted)`));
470
+
471
+ for (const group of duplicates.slice(0, 5)) {
472
+ p.log.message(`\n ${color.dim(group.hash.slice(0, 8))} - ${group.files.length} copies (${formatFileSize(group.wastedBytes)} wasted)`);
473
+ for (const file of group.files) {
474
+ p.log.message(` ${getFileIcon(file.name)} ${file.name} ${color.dim(`(${formatFileSize(file.size)})`)}`);
475
+ }
476
+ }
477
+
478
+ if (duplicates.length > 5) {
479
+ p.log.message(color.dim(`\n ... and ${duplicates.length - 5} more duplicate groups`));
480
+ }
481
+ }
482
+
224
483
  /**
225
484
  * Main organize command
226
485
  */
227
486
  export async function organizeCommand(options: OrganizeOptions): Promise<void> {
228
- p.intro(color.bgGreen(color.black(" tidyf ")));
487
+ const isJsonMode = options.json === true;
488
+
489
+ if (!isJsonMode) {
490
+ p.intro(color.bgGreen(color.black(" tidyf ")));
491
+ }
229
492
 
230
493
  // Initialize global config if needed
231
494
  initGlobalConfig();
232
495
 
233
- // Resolve configuration
234
- const config = resolveConfig();
496
+ // Validate and resolve profile if specified
497
+ if (options.profile) {
498
+ if (!profileExists(options.profile)) {
499
+ if (isJsonMode) {
500
+ console.log(JSON.stringify({ error: `Profile "${options.profile}" not found` }));
501
+ process.exit(1);
502
+ }
503
+ p.log.error(`Profile "${options.profile}" not found`);
504
+ const profiles = listProfiles();
505
+ if (profiles.length > 0) {
506
+ p.log.info("Available profiles:");
507
+ profiles.forEach((pr) => p.log.message(` - ${pr.name}`));
508
+ }
509
+ const create = await p.confirm({
510
+ message: `Create profile "${options.profile}"?`,
511
+ initialValue: false,
512
+ });
513
+ if (p.isCancel(create) || !create) {
514
+ p.outro("Canceled");
515
+ cleanup();
516
+ process.exit(0);
517
+ }
518
+ // Redirect to profile creation
519
+ const { profileCommand } = await import("./profile.ts");
520
+ await profileCommand({ action: "create", name: options.profile });
521
+ // Re-run organize with the now-existing profile
522
+ p.log.info("Profile created. Continuing with organization...");
523
+ }
524
+ if (!isJsonMode) {
525
+ p.log.info(`Profile: ${color.cyan(options.profile)}`);
526
+ }
527
+ }
528
+
529
+ // Resolve configuration (with profile if specified)
530
+ const config = options.profile
531
+ ? resolveConfigWithProfile(options.profile)
532
+ : resolveConfig();
235
533
 
236
534
  // Determine source directory
237
535
  const sourcePath = resolvePath(
@@ -249,14 +547,91 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
249
547
  "~/Documents/Organized",
250
548
  );
251
549
 
252
- p.log.info(`Source: ${color.cyan(sourcePath)}`);
253
- p.log.info(`Target: ${color.cyan(targetPath)}`);
550
+ if (!isJsonMode) {
551
+ p.log.info(`Source: ${color.cyan(sourcePath)}`);
552
+ p.log.info(`Target: ${color.cyan(targetPath)}`);
553
+ }
554
+
555
+ // Check if source directory exists
556
+ if (!existsSync(sourcePath)) {
557
+ if (isJsonMode) {
558
+ console.log(JSON.stringify({ error: `Directory does not exist: ${sourcePath}` }));
559
+ process.exit(1);
560
+ }
561
+ console.log();
562
+ p.log.error(color.red("Directory does not exist"));
563
+
564
+ console.log();
565
+ p.log.message(`${color.cyan("Missing directory:")} ${sourcePath}`);
566
+
567
+ console.log();
568
+
569
+ const action = await p.select({
570
+ message: "What would you like to do?",
571
+ options: [
572
+ {
573
+ value: "create",
574
+ label: "Create this directory",
575
+ },
576
+ {
577
+ value: "change_path",
578
+ label: "Choose a different directory",
579
+ },
580
+ {
581
+ value: "exit",
582
+ label: "Exit",
583
+ },
584
+ ],
585
+ });
586
+
587
+ if (p.isCancel(action) || action === "exit") {
588
+ p.outro("Canceled");
589
+ cleanup();
590
+ process.exit(0);
591
+ }
592
+
593
+ if (action === "create") {
594
+ const { mkdir } = await import("fs/promises");
595
+ try {
596
+ await mkdir(sourcePath, { recursive: true });
597
+ p.log.success(`Created directory: ${color.cyan(sourcePath)}`);
598
+ } catch (error: any) {
599
+ p.log.error(`Failed to create directory: ${error.message}`);
600
+ p.outro("Canceled");
601
+ cleanup();
602
+ process.exit(0);
603
+ }
604
+ }
605
+
606
+ if (action === "change_path") {
607
+ console.log();
608
+ const newPath = await p.text({
609
+ message: "Enter directory path to scan:",
610
+ placeholder: "~/Downloads",
611
+ });
612
+
613
+ if (!p.isCancel(newPath) && newPath.trim()) {
614
+ await organizeCommand({
615
+ ...options,
616
+ path: newPath.trim(),
617
+ });
618
+ return;
619
+ } else {
620
+ p.outro("Canceled");
621
+ cleanup();
622
+ process.exit(0);
623
+ }
624
+ }
625
+ }
254
626
 
255
627
  // Scan directory
256
- const spinner = p.spinner();
257
- spinner.start("Scanning directory...");
628
+ let spinner: ReturnType<typeof p.spinner> | null = null;
629
+ if (!isJsonMode) {
630
+ spinner = p.spinner();
631
+ spinner.start("Scanning directory...");
632
+ }
258
633
 
259
- const files = await scanDirectory(sourcePath, {
634
+ let files = await scanDirectory(sourcePath, {
260
635
  recursive: options.recursive,
261
636
  maxDepth: parseInt(options.depth || "1"),
262
637
  ignore: config.ignore,
@@ -264,17 +639,126 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
264
639
  maxContentSize: config.maxContentSize,
265
640
  });
266
641
 
267
- spinner.stop(`Found ${color.bold(String(files.length))} files`);
642
+ // Compute file hashes for duplicate detection if requested
643
+ if (options.detectDuplicates) {
644
+ for (const file of files) {
645
+ file.hash = await computeFileHash(file.path) || undefined;
646
+ }
647
+ }
648
+
649
+ if (!isJsonMode && spinner) {
650
+ spinner.stop(`Found ${color.bold(String(files.length))} files`);
651
+ }
268
652
 
269
653
  if (files.length === 0) {
270
- p.outro(color.yellow("No files to organize"));
271
- return;
654
+ if (isJsonMode) {
655
+ console.log(JSON.stringify({ proposals: [], strategy: "No files found", uncategorized: [], analyzedAt: new Date().toISOString() }));
656
+ cleanup();
657
+ return;
658
+ }
659
+ console.log();
660
+ p.log.warn(color.yellow("No files to organize"));
661
+
662
+ console.log();
663
+ p.log.message(`${color.cyan("Scanned directory:")} ${sourcePath}`);
664
+
665
+ console.log();
666
+ p.log.info("Possible reasons:");
667
+ p.log.message(` • The directory is empty`);
668
+ p.log.message(` • All files are ignored by your ignore patterns`);
669
+ p.log.message(` • You're not scanning recursively and files are in subdirectories`);
670
+
671
+ if (config.ignore && config.ignore.length > 0) {
672
+ console.log();
673
+ p.log.info(`Active ignore patterns: ${color.dim(config.ignore.join(", "))}`);
674
+ }
675
+
676
+ console.log();
677
+
678
+ const action = await p.select({
679
+ message: "What would you like to do?",
680
+ options: [
681
+ {
682
+ value: "scan_recursive",
683
+ label: "Scan recursively",
684
+ hint: !options.recursive ? "Include subdirectories" : "Already scanning recursively",
685
+ },
686
+ {
687
+ value: "change_path",
688
+ label: "Choose a different directory",
689
+ },
690
+ {
691
+ value: "edit_config",
692
+ label: "Edit configuration",
693
+ hint: "Modify ignore patterns and settings",
694
+ },
695
+ {
696
+ value: "exit",
697
+ label: "Exit",
698
+ },
699
+ ],
700
+ });
701
+
702
+ if (p.isCancel(action) || action === "exit") {
703
+ p.outro("Nothing to do");
704
+ cleanup();
705
+ process.exit(0);
706
+ }
707
+
708
+ if (action === "scan_recursive") {
709
+ console.log();
710
+ const newPath = await p.text({
711
+ message: "Enter directory path (or press Enter to use current):",
712
+ placeholder: sourcePath,
713
+ defaultValue: sourcePath,
714
+ });
715
+
716
+ if (!p.isCancel(newPath) && newPath.trim()) {
717
+ await organizeCommand({
718
+ ...options,
719
+ path: newPath.trim(),
720
+ recursive: true,
721
+ });
722
+ return;
723
+ }
724
+ }
725
+
726
+ if (action === "change_path") {
727
+ console.log();
728
+ const newPath = await p.text({
729
+ message: "Enter directory path to scan:",
730
+ placeholder: "~/Downloads",
731
+ });
732
+
733
+ if (!p.isCancel(newPath) && newPath.trim()) {
734
+ await organizeCommand({
735
+ ...options,
736
+ path: newPath.trim(),
737
+ });
738
+ return;
739
+ }
740
+ }
741
+
742
+ if (action === "edit_config") {
743
+ console.log();
744
+ p.log.message(`Run ${color.cyan("tidyf config")} to open the configuration editor`);
745
+ console.log();
746
+ p.outro("Exiting...");
747
+ cleanup();
748
+ process.exit(0);
749
+ }
750
+
751
+ p.outro("Nothing to do");
752
+ cleanup();
753
+ process.exit(0);
272
754
  }
273
755
 
274
756
  // Show file summary
275
- p.log.info(
276
- `Total size: ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`,
277
- );
757
+ if (!isJsonMode) {
758
+ p.log.info(
759
+ `Total size: ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`,
760
+ );
761
+ }
278
762
 
279
763
  // Scan existing folder structure in target directory
280
764
  let existingFolders: string[] = [];
@@ -284,7 +768,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
284
768
  includeEmpty: false,
285
769
  ignore: config.ignore,
286
770
  });
287
- if (existingFolders.length > 0) {
771
+ if (!isJsonMode && existingFolders.length > 0) {
288
772
  p.log.info(`Found ${color.bold(String(existingFolders.length))} existing folders in target`);
289
773
  }
290
774
  } catch {
@@ -292,26 +776,111 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
292
776
  }
293
777
 
294
778
  // Analyze with AI
295
- spinner.start("Analyzing files with AI...");
779
+ if (!isJsonMode && spinner) {
780
+ spinner.start("Analyzing files with AI...");
781
+ }
296
782
 
297
783
  let proposal: OrganizationProposal;
298
784
  try {
299
- proposal = await analyzeFiles({
300
- files,
301
- targetDir: targetPath,
302
- model: parseModelString(options.model),
303
- existingFolders,
304
- });
305
- spinner.stop("Analysis complete");
785
+ const BATCH_SIZE = 50;
786
+
787
+ if (files.length > BATCH_SIZE) {
788
+ if (!isJsonMode) {
789
+ p.log.info(`Processing ${files.length} files in batches of ${BATCH_SIZE}...`);
790
+ }
791
+
792
+ let allProposals: FileMoveProposal[] = [];
793
+ let allUncategorized: FileMetadata[] = [];
794
+ let strategies: string[] = [];
795
+
796
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
797
+ const batch = files.slice(i, i + BATCH_SIZE);
798
+ const batchNum = Math.floor(i / BATCH_SIZE) + 1;
799
+ const totalBatches = Math.ceil(files.length / BATCH_SIZE);
800
+
801
+ if (!isJsonMode && spinner) {
802
+ spinner.start(`Analyzing batch ${batchNum}/${totalBatches} (${batch.length} files)...`);
803
+ }
804
+
805
+ const batchProposal = await analyzeFiles({
806
+ files: batch,
807
+ targetDir: targetPath,
808
+ model: parseModelString(options.model),
809
+ existingFolders,
810
+ profileName: options.profile,
811
+ });
812
+
813
+ allProposals = allProposals.concat(batchProposal.proposals);
814
+ allUncategorized = allUncategorized.concat(batchProposal.uncategorized);
815
+ if (batchProposal.strategy) {
816
+ strategies.push(batchProposal.strategy);
817
+ }
818
+ }
819
+
820
+ if (!isJsonMode && spinner) {
821
+ spinner.stop("Analysis complete");
822
+ }
823
+ proposal = {
824
+ proposals: allProposals,
825
+ strategy: strategies.join("; "),
826
+ uncategorized: allUncategorized,
827
+ analyzedAt: new Date(),
828
+ };
829
+ } else {
830
+ proposal = await analyzeFiles({
831
+ files,
832
+ targetDir: targetPath,
833
+ model: parseModelString(options.model),
834
+ existingFolders,
835
+ profileName: options.profile,
836
+ });
837
+ if (!isJsonMode && spinner) {
838
+ spinner.stop("Analysis complete");
839
+ }
840
+ }
306
841
  } catch (error: any) {
307
- spinner.stop("Analysis failed");
842
+ if (isJsonMode) {
843
+ console.log(JSON.stringify({ error: error.message }));
844
+ cleanup();
845
+ process.exit(1);
846
+ }
847
+ if (spinner) {
848
+ spinner.stop("Analysis failed");
849
+ }
308
850
  p.cancel(error.message);
309
851
  cleanup();
310
852
  process.exit(1);
311
853
  }
312
854
 
855
+ // Detect duplicates if requested
856
+ if (options.detectDuplicates) {
857
+ const duplicates = await detectDuplicates(files);
858
+ proposal.duplicates = duplicates;
859
+ if (!isJsonMode && duplicates.length > 0) {
860
+ displayDuplicates(duplicates);
861
+ }
862
+ }
863
+
864
+ // JSON mode: output and exit
865
+ if (isJsonMode) {
866
+ if (options.dryRun || options.yes === false) {
867
+ console.log(JSON.stringify(toJsonOutput(proposal)));
868
+ } else {
869
+ // Execute moves and include results
870
+ const results = await executeProposalsQuiet(proposal.proposals, sourcePath, targetPath);
871
+ console.log(JSON.stringify(toJsonOutput(proposal, results)));
872
+ }
873
+ cleanup();
874
+ return;
875
+ }
876
+
313
877
  // Display proposals
314
- displayAllProposals(proposal);
878
+ const useTreeView = proposal.proposals.length >= 20;
879
+ displayAllProposals(proposal, useTreeView);
880
+
881
+ // Check for conflicts
882
+ const conflicts = proposal.proposals.filter((p) => p.conflictExists);
883
+ const hasConflicts = conflicts.length > 0;
315
884
 
316
885
  // Dry run mode
317
886
  if (options.dryRun) {
@@ -332,7 +901,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
332
901
  while (!done) {
333
902
  if (options.yes) {
334
903
  // Auto-apply all
335
- await executeProposals(proposal.proposals);
904
+ await executeProposals(proposal.proposals, sourcePath, targetPath);
336
905
  done = true;
337
906
  } else {
338
907
  const action = await p.select({
@@ -343,6 +912,20 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
343
912
  label: `Apply all ${proposal.proposals.length} moves`,
344
913
  hint: "Organize files as proposed",
345
914
  },
915
+ ...(hasConflicts
916
+ ? [
917
+ {
918
+ value: "resolve_conflicts" as const,
919
+ label: `Resolve ${conflicts.length} conflicts`,
920
+ hint: "Review and handle conflicting files",
921
+ },
922
+ ]
923
+ : []),
924
+ {
925
+ value: "select_individual",
926
+ label: "Select specific files to move",
927
+ hint: "Choose which files to organize",
928
+ },
346
929
  {
347
930
  value: "view_details",
348
931
  label: "View file details",
@@ -373,10 +956,60 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
373
956
 
374
957
  switch (action) {
375
958
  case "apply_all":
376
- await executeProposals(proposal.proposals);
959
+ await executeProposals(proposal.proposals, sourcePath, targetPath);
377
960
  done = true;
378
961
  break;
379
962
 
963
+ case "resolve_conflicts": {
964
+ const conflictIndex = await p.select({
965
+ message: "Which conflict to view?",
966
+ options: conflicts.map((p, i) => ({
967
+ value: i,
968
+ label: p.file.name,
969
+ hint: formatFileSize(p.file.size),
970
+ })),
971
+ });
972
+
973
+ if (p.isCancel(conflictIndex)) {
974
+ break;
975
+ }
976
+
977
+ await displayConflictDetails(conflicts[conflictIndex as number]);
978
+
979
+ const resolution = await p.select({
980
+ message: "How to resolve this conflict?",
981
+ options: [
982
+ { value: "rename", label: "Rename (auto-generate new name)", hint: "Keep both files" },
983
+ { value: "overwrite", label: "Overwrite", hint: "Replace destination file" },
984
+ { value: "skip", label: "Skip", hint: "Don't move this file" },
985
+ { value: "cancel", label: "Cancel", hint: "Return to main menu" },
986
+ ],
987
+ });
988
+
989
+ if (p.isCancel(resolution) || resolution === "cancel") {
990
+ break;
991
+ }
992
+
993
+ const conflictProposal = conflicts[conflictIndex as number];
994
+ if (resolution === "overwrite") {
995
+ await executeProposals([{ ...conflictProposal, conflictExists: false }], sourcePath, targetPath);
996
+ } else if (resolution === "rename") {
997
+ const uniqueDest = await generateUniqueName(conflictProposal.destination);
998
+ await executeProposals([{ ...conflictProposal, destination: uniqueDest, conflictExists: false }], sourcePath, targetPath);
999
+ }
1000
+ break;
1001
+ }
1002
+
1003
+ case "select_individual": {
1004
+ const selectedIndices = await selectFilesToMove(proposal.proposals);
1005
+ if (selectedIndices.length > 0) {
1006
+ const selectedProposals = selectedIndices.map((i: number) => proposal.proposals[i]);
1007
+ await executeProposals(selectedProposals, sourcePath, targetPath);
1008
+ done = true;
1009
+ }
1010
+ break;
1011
+ }
1012
+
380
1013
  case "view_details":
381
1014
  await viewProposalDetails(proposal.proposals);
382
1015
  break;
@@ -392,7 +1025,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
392
1025
  break;
393
1026
  }
394
1027
 
395
- spinner.start("Re-analyzing files with AI...");
1028
+ spinner!.start("Re-analyzing files with AI...");
396
1029
  try {
397
1030
  proposal = await analyzeFiles({
398
1031
  files,
@@ -400,11 +1033,12 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
400
1033
  instructions: newInstructions || undefined,
401
1034
  model: parseModelString(options.model),
402
1035
  existingFolders,
1036
+ profileName: options.profile,
403
1037
  });
404
- spinner.stop("Analysis complete");
1038
+ spinner!.stop("Analysis complete");
405
1039
  displayAllProposals(proposal);
406
1040
  } catch (error: any) {
407
- spinner.stop("Analysis failed");
1041
+ spinner!.stop("Analysis failed");
408
1042
  p.log.error(error.message);
409
1043
  }
410
1044
  break;
@@ -426,7 +1060,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
426
1060
  break;
427
1061
  }
428
1062
 
429
- spinner.start(`Re-analyzing with ${pickedModel.provider}/${pickedModel.model}...`);
1063
+ spinner!.start(`Re-analyzing with ${pickedModel.provider}/${pickedModel.model}...`);
430
1064
  try {
431
1065
  proposal = await analyzeFiles({
432
1066
  files,
@@ -434,11 +1068,12 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
434
1068
  instructions: newInstructions || undefined,
435
1069
  model: pickedModel,
436
1070
  existingFolders,
1071
+ profileName: options.profile,
437
1072
  });
438
- spinner.stop("Analysis complete");
1073
+ spinner!.stop("Analysis complete");
439
1074
  displayAllProposals(proposal);
440
1075
  } catch (error: any) {
441
- spinner.stop("Analysis failed");
1076
+ spinner!.stop("Analysis failed");
442
1077
  p.log.error(error.message);
443
1078
  }
444
1079
  break;