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.
@@ -0,0 +1,943 @@
1
+ /**
2
+ * Profile command - manage organization profiles
3
+ *
4
+ * Profiles are named configuration presets that bundle source/target paths,
5
+ * AI model preferences, ignore patterns, and optionally custom rules.
6
+ */
7
+
8
+ import * as p from "@clack/prompts";
9
+ import color from "picocolors";
10
+ import { readFileSync, rmSync, writeFileSync } from "fs";
11
+ import {
12
+ copyProfile,
13
+ deleteProfile,
14
+ exportProfile,
15
+ getProfileRulesPath,
16
+ importProfile,
17
+ installPreset,
18
+ listProfiles,
19
+ profileExists,
20
+ readProfile,
21
+ readProfileRules,
22
+ validateProfileName,
23
+ writeProfile,
24
+ writeProfileRules,
25
+ } from "../lib/profiles.ts";
26
+ import { listPresets, getPresetNames } from "../lib/presets.ts";
27
+ import {
28
+ getDefaultRules,
29
+ resolveConfig,
30
+ } from "../lib/config.ts";
31
+ import { getAvailableModels, cleanup } from "../lib/opencode.ts";
32
+ import type { Profile, ProfileCommandOptions, ProfileExport } from "../types/profile.ts";
33
+ import type { ModelSelection } from "../types/config.ts";
34
+
35
+ /**
36
+ * Main profile command
37
+ */
38
+ export async function profileCommand(options: ProfileCommandOptions): Promise<void> {
39
+ p.intro(color.bgMagenta(color.black(" tidyf profile ")));
40
+
41
+ // Route to appropriate subcommand
42
+ const action = options.action || "interactive";
43
+
44
+ switch (action) {
45
+ case "list":
46
+ listProfilesInteractive();
47
+ break;
48
+
49
+ case "create":
50
+ await createProfileInteractive(options.name, options.fromCurrent);
51
+ break;
52
+
53
+ case "edit":
54
+ if (!options.name) {
55
+ p.log.error("Profile name required. Usage: tidyf profile edit <name>");
56
+ break;
57
+ }
58
+ await editProfileInteractive(options.name);
59
+ break;
60
+
61
+ case "delete":
62
+ if (!options.name) {
63
+ p.log.error("Profile name required. Usage: tidyf profile delete <name>");
64
+ break;
65
+ }
66
+ await deleteProfileInteractive(options.name, options.force);
67
+ break;
68
+
69
+ case "show":
70
+ if (!options.name) {
71
+ p.log.error("Profile name required. Usage: tidyf profile show <name>");
72
+ break;
73
+ }
74
+ showProfile(options.name);
75
+ break;
76
+
77
+ case "copy":
78
+ if (!options.name || !options.args?.[0]) {
79
+ p.log.error("Source and destination required. Usage: tidyf profile copy <source> <destination>");
80
+ break;
81
+ }
82
+ await copyProfileInteractive(options.name, options.args[0]);
83
+ break;
84
+
85
+ case "export":
86
+ if (!options.name) {
87
+ p.log.error("Profile name required. Usage: tidyf profile export <name>");
88
+ break;
89
+ }
90
+ exportProfileToStdout(options.name);
91
+ break;
92
+
93
+ case "import":
94
+ if (!options.name) {
95
+ p.log.error("File path required. Usage: tidyf profile import <file>");
96
+ break;
97
+ }
98
+ await importProfileFromFile(options.name);
99
+ break;
100
+
101
+ case "install":
102
+ if (!options.name) {
103
+ p.log.error("Preset name required. Usage: tidyf profile install <preset> [profile-name]");
104
+ p.log.info(`Available presets: ${getPresetNames().join(", ")}`);
105
+ break;
106
+ }
107
+ await installPresetInteractive(options.name, options.args?.[0]);
108
+ break;
109
+
110
+ case "presets":
111
+ listPresetsInteractive();
112
+ break;
113
+
114
+ case "interactive":
115
+ default:
116
+ await interactiveMenu();
117
+ break;
118
+ }
119
+
120
+ p.outro(color.green("Done!"));
121
+ cleanup();
122
+ }
123
+
124
+ /**
125
+ * Interactive profile management menu
126
+ */
127
+ async function interactiveMenu(): Promise<void> {
128
+ const profiles = listProfiles();
129
+
130
+ let done = false;
131
+ while (!done) {
132
+ const action = await p.select({
133
+ message: "What would you like to do?",
134
+ options: [
135
+ {
136
+ value: "list",
137
+ label: "List profiles",
138
+ hint: `${profiles.length} profile(s)`,
139
+ },
140
+ {
141
+ value: "create",
142
+ label: "Create new profile",
143
+ },
144
+ {
145
+ value: "install",
146
+ label: "Install preset",
147
+ hint: "developer, creative, student, downloads",
148
+ },
149
+ {
150
+ value: "edit",
151
+ label: "Edit a profile",
152
+ hint: profiles.length === 0 ? "No profiles yet" : undefined,
153
+ },
154
+ {
155
+ value: "delete",
156
+ label: "Delete a profile",
157
+ hint: profiles.length === 0 ? "No profiles yet" : undefined,
158
+ },
159
+ {
160
+ value: "show",
161
+ label: "Show profile details",
162
+ },
163
+ {
164
+ value: "copy",
165
+ label: "Copy a profile",
166
+ },
167
+ {
168
+ value: "done",
169
+ label: "Done",
170
+ },
171
+ ],
172
+ });
173
+
174
+ if (p.isCancel(action) || action === "done") {
175
+ done = true;
176
+ break;
177
+ }
178
+
179
+ switch (action) {
180
+ case "list":
181
+ listProfilesInteractive();
182
+ break;
183
+
184
+ case "create":
185
+ await createProfileInteractive();
186
+ break;
187
+
188
+ case "install":
189
+ await installPresetMenuInteractive();
190
+ break;
191
+
192
+ case "edit": {
193
+ const name = await selectProfile("edit");
194
+ if (name) await editProfileInteractive(name);
195
+ break;
196
+ }
197
+
198
+ case "delete": {
199
+ const name = await selectProfile("delete");
200
+ if (name) await deleteProfileInteractive(name);
201
+ break;
202
+ }
203
+
204
+ case "show": {
205
+ const name = await selectProfile("view");
206
+ if (name) showProfile(name);
207
+ break;
208
+ }
209
+
210
+ case "copy": {
211
+ const source = await selectProfile("copy");
212
+ if (source) {
213
+ const dest = await p.text({
214
+ message: "New profile name:",
215
+ validate: (value) => {
216
+ const validation = validateProfileName(value);
217
+ if (!validation.valid) return validation.error;
218
+ if (profileExists(value)) return `Profile "${value}" already exists`;
219
+ },
220
+ });
221
+ if (!p.isCancel(dest)) {
222
+ await copyProfileInteractive(source, dest);
223
+ }
224
+ }
225
+ break;
226
+ }
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Select a profile from the list
233
+ */
234
+ async function selectProfile(action: string): Promise<string | null> {
235
+ const profiles = listProfiles();
236
+
237
+ if (profiles.length === 0) {
238
+ p.log.warn("No profiles found. Create one first.");
239
+ return null;
240
+ }
241
+
242
+ const selected = await p.select({
243
+ message: `Select profile to ${action}:`,
244
+ options: profiles.map((profile) => ({
245
+ value: profile.name,
246
+ label: profile.name,
247
+ hint: profile.description || (profile.hasCustomRules ? "custom rules" : undefined),
248
+ })),
249
+ });
250
+
251
+ if (p.isCancel(selected)) return null;
252
+ return selected as string;
253
+ }
254
+
255
+ /**
256
+ * List all profiles
257
+ */
258
+ function listProfilesInteractive(): void {
259
+ const profiles = listProfiles();
260
+
261
+ console.log();
262
+ if (profiles.length === 0) {
263
+ p.log.info("No profiles found.");
264
+ p.log.message(color.dim("Create one with: tidyf profile create <name>"));
265
+ } else {
266
+ p.log.info(color.bold(`${profiles.length} profile(s):`));
267
+ console.log();
268
+
269
+ for (const profile of profiles) {
270
+ const description = profile.description ? ` - ${profile.description}` : "";
271
+ const customRules = profile.hasCustomRules ? color.cyan(" (custom rules)") : "";
272
+ p.log.message(` ${color.green("●")} ${color.bold(profile.name)}${description}${customRules}`);
273
+ }
274
+ }
275
+ console.log();
276
+ }
277
+
278
+ /**
279
+ * Create a new profile
280
+ */
281
+ async function createProfileInteractive(
282
+ initialName?: string,
283
+ fromCurrent?: boolean,
284
+ ): Promise<void> {
285
+ // Get profile name
286
+ let name = initialName;
287
+ if (!name) {
288
+ const inputName = await p.text({
289
+ message: "Profile name:",
290
+ placeholder: "work",
291
+ validate: (value) => {
292
+ const validation = validateProfileName(value);
293
+ if (!validation.valid) return validation.error;
294
+ if (profileExists(value)) return `Profile "${value}" already exists`;
295
+ },
296
+ });
297
+
298
+ if (p.isCancel(inputName)) return;
299
+ name = inputName;
300
+ } else {
301
+ // Validate provided name
302
+ const validation = validateProfileName(name);
303
+ if (!validation.valid) {
304
+ p.log.error(validation.error!);
305
+ return;
306
+ }
307
+ if (profileExists(name)) {
308
+ p.log.error(`Profile "${name}" already exists`);
309
+ return;
310
+ }
311
+ }
312
+
313
+ // Start with current effective config or empty
314
+ let profile: Profile;
315
+ if (fromCurrent) {
316
+ const currentConfig = resolveConfig();
317
+ profile = {
318
+ name,
319
+ ...currentConfig,
320
+ };
321
+ p.log.info("Creating profile from current effective configuration...");
322
+ } else {
323
+ profile = { name };
324
+ }
325
+
326
+ // Get description
327
+ const description = await p.text({
328
+ message: "Description (optional):",
329
+ placeholder: "e.g., Work documents and projects",
330
+ });
331
+ if (!p.isCancel(description) && description) {
332
+ profile.description = description;
333
+ }
334
+
335
+ // Configure source
336
+ const configureSource = await p.confirm({
337
+ message: "Set custom source directory?",
338
+ initialValue: !fromCurrent,
339
+ });
340
+ if (!p.isCancel(configureSource) && configureSource) {
341
+ const source = await p.text({
342
+ message: "Source directory:",
343
+ placeholder: "~/Downloads",
344
+ initialValue: profile.defaultSource,
345
+ });
346
+ if (!p.isCancel(source) && source) {
347
+ profile.defaultSource = source;
348
+ }
349
+ }
350
+
351
+ // Configure target
352
+ const configureTarget = await p.confirm({
353
+ message: "Set custom target directory?",
354
+ initialValue: !fromCurrent,
355
+ });
356
+ if (!p.isCancel(configureTarget) && configureTarget) {
357
+ const target = await p.text({
358
+ message: "Target directory:",
359
+ placeholder: "~/Documents/Organized",
360
+ initialValue: profile.defaultTarget,
361
+ });
362
+ if (!p.isCancel(target) && target) {
363
+ profile.defaultTarget = target;
364
+ }
365
+ }
366
+
367
+ // Configure model
368
+ const configureModel = await p.confirm({
369
+ message: "Set custom AI model?",
370
+ initialValue: false,
371
+ });
372
+ if (!p.isCancel(configureModel) && configureModel) {
373
+ await configureProfileModel(profile);
374
+ }
375
+
376
+ // Save profile
377
+ writeProfile(name, profile);
378
+ p.log.success(`Profile "${name}" created!`);
379
+
380
+ // Ask about custom rules
381
+ const createRules = await p.confirm({
382
+ message: "Create custom rules for this profile?",
383
+ initialValue: false,
384
+ });
385
+ if (!p.isCancel(createRules) && createRules) {
386
+ writeProfileRules(name, getDefaultRules());
387
+ p.log.success(`Created rules at ${getProfileRulesPath(name)}`);
388
+ p.log.message(color.dim("Edit this file to customize how AI categorizes files for this profile."));
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Edit an existing profile
394
+ */
395
+ async function editProfileInteractive(name: string): Promise<void> {
396
+ if (!profileExists(name)) {
397
+ p.log.error(`Profile "${name}" not found`);
398
+
399
+ const profiles = listProfiles();
400
+ if (profiles.length > 0) {
401
+ p.log.info("Available profiles:");
402
+ profiles.forEach((pr) => p.log.message(` - ${pr.name}`));
403
+ }
404
+ return;
405
+ }
406
+
407
+ const profile = readProfile(name);
408
+ if (!profile) {
409
+ p.log.error(`Failed to read profile "${name}"`);
410
+ return;
411
+ }
412
+
413
+ let done = false;
414
+ while (!done) {
415
+ const action = await p.select({
416
+ message: `Editing profile: ${color.bold(name)}`,
417
+ options: [
418
+ {
419
+ value: "description",
420
+ label: "Description",
421
+ hint: profile.description || "(not set)",
422
+ },
423
+ {
424
+ value: "source",
425
+ label: "Source Directory",
426
+ hint: profile.defaultSource || "(inherit global)",
427
+ },
428
+ {
429
+ value: "target",
430
+ label: "Target Directory",
431
+ hint: profile.defaultTarget || "(inherit global)",
432
+ },
433
+ {
434
+ value: "model",
435
+ label: "AI Model",
436
+ hint: profile.organizer
437
+ ? `${profile.organizer.provider}/${profile.organizer.model}`
438
+ : "(inherit global)",
439
+ },
440
+ {
441
+ value: "ignore",
442
+ label: "Ignore Patterns",
443
+ hint: profile.ignore ? `${profile.ignore.length} patterns` : "(inherit global)",
444
+ },
445
+ {
446
+ value: "rules",
447
+ label: "Custom Rules",
448
+ hint: readProfileRules(name) ? "configured" : "(inherit global)",
449
+ },
450
+ {
451
+ value: "clear",
452
+ label: "Clear a setting",
453
+ hint: "Remove override, inherit from global",
454
+ },
455
+ {
456
+ value: "done",
457
+ label: "Done",
458
+ },
459
+ ],
460
+ });
461
+
462
+ if (p.isCancel(action) || action === "done") {
463
+ done = true;
464
+ break;
465
+ }
466
+
467
+ switch (action) {
468
+ case "description": {
469
+ const description = await p.text({
470
+ message: "Description:",
471
+ initialValue: profile.description || "",
472
+ });
473
+ if (!p.isCancel(description)) {
474
+ profile.description = description || undefined;
475
+ writeProfile(name, profile);
476
+ p.log.success("Description updated");
477
+ }
478
+ break;
479
+ }
480
+
481
+ case "source": {
482
+ const source = await p.text({
483
+ message: "Source directory:",
484
+ initialValue: profile.defaultSource || "",
485
+ placeholder: "~/Downloads",
486
+ });
487
+ if (!p.isCancel(source)) {
488
+ profile.defaultSource = source || undefined;
489
+ writeProfile(name, profile);
490
+ p.log.success("Source directory updated");
491
+ }
492
+ break;
493
+ }
494
+
495
+ case "target": {
496
+ const target = await p.text({
497
+ message: "Target directory:",
498
+ initialValue: profile.defaultTarget || "",
499
+ placeholder: "~/Documents/Organized",
500
+ });
501
+ if (!p.isCancel(target)) {
502
+ profile.defaultTarget = target || undefined;
503
+ writeProfile(name, profile);
504
+ p.log.success("Target directory updated");
505
+ }
506
+ break;
507
+ }
508
+
509
+ case "model":
510
+ await configureProfileModel(profile);
511
+ writeProfile(name, profile);
512
+ break;
513
+
514
+ case "ignore": {
515
+ await configureProfileIgnore(profile);
516
+ writeProfile(name, profile);
517
+ break;
518
+ }
519
+
520
+ case "rules": {
521
+ const hasRules = readProfileRules(name) !== null;
522
+ if (hasRules) {
523
+ p.log.info(`Rules file: ${getProfileRulesPath(name)}`);
524
+ p.log.message(color.dim("Edit this file to customize categorization."));
525
+
526
+ const deleteRules = await p.confirm({
527
+ message: "Delete custom rules? (will inherit from global)",
528
+ initialValue: false,
529
+ });
530
+ if (!p.isCancel(deleteRules) && deleteRules) {
531
+ rmSync(getProfileRulesPath(name));
532
+ p.log.success("Custom rules removed");
533
+ }
534
+ } else {
535
+ const createRules = await p.confirm({
536
+ message: "Create custom rules for this profile?",
537
+ initialValue: true,
538
+ });
539
+ if (!p.isCancel(createRules) && createRules) {
540
+ writeProfileRules(name, getDefaultRules());
541
+ p.log.success(`Created rules at ${getProfileRulesPath(name)}`);
542
+ }
543
+ }
544
+ break;
545
+ }
546
+
547
+ case "clear": {
548
+ const toClear = await p.select({
549
+ message: "Which setting to clear?",
550
+ options: [
551
+ { value: "source", label: "Source Directory" },
552
+ { value: "target", label: "Target Directory" },
553
+ { value: "model", label: "AI Model" },
554
+ { value: "ignore", label: "Ignore Patterns" },
555
+ { value: "cancel", label: "Cancel" },
556
+ ],
557
+ });
558
+
559
+ if (!p.isCancel(toClear) && toClear !== "cancel") {
560
+ switch (toClear) {
561
+ case "source":
562
+ delete profile.defaultSource;
563
+ break;
564
+ case "target":
565
+ delete profile.defaultTarget;
566
+ break;
567
+ case "model":
568
+ delete profile.organizer;
569
+ break;
570
+ case "ignore":
571
+ delete profile.ignore;
572
+ break;
573
+ }
574
+ writeProfile(name, profile);
575
+ p.log.success(`Cleared ${toClear}, will inherit from global`);
576
+ }
577
+ break;
578
+ }
579
+ }
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Configure AI model for a profile
585
+ */
586
+ async function configureProfileModel(profile: Profile): Promise<void> {
587
+ const s = p.spinner();
588
+ s.start("Fetching available models...");
589
+
590
+ let providers: any[] = [];
591
+ try {
592
+ const response = await getAvailableModels();
593
+ if (!response.error) {
594
+ providers = response.data?.providers || [];
595
+ }
596
+ s.stop(`Found ${providers.length} providers`);
597
+ } catch {
598
+ s.stop("Using manual entry");
599
+ }
600
+
601
+ if (providers.length > 0) {
602
+ const providerOptions = providers.map((prov) => ({
603
+ value: prov.id,
604
+ label: prov.name || prov.id,
605
+ }));
606
+ providerOptions.push({ value: "custom", label: "Enter custom..." });
607
+
608
+ const selectedProvider = await p.select({
609
+ message: "Select AI provider:",
610
+ options: providerOptions,
611
+ });
612
+
613
+ if (p.isCancel(selectedProvider)) return;
614
+
615
+ let providerId: string;
616
+ let modelName: string;
617
+
618
+ if (selectedProvider === "custom") {
619
+ const custom = await p.text({
620
+ message: "Enter model (provider/model):",
621
+ placeholder: "opencode/claude-sonnet-4-5",
622
+ });
623
+ if (p.isCancel(custom)) return;
624
+ const parts = custom.split("/");
625
+ providerId = parts[0];
626
+ modelName = parts.slice(1).join("/");
627
+ } else {
628
+ providerId = selectedProvider as string;
629
+ const providerData = providers.find((prov) => prov.id === providerId);
630
+ let modelIds: string[] = [];
631
+
632
+ if (providerData?.models) {
633
+ if (Array.isArray(providerData.models)) {
634
+ modelIds = providerData.models;
635
+ } else if (typeof providerData.models === "object") {
636
+ modelIds = Object.keys(providerData.models);
637
+ }
638
+ }
639
+
640
+ if (modelIds.length > 0) {
641
+ const modelOptions = modelIds.map((m: string) => ({ value: m, label: m }));
642
+ const selectedModel = await p.select({
643
+ message: "Select model:",
644
+ options: modelOptions,
645
+ });
646
+ if (p.isCancel(selectedModel)) return;
647
+ modelName = selectedModel as string;
648
+ } else {
649
+ const customModel = await p.text({ message: "Enter model ID:" });
650
+ if (p.isCancel(customModel)) return;
651
+ modelName = customModel;
652
+ }
653
+ }
654
+
655
+ profile.organizer = { provider: providerId, model: modelName };
656
+ p.log.success(`Model set to ${providerId}/${modelName}`);
657
+ } else {
658
+ const manual = await p.text({
659
+ message: "Enter model (provider/model):",
660
+ placeholder: "opencode/claude-sonnet-4-5",
661
+ });
662
+ if (p.isCancel(manual)) return;
663
+ const parts = manual.split("/");
664
+ profile.organizer = {
665
+ provider: parts[0],
666
+ model: parts.slice(1).join("/"),
667
+ };
668
+ p.log.success(`Model set to ${manual}`);
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Configure ignore patterns for a profile
674
+ */
675
+ async function configureProfileIgnore(profile: Profile): Promise<void> {
676
+ const currentPatterns = profile.ignore || [];
677
+
678
+ p.log.info("Current ignore patterns:");
679
+ if (currentPatterns.length === 0) {
680
+ p.log.message(color.dim(" (inheriting from global)"));
681
+ } else {
682
+ for (const pattern of currentPatterns) {
683
+ p.log.message(` ${pattern}`);
684
+ }
685
+ }
686
+
687
+ const action = await p.select({
688
+ message: "What would you like to do?",
689
+ options: [
690
+ { value: "add", label: "Add pattern" },
691
+ { value: "remove", label: "Remove pattern" },
692
+ { value: "clear", label: "Clear all (inherit from global)" },
693
+ { value: "back", label: "Back" },
694
+ ],
695
+ });
696
+
697
+ if (p.isCancel(action) || action === "back") return;
698
+
699
+ switch (action) {
700
+ case "add": {
701
+ const pattern = await p.text({
702
+ message: "Pattern to ignore:",
703
+ placeholder: "*.tmp",
704
+ });
705
+ if (!p.isCancel(pattern) && pattern) {
706
+ profile.ignore = [...currentPatterns, pattern];
707
+ p.log.success(`Added pattern: ${pattern}`);
708
+ }
709
+ break;
710
+ }
711
+
712
+ case "remove": {
713
+ if (currentPatterns.length === 0) {
714
+ p.log.warn("No patterns to remove");
715
+ break;
716
+ }
717
+ const toRemove = await p.select({
718
+ message: "Select pattern to remove:",
719
+ options: currentPatterns.map((pat) => ({ value: pat, label: pat })),
720
+ });
721
+ if (!p.isCancel(toRemove)) {
722
+ profile.ignore = currentPatterns.filter((pat) => pat !== toRemove);
723
+ p.log.success(`Removed: ${toRemove}`);
724
+ }
725
+ break;
726
+ }
727
+
728
+ case "clear":
729
+ delete profile.ignore;
730
+ p.log.success("Cleared ignore patterns, will inherit from global");
731
+ break;
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Delete a profile
737
+ */
738
+ async function deleteProfileInteractive(name: string, force?: boolean): Promise<void> {
739
+ if (!profileExists(name)) {
740
+ p.log.error(`Profile "${name}" not found`);
741
+ return;
742
+ }
743
+
744
+ if (!force) {
745
+ const confirm = await p.confirm({
746
+ message: `Delete profile "${name}"? This cannot be undone.`,
747
+ initialValue: false,
748
+ });
749
+ if (p.isCancel(confirm) || !confirm) return;
750
+ }
751
+
752
+ deleteProfile(name);
753
+ p.log.success(`Deleted profile "${name}"`);
754
+ }
755
+
756
+ /**
757
+ * Show profile details
758
+ */
759
+ function showProfile(name: string): void {
760
+ const profile = readProfile(name);
761
+ if (!profile) {
762
+ p.log.error(`Profile "${name}" not found`);
763
+ return;
764
+ }
765
+
766
+ console.log();
767
+ p.log.info(color.bold(`Profile: ${name}`));
768
+ console.log();
769
+
770
+ if (profile.description) {
771
+ p.log.message(`${color.bold("Description:")} ${profile.description}`);
772
+ }
773
+
774
+ p.log.message(
775
+ `${color.bold("Source:")} ${profile.defaultSource || color.dim("(inherit global)")}`,
776
+ );
777
+ p.log.message(
778
+ `${color.bold("Target:")} ${profile.defaultTarget || color.dim("(inherit global)")}`,
779
+ );
780
+
781
+ if (profile.organizer) {
782
+ p.log.message(
783
+ `${color.bold("AI Model:")} ${profile.organizer.provider}/${profile.organizer.model}`,
784
+ );
785
+ } else {
786
+ p.log.message(`${color.bold("AI Model:")} ${color.dim("(inherit global)")}`);
787
+ }
788
+
789
+ if (profile.ignore && profile.ignore.length > 0) {
790
+ p.log.message(`${color.bold("Ignore Patterns:")} ${profile.ignore.join(", ")}`);
791
+ } else {
792
+ p.log.message(`${color.bold("Ignore Patterns:")} ${color.dim("(inherit global)")}`);
793
+ }
794
+
795
+ const hasRules = readProfileRules(name) !== null;
796
+ p.log.message(
797
+ `${color.bold("Custom Rules:")} ${hasRules ? color.green("Yes") : color.dim("(inherit global)")}`,
798
+ );
799
+
800
+ if (profile.createdAt) {
801
+ p.log.message(`${color.bold("Created:")} ${new Date(profile.createdAt).toLocaleString()}`);
802
+ }
803
+ if (profile.modifiedAt) {
804
+ p.log.message(`${color.bold("Modified:")} ${new Date(profile.modifiedAt).toLocaleString()}`);
805
+ }
806
+
807
+ console.log();
808
+ }
809
+
810
+ /**
811
+ * Copy a profile
812
+ */
813
+ async function copyProfileInteractive(source: string, destination: string): Promise<void> {
814
+ const validation = validateProfileName(destination);
815
+ if (!validation.valid) {
816
+ p.log.error(validation.error!);
817
+ return;
818
+ }
819
+
820
+ try {
821
+ copyProfile(source, destination);
822
+ p.log.success(`Copied "${source}" to "${destination}"`);
823
+ } catch (error: any) {
824
+ p.log.error(error.message);
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Export profile to stdout
830
+ */
831
+ function exportProfileToStdout(name: string): void {
832
+ try {
833
+ const exported = exportProfile(name);
834
+ console.log(JSON.stringify(exported, null, 2));
835
+ } catch (error: any) {
836
+ p.log.error(error.message);
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Import profile from file
842
+ */
843
+ async function importProfileFromFile(filePath: string): Promise<void> {
844
+ try {
845
+ const content = readFileSync(filePath, "utf-8");
846
+ const data: ProfileExport = JSON.parse(content);
847
+
848
+ // Ask for name if profile with that name exists
849
+ let name = data.profile.name;
850
+ if (profileExists(name)) {
851
+ const newName = await p.text({
852
+ message: `Profile "${name}" already exists. Enter a new name:`,
853
+ validate: (value) => {
854
+ const validation = validateProfileName(value);
855
+ if (!validation.valid) return validation.error;
856
+ if (profileExists(value)) return `Profile "${value}" already exists`;
857
+ },
858
+ });
859
+ if (p.isCancel(newName)) return;
860
+ name = newName;
861
+ }
862
+
863
+ const importedName = importProfile(data, name);
864
+ p.log.success(`Imported profile "${importedName}"`);
865
+ } catch (error: any) {
866
+ p.log.error(`Failed to import: ${error.message}`);
867
+ }
868
+ }
869
+
870
+ /**
871
+ * List available presets
872
+ */
873
+ function listPresetsInteractive(): void {
874
+ const presets = listPresets();
875
+
876
+ console.log();
877
+ p.log.info(color.bold(`${presets.length} available preset(s):`));
878
+ console.log();
879
+
880
+ for (const preset of presets) {
881
+ p.log.message(` ${color.cyan("●")} ${color.bold(preset.name)} - ${preset.description}`);
882
+ }
883
+ console.log();
884
+ p.log.message(color.dim("Install with: tidyf profile install <preset> [custom-name]"));
885
+ console.log();
886
+ }
887
+
888
+ /**
889
+ * Install a preset as a profile (interactive menu version)
890
+ */
891
+ async function installPresetMenuInteractive(): Promise<void> {
892
+ const presets = listPresets();
893
+
894
+ const selected = await p.select({
895
+ message: "Select preset to install:",
896
+ options: presets.map((preset) => ({
897
+ value: preset.name,
898
+ label: preset.name,
899
+ hint: preset.description,
900
+ })),
901
+ });
902
+
903
+ if (p.isCancel(selected)) return;
904
+
905
+ const presetName = selected as string;
906
+
907
+ // Ask for custom name
908
+ const customName = await p.text({
909
+ message: "Profile name (or leave empty to use preset name):",
910
+ placeholder: presetName,
911
+ validate: (value) => {
912
+ if (!value) return; // Empty is OK, will use preset name
913
+ const validation = validateProfileName(value);
914
+ if (!validation.valid) return validation.error;
915
+ if (profileExists(value)) return `Profile "${value}" already exists`;
916
+ },
917
+ });
918
+
919
+ if (p.isCancel(customName)) return;
920
+
921
+ const profileName = customName || undefined;
922
+
923
+ // Check if using preset name and it exists
924
+ if (!profileName && profileExists(presetName)) {
925
+ p.log.error(`Profile "${presetName}" already exists. Please choose a different name.`);
926
+ return;
927
+ }
928
+
929
+ await installPresetInteractive(presetName, profileName);
930
+ }
931
+
932
+ /**
933
+ * Install a preset as a profile (CLI version)
934
+ */
935
+ async function installPresetInteractive(presetName: string, profileName?: string): Promise<void> {
936
+ try {
937
+ const installedName = installPreset(presetName, profileName);
938
+ p.log.success(`Installed preset "${presetName}" as profile "${installedName}"`);
939
+ p.log.message(color.dim(`Use with: tidyf -p ${installedName} <path>`));
940
+ } catch (error: any) {
941
+ p.log.error(error.message);
942
+ }
943
+ }