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,630 @@
1
+ /**
2
+ * Config command - configure AI models, folders, and rules
3
+ */
4
+
5
+ import * as p from "@clack/prompts";
6
+ import { existsSync } from "fs";
7
+ import color from "picocolors";
8
+ import {
9
+ expandPath,
10
+ getDefaultConfig,
11
+ getDefaultRules,
12
+ getGlobalConfigPath,
13
+ getGlobalRulesPath,
14
+ getLocalConfigPath,
15
+ getLocalRulesPath,
16
+ initGlobalConfig,
17
+ readConfig,
18
+ readRules,
19
+ resolveConfig,
20
+ writeConfig,
21
+ writeRules,
22
+ } from "../lib/config.ts";
23
+ import { cleanup, getAvailableModels } from "../lib/opencode.ts";
24
+ import type {
25
+ ConfigOptions,
26
+ ModelSelection,
27
+ TidyConfig,
28
+ } from "../types/config.ts";
29
+
30
+ /**
31
+ * Main config command
32
+ */
33
+ export async function configCommand(options: ConfigOptions): Promise<void> {
34
+ p.intro(color.bgCyan(color.black(" tidyf config ")));
35
+
36
+ // Initialize global config if needed
37
+ initGlobalConfig();
38
+
39
+ // Determine scope
40
+ const scope = options.local ? "local" : "global";
41
+ const configPath =
42
+ scope === "global" ? getGlobalConfigPath() : getLocalConfigPath();
43
+ const rulesPath =
44
+ scope === "global" ? getGlobalRulesPath() : getLocalRulesPath();
45
+
46
+ p.log.info(`Configuring ${color.bold(scope)} settings`);
47
+ p.log.message(color.dim(`Config: ${configPath}`));
48
+
49
+ // Main menu loop
50
+ let done = false;
51
+ while (!done) {
52
+ // Re-read config each iteration to show updated values
53
+ const currentConfig = readConfig(configPath);
54
+ const effectiveConfig = resolveConfig();
55
+
56
+ const action = await p.select({
57
+ message: "What would you like to configure?",
58
+ options: [
59
+ {
60
+ value: "model",
61
+ label: "AI Model",
62
+ hint: `Current: ${effectiveConfig.organizer?.provider}/${effectiveConfig.organizer?.model}`,
63
+ },
64
+ {
65
+ value: "source",
66
+ label: "Default Source Directory",
67
+ hint: effectiveConfig.defaultSource || "Not set",
68
+ },
69
+ {
70
+ value: "target",
71
+ label: "Default Target Directory",
72
+ hint: effectiveConfig.defaultTarget || "Not set",
73
+ },
74
+ {
75
+ value: "ignore",
76
+ label: "Ignore Patterns",
77
+ hint: `${effectiveConfig.ignore?.length || 0} patterns`,
78
+ },
79
+ {
80
+ value: "content",
81
+ label: "Content Reading",
82
+ hint: effectiveConfig.readContent ? "Enabled" : "Disabled",
83
+ },
84
+ {
85
+ value: "watch",
86
+ label: "Watch Mode",
87
+ hint: effectiveConfig.watchEnabled ? "Enabled" : "Disabled",
88
+ },
89
+ {
90
+ value: "rules",
91
+ label: "Edit Organization Rules",
92
+ hint: "Customize AI prompts",
93
+ },
94
+ {
95
+ value: "view",
96
+ label: "View Current Configuration",
97
+ },
98
+ {
99
+ value: "reset",
100
+ label: "Reset to Defaults",
101
+ hint: color.red("Destructive"),
102
+ },
103
+ {
104
+ value: "done",
105
+ label: "Done",
106
+ },
107
+ ],
108
+ });
109
+
110
+ if (p.isCancel(action) || action === "done") {
111
+ done = true;
112
+ break;
113
+ }
114
+
115
+ switch (action) {
116
+ case "model":
117
+ await configureModel(configPath, currentConfig);
118
+ break;
119
+ case "source":
120
+ await configureSource(configPath, currentConfig);
121
+ break;
122
+ case "target":
123
+ await configureTarget(configPath, currentConfig);
124
+ break;
125
+ case "ignore":
126
+ await configureIgnore(configPath, currentConfig);
127
+ break;
128
+ case "content":
129
+ await configureContent(configPath, currentConfig);
130
+ break;
131
+ case "watch":
132
+ await configureWatch(configPath, currentConfig);
133
+ break;
134
+ case "rules":
135
+ await configureRules(rulesPath);
136
+ break;
137
+ case "view":
138
+ viewConfig(effectiveConfig, scope);
139
+ break;
140
+ case "reset":
141
+ await resetConfig(configPath, rulesPath, scope);
142
+ break;
143
+ }
144
+ }
145
+
146
+ p.outro(color.green("Configuration saved!"));
147
+ cleanup();
148
+ process.exit(0);
149
+ }
150
+
151
+ /**
152
+ * Configure AI model
153
+ */
154
+ /**
155
+ * Configure AI model
156
+ */
157
+ async function configureModel(
158
+ configPath: string,
159
+ config: TidyConfig,
160
+ ): Promise<void> {
161
+ // Try to get available models
162
+ const s = p.spinner();
163
+ s.start("Fetching available models from OpenCode...");
164
+
165
+ let providers: any[] = [];
166
+ try {
167
+ const response = await getAvailableModels();
168
+ if (response.error) {
169
+ throw new Error("Failed to fetch models");
170
+ }
171
+ providers = response.data?.providers || [];
172
+ s.stop(`Fetched ${providers.length} providers`);
173
+ } catch (error: any) {
174
+ s.stop("Failed to fetch models");
175
+ p.log.error(error.message);
176
+ p.log.warn("Using manual entry fallback.");
177
+ }
178
+
179
+ // Helper to format model display
180
+ const formatModel = (sel?: ModelSelection) => {
181
+ if (!sel) return color.dim("default");
182
+ return `${color.cyan(sel.provider)}/${color.green(sel.model)}`;
183
+ };
184
+
185
+ p.log.info(`Current Model: ${formatModel(config.organizer)}`);
186
+
187
+ let providerId: string;
188
+ let modelName: string;
189
+
190
+ if (providers.length > 0) {
191
+ // Select Provider
192
+ const providerOptions = providers.map((prov) => ({
193
+ value: prov.id,
194
+ label: prov.name || prov.id,
195
+ }));
196
+
197
+ // Add custom option
198
+ providerOptions.push({
199
+ value: "custom",
200
+ label: "Enter custom provider...",
201
+ });
202
+
203
+ const selectedProvider = await p.select({
204
+ message: "Select AI provider:",
205
+ options: providerOptions,
206
+ });
207
+
208
+ if (p.isCancel(selectedProvider)) return;
209
+
210
+ if (selectedProvider === "custom") {
211
+ const customProv = await p.text({
212
+ message: "Enter provider ID:",
213
+ placeholder: "opencode",
214
+ validate: (value) => {
215
+ if (!value) return "Provider ID is required";
216
+ },
217
+ });
218
+ if (p.isCancel(customProv)) return;
219
+ providerId = customProv;
220
+
221
+ const customModel = await p.text({
222
+ message: "Enter model ID:",
223
+ placeholder: "gpt-4o",
224
+ validate: (value) => {
225
+ if (!value) return "Model ID is required";
226
+ },
227
+ });
228
+ if (p.isCancel(customModel)) return;
229
+ modelName = customModel;
230
+ } else {
231
+ providerId = selectedProvider as string;
232
+ const providerData = providers.find((p) => p.id === providerId);
233
+
234
+ if (!providerData || !providerData.models) {
235
+ p.log.warn(
236
+ `No models found for provider ${providerId}, please enter manually.`,
237
+ );
238
+ const customModel = await p.text({
239
+ message: "Enter model ID:",
240
+ placeholder: "gpt-4o",
241
+ validate: (value) => {
242
+ if (!value) return "Model ID is required";
243
+ },
244
+ });
245
+ if (p.isCancel(customModel)) return;
246
+ modelName = customModel;
247
+ } else {
248
+ // Handle models (can be array or object map)
249
+ let modelIds: string[] = [];
250
+ if (Array.isArray(providerData.models)) {
251
+ modelIds = providerData.models;
252
+ } else if (typeof providerData.models === "object") {
253
+ modelIds = Object.keys(providerData.models);
254
+ }
255
+
256
+ if (modelIds.length === 0) {
257
+ p.log.warn(`No models found for provider ${providerId}`);
258
+ const customModel = await p.text({
259
+ message: "Enter model ID:",
260
+ });
261
+ if (p.isCancel(customModel)) return;
262
+ modelName = customModel;
263
+ } else {
264
+ const modelOptions = modelIds.map((model: string) => ({
265
+ value: model,
266
+ label: model,
267
+ }));
268
+ // Add custom option
269
+ modelOptions.push({
270
+ value: "custom",
271
+ label: "Enter custom model...",
272
+ });
273
+
274
+ const selectedModel = await p.select({
275
+ message: "Select model:",
276
+ options: modelOptions,
277
+ });
278
+
279
+ if (p.isCancel(selectedModel)) return;
280
+
281
+ if (selectedModel === "custom") {
282
+ const customModel = await p.text({
283
+ message: "Enter model ID:",
284
+ });
285
+ if (p.isCancel(customModel)) return;
286
+ modelName = customModel;
287
+ } else {
288
+ modelName = selectedModel as string;
289
+ }
290
+ }
291
+ }
292
+ }
293
+ } else {
294
+ // Fallback to manual entry if no providers found
295
+ const manualEntry = await p.text({
296
+ message: "Enter model (format: provider/model):",
297
+ placeholder: "opencode/gpt-4o",
298
+ validate: (value) => {
299
+ if (!value.includes("/")) {
300
+ return "Model must be in format: provider/model";
301
+ }
302
+ },
303
+ });
304
+
305
+ if (p.isCancel(manualEntry)) {
306
+ return;
307
+ }
308
+
309
+ const parts = manualEntry.split("/");
310
+ providerId = parts[0];
311
+ modelName = parts.slice(1).join("/");
312
+ }
313
+
314
+ config.organizer = { provider: providerId, model: modelName };
315
+ writeConfig(configPath, config);
316
+
317
+ p.log.success(`Model set to ${color.cyan(providerId + "/" + modelName)}`);
318
+ }
319
+
320
+ /**
321
+ * Configure source directory
322
+ */
323
+ async function configureSource(
324
+ configPath: string,
325
+ config: TidyConfig,
326
+ ): Promise<void> {
327
+ const source = await p.text({
328
+ message: "Enter default source directory to organize files from:",
329
+ initialValue: config.defaultSource || "~/Downloads",
330
+ placeholder: "~/Downloads",
331
+ validate: (value) => {
332
+ if (!value) {
333
+ return "Source directory is required";
334
+ }
335
+ },
336
+ });
337
+
338
+ if (p.isCancel(source)) {
339
+ return;
340
+ }
341
+
342
+ config.defaultSource = source;
343
+ writeConfig(configPath, config);
344
+
345
+ p.log.success(`Source directory set to ${color.cyan(source)}`);
346
+ }
347
+
348
+ /**
349
+ * Configure target directory
350
+ */
351
+ async function configureTarget(
352
+ configPath: string,
353
+ config: TidyConfig,
354
+ ): Promise<void> {
355
+ const target = await p.text({
356
+ message: "Enter default target directory for organized files:",
357
+ initialValue: config.defaultTarget || "~/Documents/Organized",
358
+ placeholder: "~/Documents/Organized",
359
+ validate: (value) => {
360
+ if (!value) {
361
+ return "Target directory is required";
362
+ }
363
+ },
364
+ });
365
+
366
+ if (p.isCancel(target)) {
367
+ return;
368
+ }
369
+
370
+ config.defaultTarget = target;
371
+ writeConfig(configPath, config);
372
+
373
+ p.log.success(`Target directory set to ${color.cyan(target)}`);
374
+ }
375
+
376
+ /**
377
+ * Configure ignore patterns
378
+ */
379
+ async function configureIgnore(
380
+ configPath: string,
381
+ config: TidyConfig,
382
+ ): Promise<void> {
383
+ const currentPatterns = config.ignore || [];
384
+
385
+ p.log.info("Current ignore patterns:");
386
+ if (currentPatterns.length === 0) {
387
+ p.log.message(color.dim(" (none)"));
388
+ } else {
389
+ for (const pattern of currentPatterns) {
390
+ p.log.message(` ${pattern}`);
391
+ }
392
+ }
393
+
394
+ const action = await p.select({
395
+ message: "What would you like to do?",
396
+ options: [
397
+ { value: "add", label: "Add pattern" },
398
+ { value: "remove", label: "Remove pattern" },
399
+ { value: "reset", label: "Reset to defaults" },
400
+ { value: "back", label: "Back" },
401
+ ],
402
+ });
403
+
404
+ if (p.isCancel(action) || action === "back") {
405
+ return;
406
+ }
407
+
408
+ switch (action) {
409
+ case "add": {
410
+ const pattern = await p.text({
411
+ message: "Enter pattern to ignore (e.g., *.tmp, .DS_Store):",
412
+ placeholder: "*.tmp",
413
+ });
414
+
415
+ if (!p.isCancel(pattern) && pattern) {
416
+ config.ignore = [...currentPatterns, pattern];
417
+ writeConfig(configPath, config);
418
+ p.log.success(`Added pattern: ${color.cyan(pattern)}`);
419
+ }
420
+ break;
421
+ }
422
+
423
+ case "remove": {
424
+ if (currentPatterns.length === 0) {
425
+ p.log.warn("No patterns to remove");
426
+ break;
427
+ }
428
+
429
+ const toRemove = await p.select({
430
+ message: "Select pattern to remove:",
431
+ options: currentPatterns.map((p) => ({ value: p, label: p })),
432
+ });
433
+
434
+ if (!p.isCancel(toRemove)) {
435
+ config.ignore = currentPatterns.filter((p) => p !== toRemove);
436
+ writeConfig(configPath, config);
437
+ p.log.success(`Removed pattern: ${color.cyan(toRemove as string)}`);
438
+ }
439
+ break;
440
+ }
441
+
442
+ case "reset": {
443
+ const defaults = getDefaultConfig();
444
+ config.ignore = defaults.ignore;
445
+ writeConfig(configPath, config);
446
+ p.log.success("Reset ignore patterns to defaults");
447
+ break;
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Configure content reading
454
+ */
455
+ async function configureContent(
456
+ configPath: string,
457
+ config: TidyConfig,
458
+ ): Promise<void> {
459
+ const enabled = await p.confirm({
460
+ message:
461
+ "Enable content reading? (Reads text files to help AI categorize better)",
462
+ initialValue: config.readContent ?? false,
463
+ });
464
+
465
+ if (p.isCancel(enabled)) {
466
+ return;
467
+ }
468
+
469
+ config.readContent = enabled;
470
+
471
+ if (enabled) {
472
+ const maxSize = await p.text({
473
+ message: "Maximum file size to read (bytes):",
474
+ initialValue: String(config.maxContentSize || 10240),
475
+ placeholder: "10240",
476
+ validate: (value) => {
477
+ const num = parseInt(value);
478
+ if (isNaN(num) || num <= 0) {
479
+ return "Must be a positive number";
480
+ }
481
+ },
482
+ });
483
+
484
+ if (!p.isCancel(maxSize)) {
485
+ config.maxContentSize = parseInt(maxSize);
486
+ }
487
+ }
488
+
489
+ writeConfig(configPath, config);
490
+ p.log.success(
491
+ `Content reading ${enabled ? color.green("enabled") : color.yellow("disabled")}`,
492
+ );
493
+ }
494
+
495
+ /**
496
+ * Configure watch mode
497
+ */
498
+ async function configureWatch(
499
+ configPath: string,
500
+ config: TidyConfig,
501
+ ): Promise<void> {
502
+ const enabled = await p.confirm({
503
+ message: "Enable watch mode by default? (Auto-organize new files in source directory)",
504
+ initialValue: config.watchEnabled ?? false,
505
+ });
506
+
507
+ if (p.isCancel(enabled)) {
508
+ return;
509
+ }
510
+
511
+ config.watchEnabled = enabled;
512
+ writeConfig(configPath, config);
513
+
514
+ p.log.success(
515
+ `Watch mode ${enabled ? color.green("enabled") : color.yellow("disabled")}`,
516
+ );
517
+ }
518
+
519
+ /**
520
+ * Configure rules (open in editor hint)
521
+ */
522
+ async function configureRules(rulesPath: string): Promise<void> {
523
+ const rules = readRules(rulesPath);
524
+
525
+ if (!rules) {
526
+ const create = await p.confirm({
527
+ message: "No rules file found. Create one with defaults?",
528
+ initialValue: true,
529
+ });
530
+
531
+ if (p.isCancel(create) || !create) {
532
+ return;
533
+ }
534
+
535
+ writeRules(rulesPath, getDefaultRules());
536
+ p.log.success("Created rules file with defaults");
537
+ }
538
+
539
+ p.log.info(`Rules file: ${color.cyan(rulesPath)}`);
540
+ p.log.message(
541
+ color.dim(
542
+ "Edit this markdown file to customize how AI categorizes your files.",
543
+ ),
544
+ );
545
+ p.log.message(
546
+ color.dim("You can define categories, special rules, and output format."),
547
+ );
548
+
549
+ // Show first few lines as preview
550
+ const preview = (readRules(rulesPath) || "")
551
+ .split("\n")
552
+ .slice(0, 10)
553
+ .join("\n");
554
+ console.log();
555
+ console.log(color.dim(preview));
556
+ console.log(color.dim("..."));
557
+ }
558
+
559
+ /**
560
+ * View current configuration
561
+ */
562
+ function viewConfig(config: TidyConfig, scope: string): void {
563
+ console.log();
564
+ p.log.info(color.bold(`Current ${scope} configuration:`));
565
+ console.log();
566
+
567
+ p.log.message(
568
+ `${color.bold("AI Model:")} ${config.organizer?.provider}/${config.organizer?.model}`,
569
+ );
570
+ p.log.message(
571
+ `${color.bold("Default Source:")} ${config.defaultSource || "(not set)"}`,
572
+ );
573
+ p.log.message(
574
+ `${color.bold("Default Target:")} ${config.defaultTarget || "(not set)"}`,
575
+ );
576
+ p.log.message(
577
+ `${color.bold("Content Reading:")} ${config.readContent ? "Enabled" : "Disabled"}`,
578
+ );
579
+ p.log.message(
580
+ `${color.bold("Watch Mode:")} ${config.watchEnabled ? "Enabled" : "Disabled"}`,
581
+ );
582
+
583
+ if (config.readContent) {
584
+ p.log.message(
585
+ `${color.bold("Max Content Size:")} ${config.maxContentSize} bytes`,
586
+ );
587
+ }
588
+
589
+ console.log();
590
+ p.log.message(color.bold("Ignore Patterns:"));
591
+ for (const pattern of config.ignore || []) {
592
+ p.log.message(` ${pattern}`);
593
+ }
594
+
595
+ if (config.folders && config.folders.length > 0) {
596
+ console.log();
597
+ p.log.message(color.bold("Configured Folders:"));
598
+ for (const folder of config.folders) {
599
+ p.log.message(` Sources: ${folder.sources.join(", ")}`);
600
+ p.log.message(` Target: ${folder.target}`);
601
+ p.log.message(` Watch: ${folder.watch ? "Yes" : "No"}`);
602
+ }
603
+ }
604
+
605
+ console.log();
606
+ }
607
+
608
+ /**
609
+ * Reset configuration to defaults
610
+ */
611
+ async function resetConfig(
612
+ configPath: string,
613
+ rulesPath: string,
614
+ scope: string,
615
+ ): Promise<void> {
616
+ const confirm = await p.confirm({
617
+ message: `Reset all ${scope} settings to defaults? This cannot be undone.`,
618
+ initialValue: false,
619
+ });
620
+
621
+ if (p.isCancel(confirm) || !confirm) {
622
+ return;
623
+ }
624
+
625
+ const defaults = getDefaultConfig();
626
+ writeConfig(configPath, defaults);
627
+ writeRules(rulesPath, getDefaultRules());
628
+
629
+ p.log.success("Configuration reset to defaults");
630
+ }