@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.
- package/README.md +439 -0
- package/generators/example/hello/index.ts +132 -0
- package/generators/example/hello/templates/README.md.ejs +20 -0
- package/generators/example/hello/templates/index.ts.ejs +9 -0
- package/generators/example/webapp/index.ts +509 -0
- package/generators/example/webapp/templates/ARCHITECTURE.md.ejs +180 -0
- package/generators/example/webapp/templates/App.tsx.ejs +86 -0
- package/generators/example/webapp/templates/README.md.ejs +154 -0
- package/generators/example/webapp/templates/app.test.ts.ejs +63 -0
- package/generators/example/webapp/templates/app.ts.ejs +132 -0
- package/generators/example/webapp/templates/feature.ts.ejs +264 -0
- package/generators/example/webapp/templates/index.html.ejs +20 -0
- package/generators/example/webapp/templates/main.tsx.ejs +43 -0
- package/generators/example/webapp/templates/styles.css.ejs +135 -0
- package/generators/init/index.ts +124 -0
- package/generators/init/templates/generator.ts.ejs +85 -0
- package/generators/init/templates/template-index.ts.ejs +9 -0
- package/generators/init/templates/template-test.ts.ejs +8 -0
- package/package.json +64 -0
- package/src/__tests__/combinators.test.ts +895 -0
- package/src/__tests__/dry-run.test.ts +927 -0
- package/src/__tests__/effect.test.ts +816 -0
- package/src/__tests__/interpreter.test.ts +673 -0
- package/src/__tests__/primitives.test.ts +970 -0
- package/src/__tests__/task.test.ts +929 -0
- package/src/__tests__/template.test.ts +666 -0
- package/src/cli-format.ts +165 -0
- package/src/cli-types.ts +53 -0
- package/src/cli.tsx +1322 -0
- package/src/combinators.ts +294 -0
- package/src/completion.ts +488 -0
- package/src/components/App.tsx +960 -0
- package/src/components/ExecutionProgress.tsx +205 -0
- package/src/components/FileTreePreview.tsx +97 -0
- package/src/components/PromptSequence.tsx +483 -0
- package/src/components/Spinner.tsx +36 -0
- package/src/components/index.ts +16 -0
- package/src/dry-run.ts +434 -0
- package/src/effect.ts +224 -0
- package/src/index.ts +266 -0
- package/src/interpreter.ts +463 -0
- package/src/primitives.ts +442 -0
- package/src/task.ts +245 -0
- package/src/template.ts +537 -0
- 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
|
+
};
|