@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,488 @@
1
+ /**
2
+ * Shell Autocompletion Support for Summon CLI
3
+ *
4
+ * Uses omelette to provide TAB completion for:
5
+ * - Generator names (navigating the command tree)
6
+ * - Generator arguments (based on prompt definitions)
7
+ * - File/folder paths for path-related prompts
8
+ *
9
+ * Supports Bash, Zsh, and Fish shells.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import omelette from "omelette";
15
+ import type { GeneratorDefinition, PromptDefinition } from "./types.js";
16
+
17
+ // =============================================================================
18
+ // Types
19
+ // =============================================================================
20
+
21
+ /** Represents a node in the generator tree (simplified for completion) */
22
+ export interface CompletionNode {
23
+ name: string;
24
+ indexPath?: string;
25
+ children: Map<string, CompletionNode>;
26
+ }
27
+
28
+ /** Flattened generator info for completion */
29
+ interface GeneratorInfo {
30
+ path: string[];
31
+ prompts: PromptDefinition[];
32
+ }
33
+
34
+ // =============================================================================
35
+ // Path Detection
36
+ // =============================================================================
37
+
38
+ /**
39
+ * Detect if a prompt expects a file/folder path based on its name or message.
40
+ */
41
+ export const isPathPrompt = (prompt: PromptDefinition): boolean => {
42
+ const pathIndicators = [
43
+ "path",
44
+ "dir",
45
+ "directory",
46
+ "file",
47
+ "folder",
48
+ "location",
49
+ ];
50
+ const nameLower = prompt.name.toLowerCase();
51
+ const messageLower = prompt.message.toLowerCase();
52
+
53
+ return pathIndicators.some(
54
+ (ind) => nameLower.includes(ind) || messageLower.includes(ind),
55
+ );
56
+ };
57
+
58
+ // =============================================================================
59
+ // Filesystem Completion
60
+ // =============================================================================
61
+
62
+ /**
63
+ * Get filesystem path completions for a partial path.
64
+ */
65
+ export const getPathCompletions = (partial: string): string[] => {
66
+ try {
67
+ // Handle empty or relative paths
68
+ const searchPath = partial || ".";
69
+ const dir = path.dirname(searchPath);
70
+ const prefix = path.basename(searchPath);
71
+
72
+ // Try to read the directory
73
+ const dirToRead = partial.endsWith("/") ? searchPath : dir;
74
+ const entries = fs.readdirSync(dirToRead, { withFileTypes: true });
75
+
76
+ // Filter entries that match the prefix
77
+ const matches = entries
78
+ .filter((entry) => {
79
+ // If partial ends with /, show all entries
80
+ if (partial.endsWith("/")) return true;
81
+ // Otherwise filter by prefix
82
+ return entry.name.startsWith(prefix);
83
+ })
84
+ .map((entry) => {
85
+ const name = entry.name;
86
+ const basePath = partial.endsWith("/") ? searchPath : dir;
87
+ const fullPath = basePath === "." ? name : path.join(basePath, name);
88
+
89
+ // Append / for directories to enable continued completion
90
+ return entry.isDirectory() ? `${fullPath}/` : fullPath;
91
+ })
92
+ .slice(0, 50); // Limit results
93
+
94
+ return matches;
95
+ } catch {
96
+ // Directory doesn't exist or can't be read
97
+ return [];
98
+ }
99
+ };
100
+
101
+ // =============================================================================
102
+ // Argument Completion
103
+ // =============================================================================
104
+
105
+ /**
106
+ * Convert camelCase to kebab-case for CLI flags.
107
+ */
108
+ const toKebabCase = (str: string): string =>
109
+ str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
110
+
111
+ /**
112
+ * Get completions for generator arguments based on prompt definitions.
113
+ *
114
+ * @param prompts - The prompt definitions for the generator
115
+ * @param line - The current command line
116
+ * @param before - The word before the cursor
117
+ * @param showAll - If true, show all flags even if line doesn't contain --
118
+ */
119
+ export const getArgumentCompletions = (
120
+ prompts: PromptDefinition[],
121
+ line: string,
122
+ before: string,
123
+ showAll = false,
124
+ ): string[] => {
125
+ const completions: string[] = [];
126
+
127
+ // Check if we're completing a flag value (e.g., --type=<TAB> or --type <TAB>)
128
+ const flagValueMatch = line.match(/--([a-z-]+)(?:=|\s+)([^\s]*)$/i);
129
+ if (flagValueMatch) {
130
+ const flagName = flagValueMatch[1];
131
+ const partial = flagValueMatch[2] || "";
132
+
133
+ // Find the corresponding prompt
134
+ const prompt = prompts.find(
135
+ (p) => toKebabCase(p.name) === flagName || p.name === flagName,
136
+ );
137
+
138
+ if (prompt) {
139
+ // For select/multiselect, return choices
140
+ if (
141
+ (prompt.type === "select" || prompt.type === "multiselect") &&
142
+ prompt.choices
143
+ ) {
144
+ return prompt.choices
145
+ .map((c) => c.value)
146
+ .filter((v) => v.startsWith(partial));
147
+ }
148
+
149
+ // For text prompts that look like paths, return path completions
150
+ if (prompt.type === "text" && isPathPrompt(prompt)) {
151
+ return getPathCompletions(partial);
152
+ }
153
+ }
154
+ }
155
+
156
+ // Check if we're completing a flag name (--<TAB>) or showing all flags
157
+ const flagNameMatch = line.match(/--([a-z-]*)$/i);
158
+ const shouldShowFlags =
159
+ showAll ||
160
+ flagNameMatch ||
161
+ before === "--" ||
162
+ before.startsWith("--") ||
163
+ before.startsWith("-");
164
+
165
+ if (shouldShowFlags) {
166
+ // Extract partial from the flag name match, or from 'before' if it starts with --
167
+ let partial = flagNameMatch?.[1] || "";
168
+ if (!partial && before.startsWith("--")) {
169
+ partial = before.slice(2);
170
+ } else if (!partial && before.startsWith("-")) {
171
+ partial = before.slice(1);
172
+ }
173
+
174
+ for (const prompt of prompts) {
175
+ const kebabName = toKebabCase(prompt.name);
176
+
177
+ if (prompt.type === "confirm") {
178
+ // For confirm prompts, show --no-X if default is true, --X if default is false
179
+ if (prompt.default === true) {
180
+ const flag = `no-${kebabName}`;
181
+ if (flag.startsWith(partial)) {
182
+ completions.push(`--${flag}`);
183
+ }
184
+ } else {
185
+ if (kebabName.startsWith(partial)) {
186
+ completions.push(`--${kebabName}`);
187
+ }
188
+ }
189
+ } else {
190
+ if (kebabName.startsWith(partial)) {
191
+ completions.push(`--${kebabName}`);
192
+ }
193
+ }
194
+ }
195
+
196
+ // Add built-in flags
197
+ const builtinFlags = [
198
+ "--dry-run",
199
+ "--yes",
200
+ "--verbose",
201
+ "--no-preview",
202
+ "--no-generated-stamp",
203
+ "--help",
204
+ ];
205
+ for (const flag of builtinFlags) {
206
+ if (flag.startsWith(`--${partial}`)) {
207
+ completions.push(flag);
208
+ }
209
+ }
210
+ }
211
+
212
+ return completions;
213
+ };
214
+
215
+ // =============================================================================
216
+ // Tree Building
217
+ // =============================================================================
218
+
219
+ /**
220
+ * Generator loader function type - passed in to avoid circular dependencies.
221
+ */
222
+ export type GeneratorLoader = (
223
+ indexPath: string,
224
+ ) => Promise<GeneratorDefinition>;
225
+
226
+ /**
227
+ * Get completions for a specific position in the command.
228
+ */
229
+ const getCompletionsAtPosition = (
230
+ node: CompletionNode,
231
+ args: string[],
232
+ generators: Map<string, GeneratorInfo>,
233
+ ): string[] => {
234
+ // Navigate to the correct position in the tree
235
+ let current = node;
236
+ const pathSoFar: string[] = [];
237
+
238
+ for (const arg of args) {
239
+ // Skip flags and empty args
240
+ if (arg.startsWith("-") || !arg) continue;
241
+
242
+ const child = current.children.get(arg);
243
+ if (child) {
244
+ current = child;
245
+ pathSoFar.push(arg);
246
+ } else {
247
+ // Partial match - don't add to path, keep current node
248
+ break;
249
+ }
250
+ }
251
+
252
+ // Check if we have subcommands available
253
+ const subcommands = [...current.children.keys()];
254
+
255
+ // Check if we're at a generator
256
+ const generatorKey = pathSoFar.join("/");
257
+ const generatorInfo = generators.get(generatorKey);
258
+
259
+ // Get the last arg for context
260
+ const lastArg = args[args.length - 1] || "";
261
+ const line = args.join(" ");
262
+
263
+ // If we're at a generator
264
+ if (generatorInfo) {
265
+ // If the last arg starts with -, provide flag completions
266
+ if (lastArg.startsWith("-")) {
267
+ return getArgumentCompletions(generatorInfo.prompts, line, lastArg);
268
+ }
269
+
270
+ // Check if this generator has a positional prompt
271
+ const positionalPrompt = generatorInfo.prompts.find((p) => p.positional);
272
+
273
+ // If there are no subcommands and this is a leaf generator
274
+ if (subcommands.length === 0) {
275
+ // Count non-flag args after the generator path
276
+ const nonFlagArgs = args.filter((a) => !a.startsWith("-"));
277
+ const argsAfterGenerator = nonFlagArgs.length - pathSoFar.length;
278
+
279
+ // If we have a positional prompt and haven't provided the positional arg yet
280
+ if (positionalPrompt && argsAfterGenerator === 0) {
281
+ // If the positional is a path, provide path completions
282
+ if (isPathPrompt(positionalPrompt)) {
283
+ return getPathCompletions(lastArg || "");
284
+ }
285
+ // Otherwise show flags (they can type the positional or use flags)
286
+ return getArgumentCompletions(generatorInfo.prompts, line, "", true);
287
+ }
288
+
289
+ // If positional already provided or no positional, show flags
290
+ return getArgumentCompletions(generatorInfo.prompts, line, "", true);
291
+ }
292
+
293
+ // If there are both subcommands and this is a generator,
294
+ // show subcommands (user might want to go deeper)
295
+ return subcommands;
296
+ }
297
+
298
+ // Not at a generator, show subcommands
299
+ // Only filter if the last arg is a partial (not in pathSoFar)
300
+ const lastPathSeg = pathSoFar[pathSoFar.length - 1];
301
+ const isPartialArg =
302
+ lastArg && !lastArg.startsWith("-") && lastArg !== lastPathSeg;
303
+
304
+ if (isPartialArg) {
305
+ return subcommands.filter((cmd) => cmd.startsWith(lastArg));
306
+ }
307
+
308
+ return subcommands;
309
+ };
310
+
311
+ /**
312
+ * Load all generators and cache their prompts.
313
+ */
314
+ const loadAllGenerators = async (
315
+ node: CompletionNode,
316
+ loadGenerator: GeneratorLoader,
317
+ currentPath: string[] = [],
318
+ cache: Map<string, GeneratorInfo> = new Map(),
319
+ ): Promise<Map<string, GeneratorInfo>> => {
320
+ for (const [name, child] of node.children) {
321
+ const childPath = [...currentPath, name];
322
+ const pathKey = childPath.join("/");
323
+
324
+ if (child.indexPath) {
325
+ try {
326
+ const generator = await loadGenerator(child.indexPath);
327
+ cache.set(pathKey, {
328
+ path: childPath,
329
+ prompts: generator.prompts,
330
+ });
331
+ } catch {
332
+ // Failed to load generator, skip
333
+ }
334
+ }
335
+
336
+ if (child.children.size > 0) {
337
+ await loadAllGenerators(child, loadGenerator, childPath, cache);
338
+ }
339
+ }
340
+
341
+ return cache;
342
+ };
343
+
344
+ // =============================================================================
345
+ // Completion Initialization
346
+ // =============================================================================
347
+
348
+ /**
349
+ * Initialize shell completion for the summon CLI.
350
+ *
351
+ * This should be called early in the CLI lifecycle, before commander parsing,
352
+ * because omelette needs to intercept completion requests.
353
+ *
354
+ * @param generatorTree - The root of the generator tree
355
+ * @param loadGenerator - Function to load a generator from its path
356
+ * @returns The omelette instance (for setup/cleanup methods)
357
+ */
358
+ export const initCompletion = async (
359
+ generatorTree: CompletionNode,
360
+ loadGenerator: GeneratorLoader,
361
+ ): Promise<omelette.Instance> => {
362
+ // Load all generators to get their prompts
363
+ const generators = await loadAllGenerators(generatorTree, loadGenerator);
364
+
365
+ // Create the completion instance
366
+ // We use a template with multiple segments to handle deep hierarchies
367
+ // The "complete" event fires for all positions
368
+ const complete = omelette("summon <arg1> <arg2> <arg3> <arg4> <arg5>");
369
+
370
+ // Handle completion dynamically for all positions
371
+ // The "complete" event receives the fragment name (arg1, arg2, etc.) and context
372
+ complete.on("complete", (fragment, { line, reply, before }) => {
373
+ // Parse the current line to determine what to complete
374
+ // Remove 'summon' and filter out internal flags like --generators
375
+ let args = line.split(/\s+/).filter(Boolean).slice(1);
376
+
377
+ // Filter out internal CLI flags that shouldn't be part of completion context
378
+ const internalFlags = [
379
+ "--generators",
380
+ "-g",
381
+ "--compbash",
382
+ "--compzsh",
383
+ "--compfish",
384
+ "--compgen",
385
+ ];
386
+ args = args.filter((arg, idx) => {
387
+ // Skip internal flags and their values
388
+ if (internalFlags.includes(arg)) return false;
389
+ // Skip the value after an internal flag
390
+ if (idx > 0 && internalFlags.includes(args[idx - 1])) return false;
391
+ return true;
392
+ });
393
+
394
+ // Debug logging (only when SUMMON_DEBUG is set)
395
+ if (process.env.SUMMON_DEBUG) {
396
+ console.error("[completion] fragment:", fragment);
397
+ console.error("[completion] line:", line);
398
+ console.error("[completion] before:", before);
399
+ console.error("[completion] args (filtered):", args);
400
+ }
401
+
402
+ // If before is empty, we're at the start of a new argument
403
+ // If not, we're completing a partial argument
404
+ const completions = getCompletionsAtPosition(
405
+ generatorTree,
406
+ args,
407
+ generators,
408
+ );
409
+
410
+ if (process.env.SUMMON_DEBUG) {
411
+ console.error("[completion] completions:", completions);
412
+ }
413
+
414
+ reply(completions);
415
+ });
416
+
417
+ // Initialize omelette (this intercepts --completion args)
418
+ complete.init();
419
+
420
+ return complete;
421
+ };
422
+
423
+ /**
424
+ * Check if the current invocation is a completion request.
425
+ * This allows us to skip normal CLI processing for completion.
426
+ */
427
+ export const isCompletionRequest = (): boolean => {
428
+ const args = process.argv;
429
+ return (
430
+ args.includes("--completion") ||
431
+ args.includes("--completion-fish") ||
432
+ args.includes("--compzsh") ||
433
+ args.includes("--compbash") ||
434
+ args.includes("--compfish") ||
435
+ // Omelette internal completion trigger
436
+ args.some((arg) => arg.startsWith("--compgen"))
437
+ );
438
+ };
439
+
440
+ /**
441
+ * Check if this is a setup or cleanup request.
442
+ */
443
+ export const isSetupRequest = (): boolean => {
444
+ const args = process.argv;
445
+ return (
446
+ args.includes("--setup-completion") || args.includes("--cleanup-completion")
447
+ );
448
+ };
449
+
450
+ /**
451
+ * Handle setup/cleanup requests.
452
+ */
453
+ export const handleSetupRequest = (complete: omelette.Instance): void => {
454
+ const args = process.argv;
455
+
456
+ if (args.includes("--setup-completion")) {
457
+ try {
458
+ complete.setupShellInitFile();
459
+ console.log("Shell completion installed successfully!");
460
+ console.log(
461
+ "Please restart your shell or run: source ~/.zshrc (or ~/.bashrc)",
462
+ );
463
+ } catch (err) {
464
+ console.error("Failed to install completion:", (err as Error).message);
465
+ console.error("\nManual installation:");
466
+ console.error(" Zsh: echo '. <(summon --completion)' >> ~/.zshrc");
467
+ console.error(
468
+ " Bash: summon --completion >> ~/.summon-completion.sh && echo 'source ~/.summon-completion.sh' >> ~/.bash_profile",
469
+ );
470
+ console.error(
471
+ " Fish: echo 'summon --completion-fish | source' >> ~/.config/fish/config.fish",
472
+ );
473
+ process.exit(1);
474
+ }
475
+ process.exit(0);
476
+ }
477
+
478
+ if (args.includes("--cleanup-completion")) {
479
+ try {
480
+ complete.cleanupShellInitFile();
481
+ console.log("Shell completion removed successfully!");
482
+ } catch (err) {
483
+ console.error("Failed to remove completion:", (err as Error).message);
484
+ process.exit(1);
485
+ }
486
+ process.exit(0);
487
+ }
488
+ };