@canonical/summon 0.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.
Files changed (45) hide show
  1. package/README.md +439 -0
  2. package/generators/example/hello/index.ts +132 -0
  3. package/generators/example/hello/templates/README.md.ejs +20 -0
  4. package/generators/example/hello/templates/index.ts.ejs +9 -0
  5. package/generators/example/webapp/index.ts +509 -0
  6. package/generators/example/webapp/templates/ARCHITECTURE.md.ejs +180 -0
  7. package/generators/example/webapp/templates/App.tsx.ejs +86 -0
  8. package/generators/example/webapp/templates/README.md.ejs +154 -0
  9. package/generators/example/webapp/templates/app.test.ts.ejs +63 -0
  10. package/generators/example/webapp/templates/app.ts.ejs +132 -0
  11. package/generators/example/webapp/templates/feature.ts.ejs +264 -0
  12. package/generators/example/webapp/templates/index.html.ejs +20 -0
  13. package/generators/example/webapp/templates/main.tsx.ejs +43 -0
  14. package/generators/example/webapp/templates/styles.css.ejs +135 -0
  15. package/generators/init/index.ts +124 -0
  16. package/generators/init/templates/generator.ts.ejs +85 -0
  17. package/generators/init/templates/template-index.ts.ejs +9 -0
  18. package/generators/init/templates/template-test.ts.ejs +8 -0
  19. package/package.json +64 -0
  20. package/src/__tests__/combinators.test.ts +895 -0
  21. package/src/__tests__/dry-run.test.ts +927 -0
  22. package/src/__tests__/effect.test.ts +816 -0
  23. package/src/__tests__/interpreter.test.ts +673 -0
  24. package/src/__tests__/primitives.test.ts +970 -0
  25. package/src/__tests__/task.test.ts +929 -0
  26. package/src/__tests__/template.test.ts +666 -0
  27. package/src/cli-format.ts +165 -0
  28. package/src/cli-types.ts +53 -0
  29. package/src/cli.tsx +1322 -0
  30. package/src/combinators.ts +294 -0
  31. package/src/completion.ts +488 -0
  32. package/src/components/App.tsx +960 -0
  33. package/src/components/ExecutionProgress.tsx +205 -0
  34. package/src/components/FileTreePreview.tsx +97 -0
  35. package/src/components/PromptSequence.tsx +483 -0
  36. package/src/components/Spinner.tsx +36 -0
  37. package/src/components/index.ts +16 -0
  38. package/src/dry-run.ts +434 -0
  39. package/src/effect.ts +224 -0
  40. package/src/index.ts +266 -0
  41. package/src/interpreter.ts +463 -0
  42. package/src/primitives.ts +442 -0
  43. package/src/task.ts +245 -0
  44. package/src/template.ts +537 -0
  45. package/src/types.ts +453 -0
