tidyf 1.0.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.
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Organize command - main file organization functionality
3
+ *
4
+ * Implements the propose-and-apply pattern:
5
+ * 1. Scan directory for files
6
+ * 2. Analyze with AI
7
+ * 3. Display proposed organization
8
+ * 4. Confirm with user
9
+ * 5. Execute moves
10
+ */
11
+
12
+ import * as p from "@clack/prompts";
13
+ import { homedir } from "os";
14
+ import { isAbsolute, resolve } from "path";
15
+ import color from "picocolors";
16
+ import {
17
+ expandPath,
18
+ initGlobalConfig,
19
+ parseModelString,
20
+ resolveConfig,
21
+ } from "../lib/config.ts";
22
+ import { analyzeFiles, cleanup } from "../lib/opencode.ts";
23
+ import { scanDirectory } from "../lib/scanner.ts";
24
+ import type {
25
+ FileMoveProposal,
26
+ MoveResult,
27
+ OrganizationProposal,
28
+ OrganizeOptions,
29
+ } from "../types/organizer.ts";
30
+ import { fileExists, formatFileSize, moveFile } from "../utils/files.ts";
31
+ import {
32
+ getCategoryIcon,
33
+ getFileIcon,
34
+ getStatusIndicator,
35
+ } from "../utils/icons.ts";
36
+
37
+ /**
38
+ * Display a single proposal
39
+ */
40
+ function displayProposal(proposal: FileMoveProposal, index: number): void {
41
+ const icon = getFileIcon(proposal.file.name);
42
+ const size = color.dim(`(${formatFileSize(proposal.file.size)})`);
43
+ const confidence = Math.round(proposal.category.confidence * 100);
44
+ const confidenceColor =
45
+ confidence >= 80
46
+ ? color.green
47
+ : confidence >= 50
48
+ ? color.yellow
49
+ : color.red;
50
+
51
+ const conflictWarning = proposal.conflictExists
52
+ ? color.yellow(" ⚠ exists")
53
+ : "";
54
+
55
+ p.log.message(
56
+ `${color.cyan(`[${index + 1}]`)} ${icon} ${color.bold(proposal.file.name)} ${size}${conflictWarning}\n` +
57
+ ` → ${color.dim(proposal.destination)}\n` +
58
+ ` ${getCategoryIcon(proposal.category.name)} ${proposal.category.name}${proposal.category.subcategory ? `/${proposal.category.subcategory}` : ""} ${confidenceColor(`${confidence}%`)}\n` +
59
+ ` ${color.dim(proposal.category.reasoning)}`,
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Display all proposals grouped by category
65
+ */
66
+ function displayAllProposals(proposal: OrganizationProposal): void {
67
+ p.log.info(
68
+ color.bold(
69
+ `\nProposed organization for ${proposal.proposals.length} files:\n`,
70
+ ),
71
+ );
72
+
73
+ if (proposal.strategy) {
74
+ p.log.message(color.dim(`Strategy: ${proposal.strategy}\n`));
75
+ }
76
+
77
+ // Group by category
78
+ const byCategory = new Map<string, FileMoveProposal[]>();
79
+ for (const prop of proposal.proposals) {
80
+ const cat = prop.category.name;
81
+ if (!byCategory.has(cat)) {
82
+ byCategory.set(cat, []);
83
+ }
84
+ byCategory.get(cat)!.push(prop);
85
+ }
86
+
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++);
94
+ }
95
+ console.log();
96
+ }
97
+
98
+ // Show uncategorized files if any
99
+ if (proposal.uncategorized.length > 0) {
100
+ p.log.warn(
101
+ color.yellow(
102
+ `\n${proposal.uncategorized.length} files could not be categorized:`,
103
+ ),
104
+ );
105
+ for (const file of proposal.uncategorized) {
106
+ p.log.message(` ${getFileIcon(file.name)} ${file.name}`);
107
+ }
108
+ }
109
+
110
+ // Show conflict summary
111
+ const conflicts = proposal.proposals.filter((p) => p.conflictExists);
112
+ if (conflicts.length > 0) {
113
+ p.log.warn(
114
+ color.yellow(
115
+ `\n⚠ ${conflicts.length} files have conflicts (destination exists)`,
116
+ ),
117
+ );
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Execute all proposals
123
+ */
124
+ async function executeProposals(
125
+ proposals: FileMoveProposal[],
126
+ ): Promise<MoveResult[]> {
127
+ const results: MoveResult[] = [];
128
+ const s = p.spinner();
129
+
130
+ for (let i = 0; i < proposals.length; i++) {
131
+ const prop = proposals[i];
132
+ s.start(`Moving ${i + 1}/${proposals.length}: ${prop.file.name}`);
133
+
134
+ const result = await moveFile(prop.sourcePath, prop.destination, {
135
+ overwrite: false,
136
+ backup: false,
137
+ });
138
+
139
+ results.push(result);
140
+
141
+ if (result.status === "completed") {
142
+ s.stop(
143
+ `${color.green("✓")} ${i + 1}/${proposals.length}: ${prop.file.name}`,
144
+ );
145
+ } else if (result.status === "failed") {
146
+ s.stop(
147
+ `${color.red("✗")} ${i + 1}/${proposals.length}: ${prop.file.name} - ${result.error}`,
148
+ );
149
+ } else {
150
+ s.stop(
151
+ `${getStatusIndicator(result.status)} ${i + 1}/${proposals.length}: ${prop.file.name}`,
152
+ );
153
+ }
154
+ }
155
+
156
+ // Summary
157
+ const completed = results.filter((r) => r.status === "completed").length;
158
+ const failed = results.filter((r) => r.status === "failed").length;
159
+ const skipped = results.filter((r) => r.status === "skipped").length;
160
+
161
+ p.log.success(
162
+ `\nMoved ${completed} files` +
163
+ (failed > 0 ? color.red(`, ${failed} failed`) : "") +
164
+ (skipped > 0 ? color.yellow(`, ${skipped} skipped`) : ""),
165
+ );
166
+
167
+ return results;
168
+ }
169
+
170
+ /**
171
+ * View details of a specific proposal
172
+ */
173
+ async function viewProposalDetails(
174
+ proposals: FileMoveProposal[],
175
+ ): Promise<void> {
176
+ const options = proposals.map((p, i) => ({
177
+ value: i,
178
+ label: `[${i + 1}] ${p.file.name}`,
179
+ hint: p.category.name,
180
+ }));
181
+
182
+ const selectedIndex = await p.select({
183
+ message: "Which file to view?",
184
+ options,
185
+ });
186
+
187
+ if (p.isCancel(selectedIndex)) {
188
+ return;
189
+ }
190
+
191
+ const prop = proposals[selectedIndex as number];
192
+
193
+ console.log();
194
+ p.log.info(color.bold(`File: ${prop.file.name}`));
195
+ p.log.message(` Path: ${prop.file.path}`);
196
+ p.log.message(` Size: ${formatFileSize(prop.file.size)}`);
197
+ p.log.message(` Type: ${prop.file.mimeType || "unknown"}`);
198
+ p.log.message(` Modified: ${prop.file.modifiedAt.toLocaleString()}`);
199
+ console.log();
200
+ p.log.info(color.bold("Proposed destination:"));
201
+ p.log.message(` ${prop.destination}`);
202
+ console.log();
203
+ p.log.info(color.bold("Categorization:"));
204
+ p.log.message(` Category: ${prop.category.name}`);
205
+ p.log.message(` Subcategory: ${prop.category.subcategory || "none"}`);
206
+ p.log.message(` Confidence: ${Math.round(prop.category.confidence * 100)}%`);
207
+ p.log.message(` Reasoning: ${prop.category.reasoning}`);
208
+
209
+ if (prop.conflictExists) {
210
+ p.log.warn(color.yellow("\n⚠ A file already exists at the destination"));
211
+ }
212
+
213
+ console.log();
214
+ }
215
+
216
+ /**
217
+ * Resolve path with home directory expansion
218
+ */
219
+ function resolvePath(inputPath: string): string {
220
+ const expanded = expandPath(inputPath);
221
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
222
+ }
223
+
224
+ /**
225
+ * Main organize command
226
+ */
227
+ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
228
+ p.intro(color.bgGreen(color.black(" tidyf ")));
229
+
230
+ // Initialize global config if needed
231
+ initGlobalConfig();
232
+
233
+ // Resolve configuration
234
+ const config = resolveConfig();
235
+
236
+ // Determine source directory
237
+ const sourcePath = resolvePath(
238
+ options.path ||
239
+ config.defaultSource ||
240
+ config.folders?.[0]?.sources?.[0] ||
241
+ "~/Downloads",
242
+ );
243
+
244
+ // Determine target directory
245
+ const targetPath = resolvePath(
246
+ options.target ||
247
+ config.defaultTarget ||
248
+ config.folders?.[0]?.target ||
249
+ "~/Documents/Organized",
250
+ );
251
+
252
+ p.log.info(`Source: ${color.cyan(sourcePath)}`);
253
+ p.log.info(`Target: ${color.cyan(targetPath)}`);
254
+
255
+ // Scan directory
256
+ const spinner = p.spinner();
257
+ spinner.start("Scanning directory...");
258
+
259
+ const files = await scanDirectory(sourcePath, {
260
+ recursive: options.recursive,
261
+ maxDepth: parseInt(options.depth || "1"),
262
+ ignore: config.ignore,
263
+ readContent: config.readContent,
264
+ maxContentSize: config.maxContentSize,
265
+ });
266
+
267
+ spinner.stop(`Found ${color.bold(String(files.length))} files`);
268
+
269
+ if (files.length === 0) {
270
+ p.outro(color.yellow("No files to organize"));
271
+ return;
272
+ }
273
+
274
+ // Show file summary
275
+ p.log.info(
276
+ `Total size: ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`,
277
+ );
278
+
279
+ // Analyze with AI
280
+ spinner.start("Analyzing files with AI...");
281
+
282
+ let proposal: OrganizationProposal;
283
+ try {
284
+ proposal = await analyzeFiles({
285
+ files,
286
+ targetDir: targetPath,
287
+ model: parseModelString(options.model),
288
+ });
289
+ spinner.stop("Analysis complete");
290
+ } catch (error: any) {
291
+ spinner.stop("Analysis failed");
292
+ p.cancel(error.message);
293
+ cleanup();
294
+ process.exit(1);
295
+ }
296
+
297
+ // Display proposals
298
+ displayAllProposals(proposal);
299
+
300
+ // Dry run mode
301
+ if (options.dryRun) {
302
+ p.outro(color.cyan("Dry run complete. No files moved."));
303
+ cleanup();
304
+ return;
305
+ }
306
+
307
+ // If no proposals, exit
308
+ if (proposal.proposals.length === 0) {
309
+ p.outro(color.yellow("No files to organize"));
310
+ cleanup();
311
+ return;
312
+ }
313
+
314
+ // Interactive confirmation loop
315
+ let done = false;
316
+ while (!done) {
317
+ if (options.yes) {
318
+ // Auto-apply all
319
+ await executeProposals(proposal.proposals);
320
+ done = true;
321
+ } else {
322
+ const action = await p.select({
323
+ message: "What would you like to do?",
324
+ options: [
325
+ {
326
+ value: "apply_all",
327
+ label: `Apply all ${proposal.proposals.length} moves`,
328
+ hint: "Organize files as proposed",
329
+ },
330
+ {
331
+ value: "view_details",
332
+ label: "View file details",
333
+ hint: "See more info about a file",
334
+ },
335
+ {
336
+ value: "regenerate",
337
+ label: "Regenerate analysis",
338
+ hint: "Ask AI to re-analyze with different instructions",
339
+ },
340
+ {
341
+ value: "cancel",
342
+ label: "Cancel",
343
+ },
344
+ ],
345
+ });
346
+
347
+ if (p.isCancel(action) || action === "cancel") {
348
+ p.cancel("Aborted");
349
+ cleanup();
350
+ process.exit(0);
351
+ }
352
+
353
+ switch (action) {
354
+ case "apply_all":
355
+ await executeProposals(proposal.proposals);
356
+ done = true;
357
+ break;
358
+
359
+ case "view_details":
360
+ await viewProposalDetails(proposal.proposals);
361
+ break;
362
+
363
+ case "regenerate": {
364
+ const newInstructions = await p.text({
365
+ message:
366
+ "Enter additional instructions for AI (or press Enter to retry):",
367
+ placeholder: "e.g., Keep all PDFs together, sort images by date",
368
+ });
369
+
370
+ if (p.isCancel(newInstructions)) {
371
+ break;
372
+ }
373
+
374
+ spinner.start("Re-analyzing files with AI...");
375
+ try {
376
+ proposal = await analyzeFiles({
377
+ files,
378
+ targetDir: targetPath,
379
+ instructions: newInstructions || undefined,
380
+ model: parseModelString(options.model),
381
+ });
382
+ spinner.stop("Analysis complete");
383
+ displayAllProposals(proposal);
384
+ } catch (error: any) {
385
+ spinner.stop("Analysis failed");
386
+ p.log.error(error.message);
387
+ }
388
+ break;
389
+ }
390
+ }
391
+ }
392
+ }
393
+
394
+ p.outro(color.green("Done!"));
395
+ cleanup();
396
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Watch command - monitor folders for new files and auto-organize
3
+ */
4
+
5
+ import * as p from "@clack/prompts";
6
+ import color from "picocolors";
7
+ import {
8
+ expandPath,
9
+ initGlobalConfig,
10
+ parseModelString,
11
+ resolveConfig,
12
+ } from "../lib/config.ts";
13
+ import { analyzeFiles, cleanup } from "../lib/opencode.ts";
14
+ import { getFileMetadata } from "../lib/scanner.ts";
15
+ import { createWatcher, type FileWatcher } from "../lib/watcher.ts";
16
+ import type {
17
+ FileMetadata,
18
+ FileMoveProposal,
19
+ OrganizationProposal,
20
+ WatchEvent,
21
+ WatchOptions,
22
+ } from "../types/organizer.ts";
23
+ import { formatFileSize, moveFile } from "../utils/files.ts";
24
+ import { getCategoryIcon, getFileIcon } from "../utils/icons.ts";
25
+
26
+ /**
27
+ * Display a proposal briefly for watch mode
28
+ */
29
+ function displayProposalBrief(proposals: FileMoveProposal[]): void {
30
+ for (const prop of proposals) {
31
+ const icon = getFileIcon(prop.file.name);
32
+ const categoryIcon = getCategoryIcon(prop.category.name);
33
+ p.log.message(
34
+ ` ${icon} ${prop.file.name} → ${categoryIcon} ${prop.category.name}/${prop.category.subcategory || ""}`,
35
+ );
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Execute proposals silently (for auto mode)
41
+ */
42
+ async function executeProposalsSilent(
43
+ proposals: FileMoveProposal[],
44
+ ): Promise<{ success: number; failed: number }> {
45
+ let success = 0;
46
+ let failed = 0;
47
+
48
+ for (const prop of proposals) {
49
+ const result = await moveFile(prop.sourcePath, prop.destination, {
50
+ overwrite: false,
51
+ });
52
+
53
+ if (result.status === "completed") {
54
+ success++;
55
+ } else {
56
+ failed++;
57
+ }
58
+ }
59
+
60
+ return { success, failed };
61
+ }
62
+
63
+ /**
64
+ * Interactive review of proposals
65
+ */
66
+ async function interactiveReview(
67
+ proposal: OrganizationProposal,
68
+ ): Promise<boolean> {
69
+ p.log.info(color.bold(`\n${proposal.proposals.length} files to organize:\n`));
70
+ displayProposalBrief(proposal.proposals);
71
+
72
+ if (proposal.strategy) {
73
+ p.log.message(color.dim(`\nStrategy: ${proposal.strategy}`));
74
+ }
75
+
76
+ const action = await p.select({
77
+ message: "What would you like to do?",
78
+ options: [
79
+ { value: "apply", label: "Apply all", hint: "Move files as proposed" },
80
+ { value: "skip", label: "Skip", hint: "Don't move these files" },
81
+ { value: "stop", label: "Stop watching", hint: "Exit watch mode" },
82
+ ],
83
+ });
84
+
85
+ if (p.isCancel(action) || action === "stop") {
86
+ return false; // Signal to stop watching
87
+ }
88
+
89
+ if (action === "apply") {
90
+ const s = p.spinner();
91
+ s.start("Moving files...");
92
+
93
+ const { success, failed } = await executeProposalsSilent(
94
+ proposal.proposals,
95
+ );
96
+
97
+ s.stop(
98
+ `Moved ${success} files` +
99
+ (failed > 0 ? color.red(`, ${failed} failed`) : ""),
100
+ );
101
+ }
102
+
103
+ return true; // Continue watching
104
+ }
105
+
106
+ /**
107
+ * Main watch command
108
+ */
109
+ export async function watchCommand(options: WatchOptions): Promise<void> {
110
+ p.intro(color.bgBlue(color.black(" tidyf watch ")));
111
+
112
+ // Initialize global config if needed
113
+ initGlobalConfig();
114
+
115
+ // Resolve configuration
116
+ const config = resolveConfig();
117
+
118
+ // Determine paths to watch
119
+ let watchPaths: string[];
120
+ if (options.paths && options.paths.length > 0) {
121
+ watchPaths = options.paths.map((p) => expandPath(p));
122
+ } else if (config.folders && config.folders.length > 0) {
123
+ // Use watchEnabled config or individual folder watch settings
124
+ const watchEnabled = config.watchEnabled ?? false;
125
+ watchPaths = config.folders
126
+ .filter((f) => f.watch ?? watchEnabled)
127
+ .flatMap((f) => f.sources.map((s) => expandPath(s)));
128
+ } else {
129
+ watchPaths = [expandPath(config.defaultSource || "~/Downloads")];
130
+ }
131
+
132
+ // Determine target directory
133
+ const targetPath = expandPath(
134
+ config.defaultTarget ||
135
+ config.folders?.[0]?.target ||
136
+ "~/Documents/Organized",
137
+ );
138
+
139
+ // Show what we're watching
140
+ p.log.info("Watching directories:");
141
+ for (const path of watchPaths) {
142
+ p.log.message(` ${color.cyan(path)}`);
143
+ }
144
+ p.log.info(`Target: ${color.cyan(targetPath)}`);
145
+
146
+ if (options.auto) {
147
+ p.log.warn(
148
+ color.yellow("Auto mode: Files will be moved without confirmation"),
149
+ );
150
+ } else if (options.queue) {
151
+ p.log.info("Queue mode: Files will be queued for batch review");
152
+ }
153
+
154
+ // Create watcher
155
+ const watcher = createWatcher({
156
+ delay: parseInt(options.delay || "3000"),
157
+ ignore: config.ignore,
158
+ recursive: false,
159
+ });
160
+
161
+ // Queue for batch review mode
162
+ const reviewQueue: FileMetadata[] = [];
163
+
164
+ // Handle file events
165
+ watcher.on("files", async (events: WatchEvent[]) => {
166
+ const newFilePaths = events
167
+ .filter((e) => e.type === "add")
168
+ .map((e) => e.path);
169
+
170
+ if (newFilePaths.length === 0) {
171
+ return;
172
+ }
173
+
174
+ p.log.info(
175
+ `\n${color.green("+")} ${newFilePaths.length} new file(s) detected`,
176
+ );
177
+
178
+ // Get metadata for new files
179
+ const files: FileMetadata[] = [];
180
+ for (const path of newFilePaths) {
181
+ const metadata = await getFileMetadata(path, {
182
+ readContent: config.readContent,
183
+ maxContentSize: config.maxContentSize,
184
+ });
185
+ if (metadata) {
186
+ files.push(metadata);
187
+ }
188
+ }
189
+
190
+ if (files.length === 0) {
191
+ return;
192
+ }
193
+
194
+ // Queue mode: add to queue for later review
195
+ if (options.queue) {
196
+ reviewQueue.push(...files);
197
+ p.log.info(
198
+ `Queued ${files.length} files (${reviewQueue.length} total in queue)`,
199
+ );
200
+ p.log.message(color.dim("Press Enter to review queue"));
201
+ return;
202
+ }
203
+
204
+ // Analyze with AI
205
+ const s = p.spinner();
206
+ s.start("Analyzing files with AI...");
207
+
208
+ let proposal: OrganizationProposal;
209
+ try {
210
+ proposal = await analyzeFiles({
211
+ files,
212
+ targetDir: targetPath,
213
+ model: parseModelString(options.model),
214
+ });
215
+ s.stop("Analysis complete");
216
+ } catch (error: any) {
217
+ s.stop("Analysis failed");
218
+ p.log.error(error.message);
219
+ return;
220
+ }
221
+
222
+ if (proposal.proposals.length === 0) {
223
+ p.log.warn("No files could be categorized");
224
+ return;
225
+ }
226
+
227
+ // Auto mode: apply without confirmation
228
+ if (options.auto) {
229
+ const { success, failed } = await executeProposalsSilent(
230
+ proposal.proposals,
231
+ );
232
+ p.log.success(
233
+ `Moved ${success} files` +
234
+ (failed > 0 ? color.red(`, ${failed} failed`) : ""),
235
+ );
236
+ return;
237
+ }
238
+
239
+ // Interactive mode: ask for confirmation
240
+ const shouldContinue = await interactiveReview(proposal);
241
+ if (!shouldContinue) {
242
+ watcher.stop();
243
+ }
244
+ });
245
+
246
+ watcher.on("ready", () => {
247
+ p.log.success("Watcher ready");
248
+ p.log.message(color.dim("Press Ctrl+C to stop watching"));
249
+ });
250
+
251
+ watcher.on("error", (error) => {
252
+ p.log.error(`Watch error: ${error.message}`);
253
+ });
254
+
255
+ watcher.on("stop", () => {
256
+ p.log.info("Watcher stopped");
257
+ });
258
+
259
+ // Start watching
260
+ watcher.start(watchPaths);
261
+
262
+ // Handle Ctrl+C
263
+ process.on("SIGINT", async () => {
264
+ console.log();
265
+ p.log.info("Stopping watcher...");
266
+
267
+ // If queue mode and there are queued files, offer to review
268
+ if (options.queue && reviewQueue.length > 0) {
269
+ const review = await p.confirm({
270
+ message: `Review ${reviewQueue.length} queued files before exiting?`,
271
+ initialValue: true,
272
+ });
273
+
274
+ if (!p.isCancel(review) && review) {
275
+ const s = p.spinner();
276
+ s.start("Analyzing queued files...");
277
+
278
+ try {
279
+ const proposal = await analyzeFiles({
280
+ files: reviewQueue,
281
+ targetDir: targetPath,
282
+ model: parseModelString(options.model),
283
+ });
284
+ s.stop("Analysis complete");
285
+
286
+ await interactiveReview(proposal);
287
+ } catch (error: any) {
288
+ s.stop("Analysis failed");
289
+ p.log.error(error.message);
290
+ }
291
+ }
292
+ }
293
+
294
+ watcher.stop();
295
+ cleanup();
296
+ p.outro(color.green("Done!"));
297
+ process.exit(0);
298
+ });
299
+
300
+ // Keep process alive
301
+ await new Promise(() => {});
302
+ }