@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,483 @@
1
+ /**
2
+ * PromptSequence Component
3
+ *
4
+ * Handles interactive prompts for generators using React Ink.
5
+ * Features:
6
+ * - Progress indicator (step X of Y)
7
+ * - Navigate back to previous answers
8
+ * - Show completed answers
9
+ * - Group headers
10
+ */
11
+
12
+ import { Box, Text, useInput } from "ink";
13
+ import SelectInput from "ink-select-input";
14
+ import TextInput from "ink-text-input";
15
+ import { useCallback, useEffect, useState } from "react";
16
+ import type { PromptDefinition } from "../types.js";
17
+
18
+ export interface PromptSequenceProps {
19
+ /** List of prompts to display */
20
+ prompts: PromptDefinition[];
21
+ /** Called when all prompts are answered */
22
+ onComplete: (answers: Record<string, unknown>) => void;
23
+ /** Called when user cancels */
24
+ onCancel?: () => void;
25
+ /** Initial answers (for resuming/editing) */
26
+ initialAnswers?: Record<string, unknown>;
27
+ }
28
+
29
+ // =============================================================================
30
+ // Helper to format answer values for display
31
+ // =============================================================================
32
+
33
+ const formatAnswerValue = (
34
+ value: unknown,
35
+ prompt: PromptDefinition,
36
+ ): string => {
37
+ if (value === undefined || value === null) return "";
38
+
39
+ if (prompt.type === "confirm") {
40
+ return value ? "Yes" : "No";
41
+ }
42
+
43
+ if (prompt.type === "select" && prompt.choices) {
44
+ const choice = prompt.choices.find((c) => c.value === value);
45
+ return choice?.label ?? String(value);
46
+ }
47
+
48
+ if (prompt.type === "multiselect" && Array.isArray(value)) {
49
+ if (value.length === 0) return "None";
50
+ if (prompt.choices) {
51
+ return value
52
+ .map((v) => prompt.choices?.find((c) => c.value === v)?.label ?? v)
53
+ .join(", ");
54
+ }
55
+ return value.join(", ");
56
+ }
57
+
58
+ return String(value);
59
+ };
60
+
61
+ // =============================================================================
62
+ // Completed Answers Table Display
63
+ // =============================================================================
64
+
65
+ interface CompletedAnswersTableProps {
66
+ prompts: PromptDefinition[];
67
+ answers: Record<string, unknown>;
68
+ }
69
+
70
+ /**
71
+ * Display completed answers in a borderless table format with aligned columns.
72
+ */
73
+ const CompletedAnswersTable = ({
74
+ prompts,
75
+ answers,
76
+ }: CompletedAnswersTableProps) => {
77
+ if (prompts.length === 0) {
78
+ return null;
79
+ }
80
+
81
+ // Calculate max width for the question column (for alignment)
82
+ const maxQuestionWidth = Math.max(...prompts.map((p) => p.message.length));
83
+
84
+ return (
85
+ <Box flexDirection="column" marginBottom={1}>
86
+ {prompts.map((prompt) => {
87
+ const value = answers[prompt.name];
88
+ const displayValue = formatAnswerValue(value, prompt);
89
+
90
+ return (
91
+ <Box key={prompt.name}>
92
+ <Text color="green">✔ </Text>
93
+ <Text dimColor>{prompt.message.padEnd(maxQuestionWidth)}</Text>
94
+ <Text dimColor> </Text>
95
+ <Text color="cyan">{displayValue}</Text>
96
+ </Box>
97
+ );
98
+ })}
99
+ </Box>
100
+ );
101
+ };
102
+
103
+ // =============================================================================
104
+ // Text Prompt
105
+ // =============================================================================
106
+
107
+ interface TextPromptProps {
108
+ prompt: PromptDefinition;
109
+ initialValue?: string;
110
+ onSubmit: (value: string) => void;
111
+ onBack?: () => void;
112
+ }
113
+
114
+ const TextPrompt = ({
115
+ prompt,
116
+ initialValue,
117
+ onSubmit,
118
+ onBack,
119
+ }: TextPromptProps) => {
120
+ const [value, setValue] = useState(
121
+ initialValue ?? String(prompt.default ?? ""),
122
+ );
123
+ const [error, setError] = useState<string | null>(null);
124
+
125
+ // Reset value when prompt changes
126
+ useEffect(() => {
127
+ setValue(initialValue ?? String(prompt.default ?? ""));
128
+ setError(null);
129
+ }, [initialValue, prompt.default]);
130
+
131
+ const handleSubmit = useCallback(
132
+ (val: string) => {
133
+ if (prompt.validate) {
134
+ const result = prompt.validate(val);
135
+ if (result !== true) {
136
+ setError(typeof result === "string" ? result : "Invalid input");
137
+ return;
138
+ }
139
+ }
140
+ onSubmit(val);
141
+ },
142
+ [prompt, onSubmit],
143
+ );
144
+
145
+ // Handle back navigation with Escape
146
+ useInput((_input, key) => {
147
+ if (key.escape && onBack) {
148
+ onBack();
149
+ }
150
+ });
151
+
152
+ return (
153
+ <Box flexDirection="column">
154
+ <Box>
155
+ <Text color="magenta">› </Text>
156
+ <Text bold>{prompt.message}</Text>
157
+ </Box>
158
+ <Box marginLeft={2}>
159
+ <TextInput value={value} onChange={setValue} onSubmit={handleSubmit} />
160
+ </Box>
161
+ {error && (
162
+ <Box marginLeft={2}>
163
+ <Text color="red">✘ {error}</Text>
164
+ </Box>
165
+ )}
166
+ </Box>
167
+ );
168
+ };
169
+
170
+ // =============================================================================
171
+ // Confirm Prompt
172
+ // =============================================================================
173
+
174
+ interface ConfirmPromptProps {
175
+ prompt: PromptDefinition;
176
+ onSubmit: (value: boolean) => void;
177
+ onBack?: () => void;
178
+ }
179
+
180
+ const ConfirmPrompt = ({ prompt, onSubmit, onBack }: ConfirmPromptProps) => {
181
+ const defaultValue = Boolean(prompt.default);
182
+
183
+ useInput((input, key) => {
184
+ if (input.toLowerCase() === "y") {
185
+ onSubmit(true);
186
+ } else if (input.toLowerCase() === "n") {
187
+ onSubmit(false);
188
+ } else if (key.return) {
189
+ onSubmit(defaultValue);
190
+ } else if (key.escape && onBack) {
191
+ onBack();
192
+ }
193
+ });
194
+
195
+ const hint = defaultValue ? "Y/n" : "y/N";
196
+
197
+ return (
198
+ <Box>
199
+ <Text color="magenta">› </Text>
200
+ <Text bold>{prompt.message} </Text>
201
+ <Text dimColor>({hint})</Text>
202
+ </Box>
203
+ );
204
+ };
205
+
206
+ // =============================================================================
207
+ // Select Prompt
208
+ // =============================================================================
209
+
210
+ interface SelectPromptProps {
211
+ prompt: PromptDefinition;
212
+ onSubmit: (value: string) => void;
213
+ onBack?: () => void;
214
+ }
215
+
216
+ const SelectPrompt = ({ prompt, onSubmit, onBack }: SelectPromptProps) => {
217
+ const items =
218
+ prompt.choices?.map((choice) => ({
219
+ label: choice.label,
220
+ value: choice.value,
221
+ })) ?? [];
222
+
223
+ // Find initial index based on default value
224
+ const initialIndex = prompt.default
225
+ ? items.findIndex((item) => item.value === prompt.default)
226
+ : 0;
227
+
228
+ useInput((_input, key) => {
229
+ if (key.escape && onBack) {
230
+ onBack();
231
+ }
232
+ });
233
+
234
+ return (
235
+ <Box flexDirection="column">
236
+ <Box>
237
+ <Text color="magenta">› </Text>
238
+ <Text bold>{prompt.message}</Text>
239
+ <Text dimColor> (↑↓ to select, enter to confirm)</Text>
240
+ </Box>
241
+ <Box marginLeft={2}>
242
+ <SelectInput
243
+ items={items}
244
+ initialIndex={initialIndex >= 0 ? initialIndex : 0}
245
+ onSelect={(item) => onSubmit(item.value)}
246
+ />
247
+ </Box>
248
+ </Box>
249
+ );
250
+ };
251
+
252
+ // =============================================================================
253
+ // Multiselect Prompt
254
+ // =============================================================================
255
+
256
+ interface MultiselectPromptProps {
257
+ prompt: PromptDefinition;
258
+ onSubmit: (values: string[]) => void;
259
+ onBack?: () => void;
260
+ }
261
+
262
+ const MultiselectPrompt = ({
263
+ prompt,
264
+ onSubmit,
265
+ onBack,
266
+ }: MultiselectPromptProps) => {
267
+ const [selected, setSelected] = useState<Set<string>>(
268
+ new Set(prompt.default as string[] | undefined),
269
+ );
270
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
271
+
272
+ const choices = prompt.choices ?? [];
273
+
274
+ useInput((input, key) => {
275
+ if (key.escape && onBack) {
276
+ onBack();
277
+ return;
278
+ }
279
+ if (key.upArrow) {
280
+ setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : choices.length - 1));
281
+ } else if (key.downArrow) {
282
+ setHighlightedIndex((prev) => (prev < choices.length - 1 ? prev + 1 : 0));
283
+ } else if (input === " ") {
284
+ const choice = choices[highlightedIndex];
285
+ if (choice) {
286
+ setSelected((prev) => {
287
+ const newSet = new Set(prev);
288
+ if (newSet.has(choice.value)) {
289
+ newSet.delete(choice.value);
290
+ } else {
291
+ newSet.add(choice.value);
292
+ }
293
+ return newSet;
294
+ });
295
+ }
296
+ } else if (key.return) {
297
+ onSubmit(Array.from(selected));
298
+ }
299
+ });
300
+
301
+ return (
302
+ <Box flexDirection="column">
303
+ <Box>
304
+ <Text color="magenta">› </Text>
305
+ <Text bold>{prompt.message}</Text>
306
+ <Text dimColor> (space to toggle, enter to confirm)</Text>
307
+ </Box>
308
+ <Box marginLeft={2} flexDirection="column">
309
+ {choices.map((choice, i) => {
310
+ const isHighlighted = i === highlightedIndex;
311
+ const isSelected = selected.has(choice.value);
312
+ const pointer = isHighlighted ? "› " : " ";
313
+ const checkbox = isSelected ? "◉ " : "○ ";
314
+
315
+ return (
316
+ <Box key={choice.value}>
317
+ <Text color={isHighlighted ? "magenta" : undefined}>
318
+ {pointer}
319
+ </Text>
320
+ <Text color={isSelected ? "green" : "gray"}>{checkbox}</Text>
321
+ <Text color={isSelected ? undefined : "gray"}>
322
+ {choice.label}
323
+ </Text>
324
+ </Box>
325
+ );
326
+ })}
327
+ </Box>
328
+ </Box>
329
+ );
330
+ };
331
+
332
+ // =============================================================================
333
+ // Progress Header
334
+ // =============================================================================
335
+
336
+ interface ProgressHeaderProps {
337
+ current: number;
338
+ total: number;
339
+ group?: string;
340
+ }
341
+
342
+ const ProgressHeader = ({ current, total, group }: ProgressHeaderProps) => {
343
+ return (
344
+ <Box marginBottom={1}>
345
+ <Text dimColor>
346
+ {group ? `${group} · ` : ""}Step {current} of {total}
347
+ {" · "}
348
+ <Text dimColor italic>
349
+ esc to go back
350
+ </Text>
351
+ </Text>
352
+ </Box>
353
+ );
354
+ };
355
+
356
+ // =============================================================================
357
+ // Main PromptSequence Component
358
+ // =============================================================================
359
+
360
+ export const PromptSequence = ({
361
+ prompts,
362
+ onComplete,
363
+ onCancel,
364
+ initialAnswers,
365
+ }: PromptSequenceProps) => {
366
+ // Always start at index 0, but preserve answers for display/editing
367
+ const [currentIndex, setCurrentIndex] = useState(0);
368
+ const [answers, setAnswers] = useState<Record<string, unknown>>(
369
+ initialAnswers ?? {},
370
+ );
371
+ // Track the history of prompt indices for back navigation
372
+ const [history, setHistory] = useState<number[]>([]);
373
+
374
+ // Filter prompts based on `when` condition
375
+ const activePrompts = prompts.filter((prompt) => {
376
+ if (prompt.when) {
377
+ return prompt.when(answers);
378
+ }
379
+ return true;
380
+ });
381
+
382
+ const currentPrompt = activePrompts[currentIndex];
383
+
384
+ const handleAnswer = useCallback(
385
+ (value: unknown) => {
386
+ if (!currentPrompt) return;
387
+
388
+ const newAnswers = { ...answers, [currentPrompt.name]: value };
389
+ setAnswers(newAnswers);
390
+
391
+ if (currentIndex < activePrompts.length - 1) {
392
+ setHistory((prev) => [...prev, currentIndex]);
393
+ setCurrentIndex((prev) => prev + 1);
394
+ } else {
395
+ onComplete(newAnswers);
396
+ }
397
+ },
398
+ [answers, currentPrompt, currentIndex, activePrompts.length, onComplete],
399
+ );
400
+
401
+ const handleBack = useCallback(() => {
402
+ if (history.length > 0) {
403
+ const prevIndex = history[history.length - 1];
404
+ setHistory((prev) => prev.slice(0, -1));
405
+ setCurrentIndex(prevIndex);
406
+ } else if (onCancel) {
407
+ onCancel();
408
+ }
409
+ }, [history, onCancel]);
410
+
411
+ if (!currentPrompt) {
412
+ return null;
413
+ }
414
+
415
+ // Get previously completed prompts to display
416
+ const completedPrompts = activePrompts.slice(0, currentIndex);
417
+
418
+ // Render the appropriate prompt type
419
+ const renderCurrentPrompt = () => {
420
+ const existingValue = answers[currentPrompt.name];
421
+
422
+ switch (currentPrompt.type) {
423
+ case "text":
424
+ return (
425
+ <TextPrompt
426
+ prompt={currentPrompt}
427
+ initialValue={existingValue as string | undefined}
428
+ onSubmit={handleAnswer}
429
+ onBack={handleBack}
430
+ />
431
+ );
432
+ case "confirm":
433
+ return (
434
+ <ConfirmPrompt
435
+ prompt={currentPrompt}
436
+ onSubmit={handleAnswer}
437
+ onBack={handleBack}
438
+ />
439
+ );
440
+ case "select":
441
+ return (
442
+ <SelectPrompt
443
+ prompt={currentPrompt}
444
+ onSubmit={handleAnswer}
445
+ onBack={handleBack}
446
+ />
447
+ );
448
+ case "multiselect":
449
+ return (
450
+ <MultiselectPrompt
451
+ prompt={currentPrompt}
452
+ onSubmit={handleAnswer}
453
+ onBack={handleBack}
454
+ />
455
+ );
456
+ default:
457
+ return (
458
+ <Text color="red">
459
+ Unknown prompt type: {(currentPrompt as PromptDefinition).type}
460
+ </Text>
461
+ );
462
+ }
463
+ };
464
+
465
+ return (
466
+ <Box flexDirection="column">
467
+ {/* Progress indicator */}
468
+ <ProgressHeader
469
+ current={currentIndex + 1}
470
+ total={activePrompts.length}
471
+ group={currentPrompt.group}
472
+ />
473
+
474
+ {/* Show completed answers in table format */}
475
+ {completedPrompts.length > 0 && (
476
+ <CompletedAnswersTable prompts={completedPrompts} answers={answers} />
477
+ )}
478
+
479
+ {/* Current prompt */}
480
+ {renderCurrentPrompt()}
481
+ </Box>
482
+ );
483
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Spinner Component
3
+ *
4
+ * A simple loading spinner for the CLI.
5
+ */
6
+
7
+ import { Box, Text } from "ink";
8
+ import { useEffect, useState } from "react";
9
+
10
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
11
+
12
+ export interface SpinnerProps {
13
+ /** Color of the spinner */
14
+ color?: string;
15
+ /** Label to show next to the spinner */
16
+ label?: string;
17
+ }
18
+
19
+ export const Spinner = ({ color = "cyan", label }: SpinnerProps) => {
20
+ const [frameIndex, setFrameIndex] = useState(0);
21
+
22
+ useEffect(() => {
23
+ const timer = setInterval(() => {
24
+ setFrameIndex((prev) => (prev + 1) % frames.length);
25
+ }, 80);
26
+
27
+ return () => clearInterval(timer);
28
+ }, []);
29
+
30
+ return (
31
+ <Box>
32
+ <Text color={color}>{frames[frameIndex]}</Text>
33
+ {label && <Text> {label}</Text>}
34
+ </Box>
35
+ );
36
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * React Ink Components
3
+ *
4
+ * This module exports all React Ink components for the CLI.
5
+ */
6
+
7
+ export type { AppProps, AppState } from "./App.js";
8
+ export { App } from "./App.js";
9
+ export type { ExecutionProgressProps } from "./ExecutionProgress.js";
10
+ export { ExecutionProgress } from "./ExecutionProgress.js";
11
+ export type { FileTreePreviewProps } from "./FileTreePreview.js";
12
+ export { FileTreePreview } from "./FileTreePreview.js";
13
+ export type { PromptSequenceProps } from "./PromptSequence.js";
14
+ export { PromptSequence } from "./PromptSequence.js";
15
+ export type { SpinnerProps } from "./Spinner.js";
16
+ export { Spinner } from "./Spinner.js";