@@ -0,0 +1,960 @@
1
+ /**
2
+ * App Component
3
+ *
4
+ * Main CLI application component using React Ink.
5
+ */
6
+
7
+ import { Box, Text, useApp, useInput } from "ink";
8
+ import { useCallback, useEffect, useState } from "react";
9
+ import { dryRun } from "../dry-run.js";
10
+ import type { StampConfig } from "../interpreter.js";
11
+ import type {
12
+ Effect,
13
+ GeneratorDefinition,
14
+ PromptDefinition,
15
+ Task,
16
+ TaskError,
17
+ } from "../types.js";
18
+ import { ExecutionProgress, type TimedEffect } from "./ExecutionProgress.js";
19
+ import { PromptSequence } from "./PromptSequence.js";
20
+ import { Spinner } from "./Spinner.js";
21
+
22
+ // =============================================================================
23
+ // Effect Tree - Hierarchical display with action labels and tree connectors
24
+ // =============================================================================
25
+
26
+ interface GroupedEffects {
27
+ files: Effect[];
28
+ directories: Effect[];
29
+ commands: Effect[];
30
+ logs: Effect[];
31
+ }
32
+
33
+ /**
34
+ * Group effects by category for display.
35
+ * @param effects - The effects to group
36
+ * @param verbose - If true, include debug logs
37
+ */
38
+ const groupEffects = (effects: Effect[], verbose = false): GroupedEffects => {
39
+ const groups: GroupedEffects = {
40
+ files: [],
41
+ directories: [],
42
+ commands: [],
43
+ logs: [],
44
+ };
45
+
46
+ for (const effect of effects) {
47
+ switch (effect._tag) {
48
+ case "WriteFile":
49
+ case "AppendFile":
50
+ case "CopyFile":
51
+ case "CopyDirectory":
52
+ case "DeleteFile":
53
+ groups.files.push(effect);
54
+ break;
55
+ case "MakeDir":
56
+ case "DeleteDirectory":
57
+ groups.directories.push(effect);
58
+ break;
59
+ case "Exec":
60
+ groups.commands.push(effect);
61
+ break;
62
+ case "Log":
63
+ // Filter out debug logs unless verbose is enabled
64
+ if (effect.level !== "debug" || verbose) {
65
+ groups.logs.push(effect);
66
+ }
67
+ break;
68
+ // Ignore internal effects: ReadFile, Exists, Glob, ReadContext, WriteContext, Parallel, Race
69
+ }
70
+ }
71
+
72
+ return groups;
73
+ };
74
+
75
+ /**
76
+ * Get human-readable action label for an effect.
77
+ */
78
+ const getActionLabel = (effect: Effect): string => {
79
+ switch (effect._tag) {
80
+ case "WriteFile":
81
+ return "Created file";
82
+ case "AppendFile":
83
+ return "Appended to";
84
+ case "MakeDir":
85
+ return "Created dir";
86
+ case "CopyFile":
87
+ return "Copied file";
88
+ case "CopyDirectory":
89
+ return "Copied dir";
90
+ case "DeleteFile":
91
+ return "Deleted file";
92
+ case "DeleteDirectory":
93
+ return "Deleted dir";
94
+ case "Exec":
95
+ return "Executed";
96
+ case "Log":
97
+ switch (effect.level) {
98
+ case "debug":
99
+ return "Debug";
100
+ case "info":
101
+ return "Info";
102
+ case "warn":
103
+ return "Warning";
104
+ case "error":
105
+ return "Error";
106
+ default:
107
+ return "Log";
108
+ }
109
+ default:
110
+ return effect._tag;
111
+ }
112
+ };
113
+
114
+ /**
115
+ * Get color for action label based on effect type.
116
+ */
117
+ const getActionColor = (
118
+ effect: Effect,
119
+ ): "green" | "red" | "yellow" | "cyan" | "blue" | "magenta" | undefined => {
120
+ switch (effect._tag) {
121
+ case "WriteFile":
122
+ case "MakeDir":
123
+ return "green";
124
+ case "AppendFile":
125
+ return "magenta";
126
+ case "DeleteFile":
127
+ case "DeleteDirectory":
128
+ return "red";
129
+ case "CopyFile":
130
+ case "CopyDirectory":
131
+ return "cyan";
132
+ case "Exec":
133
+ return "yellow";
134
+ case "Log":
135
+ switch (effect.level) {
136
+ case "error":
137
+ return "red";
138
+ case "warn":
139
+ return "yellow";
140
+ case "debug":
141
+ return undefined; // dim by default
142
+ default:
143
+ return "blue";
144
+ }
145
+ default:
146
+ return undefined;
147
+ }
148
+ };
149
+
150
+ /**
151
+ * Get the payload (description) for an effect.
152
+ */
153
+ const getEffectPayload = (effect: Effect): string => {
154
+ switch (effect._tag) {
155
+ case "WriteFile":
156
+ return effect.path;
157
+ case "AppendFile":
158
+ return effect.path;
159
+ case "MakeDir":
160
+ return effect.path;
161
+ case "CopyFile":
162
+ return `${effect.source} → ${effect.dest}`;
163
+ case "CopyDirectory":
164
+ return `${effect.source}/ → ${effect.dest}/`;
165
+ case "DeleteFile":
166
+ case "DeleteDirectory":
167
+ return effect.path;
168
+ case "Exec":
169
+ return `${effect.command} ${effect.args.join(" ")}`;
170
+ case "Log":
171
+ return effect.message;
172
+ default:
173
+ return effect._tag;
174
+ }
175
+ };
176
+
177
+ // Fixed width for action label column (padded to align payloads)
178
+ const ACTION_LABEL_WIDTH = 14;
179
+
180
+ /**
181
+ * Render a single effect as a tree row with action label and payload.
182
+ */
183
+ const EffectTreeRow = ({
184
+ effect,
185
+ isLast,
186
+ }: {
187
+ effect: Effect;
188
+ isLast: boolean;
189
+ }) => {
190
+ const connector = isLast ? "└─" : "├─";
191
+ const actionLabel = getActionLabel(effect);
192
+ const color = getActionColor(effect);
193
+ const payload = getEffectPayload(effect);
194
+
195
+ return (
196
+ <Box>
197
+ <Text dimColor>{connector} </Text>
198
+ <Text color={color}>{actionLabel.padEnd(ACTION_LABEL_WIDTH)}</Text>
199
+ <Text>{payload}</Text>
200
+ </Box>
201
+ );
202
+ };
203
+
204
+ /**
205
+ * Render a section of the effect tree (e.g., Files, Directories).
206
+ */
207
+ const EffectTreeSection = ({
208
+ title,
209
+ effects,
210
+ }: {
211
+ title: string;
212
+ effects: Effect[];
213
+ }) => {
214
+ if (effects.length === 0) {
215
+ return null;
216
+ }
217
+
218
+ return (
219
+ <Box flexDirection="column" marginTop={1}>
220
+ <Text bold dimColor>
221
+ {title}:
222
+ </Text>
223
+ <Box flexDirection="column" marginLeft={1}>
224
+ {effects.map((effect, index) => (
225
+ <EffectTreeRow
226
+ key={`${effect._tag}-${index}`}
227
+ effect={effect}
228
+ isLast={index === effects.length - 1}
229
+ />
230
+ ))}
231
+ </Box>
232
+ </Box>
233
+ );
234
+ };
235
+
236
+ /**
237
+ * Render a tree view of completed effects, grouped by category.
238
+ */
239
+ const _EffectTree = ({ effects }: { effects: Effect[] }) => {
240
+ const groups = groupEffects(effects);
241
+ const hasAnyEffects =
242
+ groups.files.length > 0 ||
243
+ groups.directories.length > 0 ||
244
+ groups.commands.length > 0 ||
245
+ groups.logs.length > 0;
246
+
247
+ if (!hasAnyEffects) {
248
+ return null;
249
+ }
250
+
251
+ return (
252
+ <Box flexDirection="column">
253
+ <EffectTreeSection title="Files" effects={groups.files} />
254
+ <EffectTreeSection title="Directories" effects={groups.directories} />
255
+ <EffectTreeSection title="Commands" effects={groups.commands} />
256
+ <EffectTreeSection title="Logs" effects={groups.logs} />
257
+ </Box>
258
+ );
259
+ };
260
+
261
+ // =============================================================================
262
+ // Effect Timeline - Chronological display with timestamps
263
+ // =============================================================================
264
+
265
+ // Width for timestamp column (e.g., "+123ms")
266
+ const TIMESTAMP_WIDTH = 8;
267
+
268
+ /**
269
+ * Filter effects to only show user-relevant ones (not internal effects).
270
+ * @param effect - The effect to check
271
+ * @param verbose - If true, include debug logs
272
+ */
273
+ const isVisibleEffect = (effect: Effect, verbose = false): boolean => {
274
+ switch (effect._tag) {
275
+ case "WriteFile":
276
+ case "AppendFile":
277
+ case "MakeDir":
278
+ case "CopyFile":
279
+ case "CopyDirectory":
280
+ case "DeleteFile":
281
+ case "DeleteDirectory":
282
+ case "Exec":
283
+ return true;
284
+ case "Log":
285
+ // Filter out debug logs unless verbose is enabled
286
+ if (effect.level === "debug") {
287
+ return verbose;
288
+ }
289
+ return true;
290
+ // Internal effects are not shown
291
+ case "ReadFile":
292
+ case "Exists":
293
+ case "Glob":
294
+ case "ReadContext":
295
+ case "WriteContext":
296
+ case "Prompt":
297
+ case "Parallel":
298
+ case "Race":
299
+ return false;
300
+ default:
301
+ return false;
302
+ }
303
+ };
304
+
305
+ /**
306
+ * Render a single timeline row with timestamp, action, and payload.
307
+ */
308
+ const TimelineRow = ({
309
+ effect,
310
+ timestamp,
311
+ showTimestamp,
312
+ isLast,
313
+ }: {
314
+ effect: Effect;
315
+ timestamp: number;
316
+ showTimestamp: boolean;
317
+ isLast: boolean;
318
+ }) => {
319
+ const connector = isLast ? "└─" : "├─";
320
+ const actionLabel = getActionLabel(effect);
321
+ const color = getActionColor(effect);
322
+ const payload = getEffectPayload(effect);
323
+ const timestampStr = showTimestamp
324
+ ? `+${Math.round(timestamp)}ms`.padEnd(TIMESTAMP_WIDTH)
325
+ : " ".repeat(TIMESTAMP_WIDTH);
326
+
327
+ return (
328
+ <Box>
329
+ <Text dimColor>{timestampStr}</Text>
330
+ <Text dimColor>{connector} </Text>
331
+ <Text color={color}>{actionLabel.padEnd(ACTION_LABEL_WIDTH)}</Text>
332
+ <Text>{payload}</Text>
333
+ </Box>
334
+ );
335
+ };
336
+
337
+ /**
338
+ * Render effects in chronological order with timestamps.
339
+ * Timestamps are only shown when they differ from the previous effect.
340
+ * Duplicate MakeDir effects (same path) are deduplicated.
341
+ */
342
+ const EffectTimeline = ({
343
+ effects,
344
+ verbose = false,
345
+ }: {
346
+ effects: TimedEffect[];
347
+ verbose?: boolean;
348
+ }) => {
349
+ // Filter to visible effects only and deduplicate MakeDir by path
350
+ const seenDirPaths = new Set<string>();
351
+ const visibleEffects = effects.filter((e) => {
352
+ if (!isVisibleEffect(e.effect, verbose)) return false;
353
+ // Deduplicate MakeDir by path (keep only first occurrence)
354
+ if (e.effect._tag === "MakeDir") {
355
+ if (seenDirPaths.has(e.effect.path)) return false;
356
+ seenDirPaths.add(e.effect.path);
357
+ }
358
+ return true;
359
+ });
360
+
361
+ if (visibleEffects.length === 0) {
362
+ return null;
363
+ }
364
+
365
+ // Track which timestamps to show (only when different from previous)
366
+ let lastShownTimestamp = -1;
367
+
368
+ return (
369
+ <Box flexDirection="column" marginTop={1}>
370
+ <Text bold dimColor>
371
+ Timeline:
372
+ </Text>
373
+ <Box flexDirection="column" marginLeft={1}>
374
+ {visibleEffects.map((item, index) => {
375
+ const roundedTimestamp = Math.round(item.timestamp);
376
+ const showTimestamp = roundedTimestamp !== lastShownTimestamp;
377
+ if (showTimestamp) {
378
+ lastShownTimestamp = roundedTimestamp;
379
+ }
380
+ // Effects are append-only, index is stable
381
+ const key = `${item.timestamp}-${item.effect._tag}-${index}`;
382
+ return (
383
+ <TimelineRow
384
+ key={key}
385
+ effect={item.effect}
386
+ timestamp={item.timestamp}
387
+ showTimestamp={showTimestamp}
388
+ isLast={index === visibleEffects.length - 1}
389
+ />
390
+ );
391
+ })}
392
+ </Box>
393
+ </Box>
394
+ );
395
+ };
396
+
397
+ // =============================================================================
398
+ // Dry-Run Timeline - Preview without timestamps
399
+ // =============================================================================
400
+
401
+ /**
402
+ * Render a single dry-run row (no timestamp column).
403
+ */
404
+ const DryRunRow = ({ effect, isLast }: { effect: Effect; isLast: boolean }) => {
405
+ const connector = isLast ? "└─" : "├─";
406
+ const actionLabel = getActionLabel(effect);
407
+ const color = getActionColor(effect);
408
+ const payload = getEffectPayload(effect);
409
+
410
+ return (
411
+ <Box>
412
+ <Text dimColor>{connector} </Text>
413
+ <Text color={color}>{actionLabel.padEnd(ACTION_LABEL_WIDTH)}</Text>
414
+ <Text>{payload}</Text>
415
+ </Box>
416
+ );
417
+ };
418
+
419
+ /**
420
+ * Render effects as a preview timeline (dry-run mode).
421
+ * Shows the same format as execution timeline but without timestamps.
422
+ */
423
+ const DryRunTimeline = ({
424
+ effects,
425
+ title = "Plan:",
426
+ verbose = false,
427
+ }: {
428
+ effects: Effect[];
429
+ title?: string;
430
+ verbose?: boolean;
431
+ }) => {
432
+ // Filter to visible effects only and deduplicate MakeDir by path
433
+ const seenDirPaths = new Set<string>();
434
+ const visibleEffects = effects.filter((e) => {
435
+ if (!isVisibleEffect(e, verbose)) return false;
436
+ // Deduplicate MakeDir by path (keep only first occurrence)
437
+ if (e._tag === "MakeDir") {
438
+ if (seenDirPaths.has(e.path)) return false;
439
+ seenDirPaths.add(e.path);
440
+ }
441
+ return true;
442
+ });
443
+
444
+ if (visibleEffects.length === 0) {
445
+ return (
446
+ <Box>
447
+ <Text dimColor>No operations planned.</Text>
448
+ </Box>
449
+ );
450
+ }
451
+
452
+ return (
453
+ <Box flexDirection="column">
454
+ <Text bold dimColor>
455
+ {title}
456
+ </Text>
457
+ <Box flexDirection="column" marginLeft={1}>
458
+ {visibleEffects.map((effect, index) => {
459
+ const key = `${effect._tag}-${index}`;
460
+ return (
461
+ <DryRunRow
462
+ key={key}
463
+ effect={effect}
464
+ isLast={index === visibleEffects.length - 1}
465
+ />
466
+ );
467
+ })}
468
+ </Box>
469
+ </Box>
470
+ );
471
+ };
472
+
473
+ // =============================================================================
474
+ // Completed Answers Display (for confirmation phase)
475
+ // =============================================================================
476
+
477
+ /**
478
+ * Format answer value for display based on prompt type.
479
+ */
480
+ const formatAnswerValue = (
481
+ value: unknown,
482
+ prompt: PromptDefinition,
483
+ ): string => {
484
+ if (value === undefined || value === null) return "";
485
+
486
+ if (prompt.type === "confirm") {
487
+ return value ? "Yes" : "No";
488
+ }
489
+
490
+ if (prompt.type === "select" && prompt.choices) {
491
+ const choice = prompt.choices.find((c) => c.value === value);
492
+ return choice?.label ?? String(value);
493
+ }
494
+
495
+ if (prompt.type === "multiselect" && Array.isArray(value)) {
496
+ if (value.length === 0) return "None";
497
+ if (prompt.choices) {
498
+ return value
499
+ .map((v) => prompt.choices?.find((c) => c.value === v)?.label ?? v)
500
+ .join(", ");
501
+ }
502
+ return value.join(", ");
503
+ }
504
+
505
+ return String(value);
506
+ };
507
+
508
+ /**
509
+ * Display all completed answers in a borderless table format for confirmation review.
510
+ */
511
+ const CompletedAnswersTable = ({
512
+ prompts,
513
+ answers,
514
+ }: {
515
+ prompts: PromptDefinition[];
516
+ answers: Record<string, unknown>;
517
+ }) => {
518
+ // Filter to only show prompts that have answers and pass their `when` condition
519
+ const activePrompts = prompts.filter((prompt) => {
520
+ if (prompt.when && !prompt.when(answers)) {
521
+ return false;
522
+ }
523
+ return prompt.name in answers;
524
+ });
525
+
526
+ if (activePrompts.length === 0) {
527
+ return null;
528
+ }
529
+
530
+ // Calculate max width for the question column (for alignment)
531
+ const maxQuestionWidth = Math.max(
532
+ ...activePrompts.map((p) => p.message.length),
533
+ );
534
+
535
+ return (
536
+ <Box flexDirection="column" marginBottom={1}>
537
+ {activePrompts.map((prompt) => {
538
+ const value = answers[prompt.name];
539
+ const displayValue = formatAnswerValue(value, prompt);
540
+
541
+ return (
542
+ <Box key={prompt.name}>
543
+ <Text color="green">✔ </Text>
544
+ <Text dimColor>{prompt.message.padEnd(maxQuestionWidth)}</Text>
545
+ <Text dimColor> </Text>
546
+ <Text color="cyan">{displayValue}</Text>
547
+ </Box>
548
+ );
549
+ })}
550
+ </Box>
551
+ );
552
+ };
553
+
554
+ /**
555
+ * Display a summary of planned effects in a borderless table format.
556
+ */
557
+ const EffectsSummaryTable = ({ effects }: { effects: Effect[] }) => {
558
+ const files = new Set<string>();
559
+ const directories = new Set<string>();
560
+ const copied = new Set<string>();
561
+ const deleted = new Set<string>();
562
+ let commands = 0;
563
+
564
+ for (const effect of effects) {
565
+ switch (effect._tag) {
566
+ case "WriteFile":
567
+ case "AppendFile":
568
+ files.add(effect.path);
569
+ break;
570
+ case "MakeDir":
571
+ directories.add(effect.path);
572
+ break;
573
+ case "CopyFile":
574
+ copied.add(effect.dest);
575
+ break;
576
+ case "CopyDirectory":
577
+ copied.add(effect.dest);
578
+ break;
579
+ case "DeleteFile":
580
+ case "DeleteDirectory":
581
+ deleted.add(effect.path);
582
+ break;
583
+ case "Exec":
584
+ commands++;
585
+ break;
586
+ }
587
+ }
588
+
589
+ // Build rows for non-zero counts
590
+ const rows: Array<{ label: string; count: number; color: string }> = [];
591
+
592
+ if (files.size > 0) {
593
+ rows.push({
594
+ label: `File${files.size > 1 ? "s" : ""} to create`,
595
+ count: files.size,
596
+ color: "green",
597
+ });
598
+ }
599
+ if (directories.size > 0) {
600
+ rows.push({
601
+ label: `Director${directories.size > 1 ? "ies" : "y"} to create`,
602
+ count: directories.size,
603
+ color: "green",
604
+ });
605
+ }
606
+ if (copied.size > 0) {
607
+ rows.push({
608
+ label: `Item${copied.size > 1 ? "s" : ""} to copy`,
609
+ count: copied.size,
610
+ color: "cyan",
611
+ });
612
+ }
613
+ if (deleted.size > 0) {
614
+ rows.push({
615
+ label: `Item${deleted.size > 1 ? "s" : ""} to delete`,
616
+ count: deleted.size,
617
+ color: "red",
618
+ });
619
+ }
620
+ if (commands > 0) {
621
+ rows.push({
622
+ label: `Command${commands > 1 ? "s" : ""} to run`,
623
+ count: commands,
624
+ color: "yellow",
625
+ });
626
+ }
627
+
628
+ if (rows.length === 0) {
629
+ return (
630
+ <Box marginBottom={1}>
631
+ <Text dimColor>No operations planned.</Text>
632
+ </Box>
633
+ );
634
+ }
635
+
636
+ // Calculate max label width for alignment
637
+ const maxLabelWidth = Math.max(...rows.map((r) => r.label.length));
638
+
639
+ return (
640
+ <Box flexDirection="column" marginBottom={1}>
641
+ <Text bold dimColor>
642
+ Operations:
643
+ </Text>
644
+ {rows.map((row) => (
645
+ <Box key={row.label}>
646
+ <Text dimColor> {row.label.padEnd(maxLabelWidth)} </Text>
647
+ <Text color={row.color as "green" | "cyan" | "red" | "yellow"}>
648
+ {row.count}
649
+ </Text>
650
+ </Box>
651
+ ))}
652
+ </Box>
653
+ );
654
+ };
655
+
656
+ /**
657
+ * Summarize effects into a human-readable string.
658
+ * Deduplicates paths to avoid counting the same directory multiple times.
659
+ */
660
+ const summarizeEffects = (effects: TimedEffect[]): string => {
661
+ const files = new Set<string>();
662
+ const directories = new Set<string>();
663
+ const copied = new Set<string>();
664
+ const deleted = new Set<string>();
665
+ let commands = 0;
666
+
667
+ for (const { effect } of effects) {
668
+ switch (effect._tag) {
669
+ case "WriteFile":
670
+ files.add(effect.path);
671
+ break;
672
+ case "MakeDir":
673
+ directories.add(effect.path);
674
+ break;
675
+ case "CopyFile":
676
+ copied.add(effect.dest);
677
+ break;
678
+ case "DeleteFile":
679
+ case "DeleteDirectory":
680
+ deleted.add(effect.path);
681
+ break;
682
+ case "Exec":
683
+ commands++;
684
+ break;
685
+ // Log effects are not counted in summary
686
+ }
687
+ }
688
+
689
+ const parts: string[] = [];
690
+
691
+ if (files.size > 0) {
692
+ parts.push(`${files.size} file${files.size > 1 ? "s" : ""}`);
693
+ }
694
+ if (directories.size > 0) {
695
+ parts.push(
696
+ `${directories.size} director${directories.size > 1 ? "ies" : "y"}`,
697
+ );
698
+ }
699
+ if (copied.size > 0) {
700
+ parts.push(`${copied.size} copied`);
701
+ }
702
+ if (deleted.size > 0) {
703
+ parts.push(`${deleted.size} deleted`);
704
+ }
705
+ if (commands > 0) {
706
+ parts.push(`${commands} command${commands > 1 ? "s" : ""}`);
707
+ }
708
+
709
+ if (parts.length === 0) {
710
+ return "No changes made";
711
+ }
712
+
713
+ return `Created ${parts.join(", ")}`;
714
+ };
715
+
716
+ export type AppState =
717
+ | { phase: "loading" }
718
+ | { phase: "prompting" }
719
+ | { phase: "preview"; effects: Effect[] }
720
+ | {
721
+ phase: "confirming";
722
+ effects: Effect[];
723
+ promptAnswers: Record<string, unknown>;
724
+ }
725
+ | { phase: "executing"; task: Task<void> }
726
+ | { phase: "complete"; effects: TimedEffect[]; duration: number }
727
+ | { phase: "error"; error: TaskError; answers?: Record<string, unknown> };
728
+
729
+ export interface AppProps {
730
+ /** The generator to run */
731
+ generator: GeneratorDefinition;
732
+ /** Whether to show a preview before executing */
733
+ preview?: boolean;
734
+ /** Whether to run in dry-run mode only */
735
+ dryRunOnly?: boolean;
736
+ /** Whether to show debug output */
737
+ verbose?: boolean;
738
+ /** Pre-filled answers (for non-interactive mode) */
739
+ answers?: Record<string, unknown>;
740
+ /** Stamp configuration for generated files (undefined = no stamps) */
741
+ stamp?: StampConfig;
742
+ }
743
+
744
+ export const App = ({
745
+ generator,
746
+ preview = true,
747
+ dryRunOnly = false,
748
+ verbose = false,
749
+ answers: prefilledAnswers,
750
+ stamp,
751
+ }: AppProps) => {
752
+ const { exit } = useApp();
753
+ const [state, setState] = useState<AppState>(
754
+ prefilledAnswers ? { phase: "loading" } : { phase: "prompting" },
755
+ );
756
+ const [answers, setAnswers] = useState<Record<string, unknown>>(
757
+ prefilledAnswers ?? {},
758
+ );
759
+
760
+ const handlePromptsComplete = useCallback(
761
+ (promptAnswers: Record<string, unknown>) => {
762
+ setAnswers(promptAnswers);
763
+
764
+ // Generate the task
765
+ const task = generator.generate(promptAnswers);
766
+
767
+ if (dryRunOnly || preview) {
768
+ // Run dry-run to collect effects
769
+ try {
770
+ const result = dryRun(task);
771
+ if (dryRunOnly) {
772
+ setState({ phase: "preview", effects: result.effects });
773
+ } else {
774
+ setState({
775
+ phase: "confirming",
776
+ effects: result.effects,
777
+ promptAnswers,
778
+ });
779
+ }
780
+ } catch (err) {
781
+ setState({
782
+ phase: "error",
783
+ error:
784
+ err instanceof Error
785
+ ? { code: "DRY_RUN_ERROR", message: err.message }
786
+ : { code: "UNKNOWN_ERROR", message: String(err) },
787
+ answers: promptAnswers,
788
+ });
789
+ }
790
+ } else {
791
+ setState({ phase: "executing", task });
792
+ }
793
+ },
794
+ [generator, preview, dryRunOnly],
795
+ );
796
+
797
+ const handleConfirm = useCallback(() => {
798
+ const task = generator.generate(answers);
799
+ setState({ phase: "executing", task });
800
+ }, [generator, answers]);
801
+
802
+ const handleCancel = useCallback(() => {
803
+ exit();
804
+ }, [exit]);
805
+
806
+ const handleExecutionComplete = useCallback(
807
+ (effects: TimedEffect[], duration: number) => {
808
+ setState({ phase: "complete", effects, duration });
809
+ },
810
+ [],
811
+ );
812
+
813
+ const handleExecutionError = useCallback(
814
+ (error: TaskError) => {
815
+ setState({ phase: "error", error, answers });
816
+ },
817
+ [answers],
818
+ );
819
+
820
+ // Handle pre-filled answers
821
+ useEffect(() => {
822
+ if (prefilledAnswers && state.phase === "loading") {
823
+ handlePromptsComplete(prefilledAnswers);
824
+ }
825
+ }, [prefilledAnswers, state.phase, handlePromptsComplete]);
826
+
827
+ // Handle going back from confirmation to prompting
828
+ const handleGoBack = useCallback(() => {
829
+ setState({ phase: "prompting" });
830
+ }, []);
831
+
832
+ // Handle confirm/cancel/back input when in confirming state
833
+ useInput(
834
+ (input, key) => {
835
+ if (state.phase === "confirming") {
836
+ if (key.escape) {
837
+ handleGoBack();
838
+ } else if (key.return || input.toLowerCase() === "y") {
839
+ // Enter or Y confirms
840
+ handleConfirm();
841
+ } else if (input.toLowerCase() === "n") {
842
+ handleCancel();
843
+ }
844
+ }
845
+ },
846
+ { isActive: state.phase === "confirming" },
847
+ );
848
+
849
+ return (
850
+ <Box flexDirection="column" padding={1}>
851
+ {/* Header */}
852
+ <Box marginBottom={1}>
853
+ <Text bold color="magenta">
854
+ {generator.meta.name}
855
+ </Text>
856
+ <Text dimColor> v{generator.meta.version}</Text>
857
+ </Box>
858
+ <Text dimColor>{generator.meta.description}</Text>
859
+ <Box marginBottom={1} />
860
+
861
+ {/* Content based on state */}
862
+ {state.phase === "loading" && <Spinner label="Loading..." />}
863
+
864
+ {state.phase === "prompting" && (
865
+ <PromptSequence
866
+ prompts={generator.prompts}
867
+ onComplete={handlePromptsComplete}
868
+ onCancel={handleCancel}
869
+ initialAnswers={answers}
870
+ />
871
+ )}
872
+
873
+ {state.phase === "preview" && (
874
+ <Box flexDirection="column">
875
+ <DryRunTimeline
876
+ effects={state.effects}
877
+ title="Plan (dry-run):"
878
+ verbose={verbose}
879
+ />
880
+ <Box marginTop={1}>
881
+ <Text dimColor>Dry-run complete. No files were modified.</Text>
882
+ </Box>
883
+ </Box>
884
+ )}
885
+
886
+ {state.phase === "confirming" && (
887
+ <Box flexDirection="column">
888
+ {/* Show completed answers in table format */}
889
+ <CompletedAnswersTable
890
+ prompts={generator.prompts}
891
+ answers={state.promptAnswers}
892
+ />
893
+ {/* Show effects summary table */}
894
+ <EffectsSummaryTable effects={state.effects} />
895
+ {/* Confirmation prompt with escape hint */}
896
+ <Box>
897
+ <Text color="magenta">› </Text>
898
+ <Text bold>Proceed? </Text>
899
+ <Text dimColor>(Y/n) </Text>
900
+ <Text dimColor italic>
901
+ esc to go back
902
+ </Text>
903
+ </Box>
904
+ </Box>
905
+ )}
906
+
907
+ {state.phase === "executing" && (
908
+ <ExecutionProgress
909
+ task={state.task}
910
+ onComplete={handleExecutionComplete}
911
+ onError={handleExecutionError}
912
+ stamp={stamp}
913
+ />
914
+ )}
915
+
916
+ {state.phase === "complete" && (
917
+ <Box flexDirection="column">
918
+ <Box>
919
+ <Text color="green">✓ Generation complete!</Text>
920
+ </Box>
921
+ <EffectTimeline effects={state.effects} verbose={verbose} />
922
+ <Box marginTop={1}>
923
+ <Text dimColor>
924
+ {summarizeEffects(state.effects)} in {state.duration.toFixed(0)}ms
925
+ </Text>
926
+ </Box>
927
+ </Box>
928
+ )}
929
+
930
+ {state.phase === "error" && (
931
+ <Box flexDirection="column">
932
+ <Box>
933
+ <Text color="red">✗ Error: {state.error.message}</Text>
934
+ </Box>
935
+ {state.error.code && (
936
+ <Box marginTop={1}>
937
+ <Text dimColor>Code: {state.error.code}</Text>
938
+ </Box>
939
+ )}
940
+ {state.answers && Object.keys(state.answers).length > 0 && (
941
+ <Box flexDirection="column" marginTop={1}>
942
+ <Text dimColor bold>
943
+ Arguments:
944
+ </Text>
945
+ <Box flexDirection="column" marginLeft={1}>
946
+ {Object.entries(state.answers).map(([key, value]) => (
947
+ <Box key={key}>
948
+ <Text dimColor>
949
+ {key}: {JSON.stringify(value)}
950
+ </Text>
951
+ </Box>
952
+ ))}
953
+ </Box>
954
+ </Box>
955
+ )}
956
+ </Box>
957
+ )}
958
+ </Box>
959
+ );
960
+ };