@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,205 @@
1
+ /**
2
+ * ExecutionProgress Component
3
+ *
4
+ * Displays the progress of task execution with effect-by-effect feedback.
5
+ */
6
+
7
+ import { Box, Text } from "ink";
8
+ import type React from "react";
9
+ import { useEffect, useState } from "react";
10
+ import { describeEffect } from "../effect.js";
11
+ import { runTask, type StampConfig } from "../interpreter.js";
12
+ import type { Effect, Task, TaskError } from "../types.js";
13
+ import { Spinner } from "./Spinner.js";
14
+
15
+ /** Effect with timing information for the completion timeline */
16
+ export interface TimedEffect {
17
+ effect: Effect;
18
+ /** Time in ms when this effect completed, relative to execution start */
19
+ timestamp: number;
20
+ }
21
+
22
+ export interface ExecutionProgressProps {
23
+ /** The task to execute */
24
+ task: Task<void>;
25
+ /** Whether to run in dry-run mode */
26
+ dryRun?: boolean;
27
+ /** Called when execution completes */
28
+ onComplete: (effects: TimedEffect[], duration: number) => void;
29
+ /** Called when execution fails */
30
+ onError: (error: TaskError) => void;
31
+ /** Stamp configuration for generated files (undefined = no stamps) */
32
+ stamp?: StampConfig;
33
+ }
34
+
35
+ interface CompletedEffect {
36
+ id: number;
37
+ effect: Effect;
38
+ duration: number;
39
+ timestamp: number;
40
+ }
41
+
42
+ interface LogMessage {
43
+ id: number;
44
+ level: "debug" | "info" | "warn" | "error";
45
+ message: string;
46
+ }
47
+
48
+ /** Effects that should be hidden from the progress display (internal/noisy) */
49
+ const isInternalEffect = (effect: Effect): boolean => {
50
+ // Hide internal coordination effects
51
+ if (
52
+ effect._tag === "Log" ||
53
+ effect._tag === "Exists" ||
54
+ effect._tag === "ReadContext" ||
55
+ effect._tag === "WriteContext" ||
56
+ effect._tag === "Parallel" ||
57
+ effect._tag === "Race"
58
+ ) {
59
+ return true;
60
+ }
61
+ // Hide template file reads (internal implementation detail)
62
+ if (effect._tag === "ReadFile" && effect.path.includes("/templates/")) {
63
+ return true;
64
+ }
65
+ return false;
66
+ };
67
+
68
+ export const ExecutionProgress: React.FC<ExecutionProgressProps> = ({
69
+ task,
70
+ dryRun: _dryRun = false,
71
+ onComplete,
72
+ onError,
73
+ stamp,
74
+ }) => {
75
+ const [currentEffect, setCurrentEffect] = useState<Effect | null>(null);
76
+ const [completedEffects, setCompletedEffects] = useState<CompletedEffect[]>(
77
+ [],
78
+ );
79
+ const [logMessages, setLogMessages] = useState<LogMessage[]>([]);
80
+ const [isRunning, setIsRunning] = useState(true);
81
+
82
+ useEffect(() => {
83
+ const collectedEffects: TimedEffect[] = [];
84
+ const startTime = performance.now();
85
+ let effectId = 0;
86
+ let logId = 0;
87
+ // Track seen directory paths to deduplicate MakeDir effects in live progress
88
+ const seenDirPaths = new Set<string>();
89
+
90
+ const executeWithProgress = async () => {
91
+ try {
92
+ await runTask(task, {
93
+ stamp,
94
+ onEffectStart: (effect) => {
95
+ // Skip showing duplicate MakeDir in spinner
96
+ if (effect._tag === "MakeDir" && seenDirPaths.has(effect.path)) {
97
+ return;
98
+ }
99
+ setCurrentEffect(effect);
100
+ },
101
+ onEffectComplete: (effect, duration) => {
102
+ const id = effectId++;
103
+ const timestamp = performance.now() - startTime;
104
+ collectedEffects.push({ effect, timestamp });
105
+ // Skip duplicate MakeDir effects in live display
106
+ if (effect._tag === "MakeDir") {
107
+ if (seenDirPaths.has(effect.path)) {
108
+ setCurrentEffect(null);
109
+ return;
110
+ }
111
+ seenDirPaths.add(effect.path);
112
+ }
113
+ setCompletedEffects((prev) => [
114
+ ...prev,
115
+ { id, effect, duration, timestamp },
116
+ ]);
117
+ setCurrentEffect(null);
118
+ },
119
+ onLog: (level, message) => {
120
+ const id = logId++;
121
+ setLogMessages((prev) => [...prev, { id, level, message }]);
122
+ },
123
+ });
124
+
125
+ const duration = performance.now() - startTime;
126
+ onComplete(collectedEffects, duration);
127
+ } catch (err) {
128
+ const taskError: TaskError =
129
+ err instanceof Error
130
+ ? {
131
+ code: "EXECUTION_ERROR",
132
+ message: err.message,
133
+ stack: err.stack,
134
+ }
135
+ : {
136
+ code: "UNKNOWN_ERROR",
137
+ message: String(err),
138
+ };
139
+ onError(taskError);
140
+ } finally {
141
+ setIsRunning(false);
142
+ }
143
+ };
144
+
145
+ executeWithProgress();
146
+ }, [task, onComplete, onError, stamp]);
147
+
148
+ const logColor = (level: LogMessage["level"]) => {
149
+ switch (level) {
150
+ case "debug":
151
+ return "gray";
152
+ case "info":
153
+ return "blue";
154
+ case "warn":
155
+ return "yellow";
156
+ case "error":
157
+ return "red";
158
+ }
159
+ };
160
+
161
+ const logIcon = (level: LogMessage["level"]) => {
162
+ switch (level) {
163
+ case "debug":
164
+ return "·";
165
+ case "info":
166
+ return "›";
167
+ case "warn":
168
+ return "⚠";
169
+ case "error":
170
+ return "✗";
171
+ }
172
+ };
173
+
174
+ return (
175
+ <Box flexDirection="column">
176
+ {/* Show log messages */}
177
+ {logMessages.map((log) => (
178
+ <Text key={`log-${log.id}`}>
179
+ <Text color={logColor(log.level)}>{logIcon(log.level)}</Text>{" "}
180
+ <Text>{log.message}</Text>
181
+ </Text>
182
+ ))}
183
+ {/* Show completed file effects (excluding internal/noisy effects) */}
184
+ {completedEffects
185
+ .filter((item) => !isInternalEffect(item.effect))
186
+ .map((item) => (
187
+ <Text key={`effect-${item.id}`}>
188
+ <Text color="green">✓</Text> {describeEffect(item.effect)}{" "}
189
+ <Text dimColor>({item.duration.toFixed(0)}ms)</Text>
190
+ </Text>
191
+ ))}
192
+ {currentEffect && !isInternalEffect(currentEffect) && (
193
+ <Box>
194
+ <Spinner color="blue" />
195
+ <Text> {describeEffect(currentEffect)}</Text>
196
+ </Box>
197
+ )}
198
+ {isRunning && !currentEffect && (
199
+ <Box>
200
+ <Spinner color="blue" label="Executing..." />
201
+ </Box>
202
+ )}
203
+ </Box>
204
+ );
205
+ };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * FileTreePreview Component
3
+ *
4
+ * Displays a list of files that will be created/modified.
5
+ */
6
+
7
+ import { Box, Text } from "ink";
8
+ import type { Effect } from "../types.js";
9
+
10
+ export interface FileTreePreviewProps {
11
+ /** Effects to display */
12
+ effects: Effect[];
13
+ /** Title for the preview */
14
+ title?: string;
15
+ }
16
+
17
+ const formatBytes = (bytes: number): string => {
18
+ if (bytes < 1024) return `${bytes} B`;
19
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
20
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
21
+ };
22
+
23
+ const getEffectInfo = (
24
+ effect: Effect,
25
+ ): { path: string; action: string; size?: number } | null => {
26
+ switch (effect._tag) {
27
+ case "WriteFile":
28
+ return {
29
+ path: effect.path,
30
+ action: "create",
31
+ size: effect.content.length,
32
+ };
33
+ case "MakeDir":
34
+ return { path: effect.path, action: "mkdir" };
35
+ case "CopyFile":
36
+ return { path: effect.dest, action: "copy" };
37
+ case "DeleteFile":
38
+ return { path: effect.path, action: "delete" };
39
+ case "DeleteDirectory":
40
+ return { path: effect.path, action: "rmdir" };
41
+ default:
42
+ return null;
43
+ }
44
+ };
45
+
46
+ export const FileTreePreview = ({
47
+ effects,
48
+ title = "Files to be created:",
49
+ }: FileTreePreviewProps) => {
50
+ // Deduplicate effects by path (keep first occurrence, which preserves WriteFile over MakeDir)
51
+ const seen = new Set<string>();
52
+ const fileEffects = effects
53
+ .map(getEffectInfo)
54
+ .filter((info): info is NonNullable<typeof info> => {
55
+ if (info === null) return false;
56
+ if (seen.has(info.path)) return false;
57
+ seen.add(info.path);
58
+ return true;
59
+ })
60
+ .sort((a, b) => a.path.localeCompare(b.path));
61
+
62
+ if (fileEffects.length === 0) {
63
+ return (
64
+ <Box>
65
+ <Text dimColor>No files will be created.</Text>
66
+ </Box>
67
+ );
68
+ }
69
+
70
+ return (
71
+ <Box flexDirection="column">
72
+ <Text bold color="cyan">
73
+ {title}
74
+ </Text>
75
+ <Box marginLeft={1} flexDirection="column">
76
+ {fileEffects.map((info, i) => {
77
+ const actionColor =
78
+ info.action === "delete" || info.action === "rmdir"
79
+ ? "red"
80
+ : "green";
81
+ const actionIcon =
82
+ info.action === "delete" || info.action === "rmdir" ? "-" : "+";
83
+
84
+ return (
85
+ <Box key={`${info.path}-${i}`}>
86
+ <Text color={actionColor}>{actionIcon} </Text>
87
+ <Text>{info.path}</Text>
88
+ {info.size !== undefined && (
89
+ <Text dimColor> ({formatBytes(info.size)})</Text>
90
+ )}
91
+ </Box>
92
+ );
93
+ })}
94
+ </Box>
95
+ </Box>
96
+ );
97
+ };