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.
- package/LICENSE +21 -0
- package/README.md +299 -0
- package/dist/cli.js +19340 -0
- package/dist/fsevents-hj42pnne.node +0 -0
- package/dist/index.js +17617 -0
- package/package.json +58 -0
- package/src/cli.ts +63 -0
- package/src/commands/config.ts +630 -0
- package/src/commands/organize.ts +396 -0
- package/src/commands/watch.ts +302 -0
- package/src/index.ts +93 -0
- package/src/lib/config.ts +335 -0
- package/src/lib/opencode.ts +380 -0
- package/src/lib/scanner.ts +296 -0
- package/src/lib/watcher.ts +151 -0
- package/src/types/config.ts +69 -0
- package/src/types/organizer.ts +144 -0
- package/src/utils/files.ts +198 -0
- package/src/utils/icons.ts +195 -0
|
@@ -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
|
+
}
|