@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
package/src/cli.tsx ADDED
@@ -0,0 +1,1322 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Summon CLI
5
+ *
6
+ * A monadic task-centric code generator framework.
7
+ */
8
+
9
+ import * as fs from "node:fs/promises";
10
+ import * as path from "node:path";
11
+ import chalk from "chalk";
12
+ import { Command } from "commander";
13
+ import { render } from "ink";
14
+ import {
15
+ type CompletionNode,
16
+ handleSetupRequest,
17
+ initCompletion,
18
+ isCompletionRequest,
19
+ isSetupRequest,
20
+ } from "./completion.js";
21
+ import { App } from "./components/App.js";
22
+ import type { StampConfig } from "./interpreter.js";
23
+ import type { GeneratorDefinition, PromptDefinition } from "./types.js";
24
+
25
+ // =============================================================================
26
+ // Generator Discovery (Tree Structure)
27
+ // =============================================================================
28
+
29
+ /** Origin of a generator (for display purposes) */
30
+ type GeneratorOrigin = "local" | "global" | "package" | "builtin";
31
+
32
+ interface GeneratorNode {
33
+ name: string;
34
+ path: string; // Directory path
35
+ indexPath?: string; // Path to index.ts if this is a runnable generator
36
+ children: Map<string, GeneratorNode>;
37
+ origin?: GeneratorOrigin; // Where this generator came from
38
+ meta?: {
39
+ name: string;
40
+ description: string;
41
+ version: string;
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Merge a child node into a parent, combining children if the topic already exists.
47
+ */
48
+ const mergeIntoTree = (parent: GeneratorNode, child: GeneratorNode): void => {
49
+ const existing = parent.children.get(child.name);
50
+ if (existing) {
51
+ // Merge children - existing (local) takes precedence for indexPath
52
+ for (const [name, grandchild] of child.children) {
53
+ if (!existing.children.has(name)) {
54
+ existing.children.set(name, grandchild);
55
+ }
56
+ }
57
+ // Only set indexPath if existing doesn't have one (local takes precedence)
58
+ if (!existing.indexPath && child.indexPath) {
59
+ existing.indexPath = child.indexPath;
60
+ }
61
+ } else {
62
+ parent.children.set(child.name, child);
63
+ }
64
+ };
65
+
66
+ /**
67
+ * Build a tree of generators from a directory.
68
+ * Supports nested structure like:
69
+ * generators/component/react/index.ts -> summon component react
70
+ * generators/component/svelte/index.ts -> summon component svelte
71
+ * generators/util/index.ts -> summon util
72
+ */
73
+ const buildGeneratorTree = async (
74
+ dir: string,
75
+ node: GeneratorNode,
76
+ origin: GeneratorOrigin = "local",
77
+ ): Promise<void> => {
78
+ try {
79
+ const entries = await fs.readdir(dir, { withFileTypes: true });
80
+
81
+ for (const entry of entries) {
82
+ if (entry.isDirectory()) {
83
+ const childDir = path.join(dir, entry.name);
84
+ const indexPath = path.join(childDir, "index.ts");
85
+
86
+ const childNode: GeneratorNode = {
87
+ name: entry.name,
88
+ path: childDir,
89
+ children: new Map(),
90
+ origin,
91
+ };
92
+
93
+ // Check if this directory has an index.ts (is a runnable generator)
94
+ try {
95
+ await fs.access(indexPath);
96
+ childNode.indexPath = indexPath;
97
+ } catch {
98
+ // No index.ts, might still have children
99
+ }
100
+
101
+ // Recursively discover children
102
+ await buildGeneratorTree(childDir, childNode, origin);
103
+
104
+ // Only add node if it has an index.ts or has children with generators
105
+ if (childNode.indexPath || childNode.children.size > 0) {
106
+ mergeIntoTree(node, childNode);
107
+ }
108
+ }
109
+ }
110
+ } catch {
111
+ // Directory doesn't exist
112
+ }
113
+ };
114
+
115
+ /**
116
+ * Extract topic name from a summon package name.
117
+ * summon-component -> component
118
+ * @scope/summon-component -> component
119
+ */
120
+ const _extractTopicFromPackageName = (pkgName: string): string | null => {
121
+ // Handle scoped packages: @scope/summon-topic
122
+ const scopedMatch = pkgName.match(/^@[^/]+\/summon-(.+)$/);
123
+ if (scopedMatch) return scopedMatch[1];
124
+
125
+ // Handle unscoped packages: summon-topic
126
+ const unscopedMatch = pkgName.match(/^summon-(.+)$/);
127
+ if (unscopedMatch) return unscopedMatch[1];
128
+
129
+ return null;
130
+ };
131
+
132
+ /**
133
+ * Check if a path is a directory (follows symlinks).
134
+ */
135
+ const isDirectory = async (filePath: string): Promise<boolean> => {
136
+ try {
137
+ const stat = await fs.stat(filePath); // stat follows symlinks
138
+ return stat.isDirectory();
139
+ } catch {
140
+ return false;
141
+ }
142
+ };
143
+
144
+ /**
145
+ * Discover summon-* packages in node_modules.
146
+ */
147
+ const discoverNodeModulesPackages = async (
148
+ nodeModulesDir: string,
149
+ root: GeneratorNode,
150
+ ): Promise<void> => {
151
+ try {
152
+ const entries = await fs.readdir(nodeModulesDir);
153
+
154
+ for (const entry of entries) {
155
+ const entryPath = path.join(nodeModulesDir, entry);
156
+
157
+ if (entry.startsWith("@")) {
158
+ // Scoped packages - look inside @scope/
159
+ if (!(await isDirectory(entryPath))) continue;
160
+ try {
161
+ const scopedEntries = await fs.readdir(entryPath);
162
+ for (const scopedEntry of scopedEntries) {
163
+ if (scopedEntry.startsWith("summon-")) {
164
+ const pkgDir = path.join(entryPath, scopedEntry);
165
+ if (await isDirectory(pkgDir)) {
166
+ await processPackage(`${entry}/${scopedEntry}`, pkgDir, root);
167
+ }
168
+ }
169
+ }
170
+ } catch {
171
+ // Scope directory doesn't exist or can't be read
172
+ }
173
+ } else if (entry.startsWith("summon-")) {
174
+ // Unscoped summon-* package
175
+ if (await isDirectory(entryPath)) {
176
+ await processPackage(entry, entryPath, root);
177
+ }
178
+ }
179
+ }
180
+ } catch {
181
+ // node_modules doesn't exist
182
+ }
183
+ };
184
+
185
+ /**
186
+ * Process a potential summon package.
187
+ */
188
+ /**
189
+ * Generator cache - stores generators loaded from package barrels.
190
+ * Key is the command path (e.g., "component/react").
191
+ */
192
+ const generatorCache = new Map<string, GeneratorDefinition>();
193
+
194
+ /**
195
+ * Insert a generator into the tree at the given path.
196
+ * Creates intermediate namespace nodes as needed.
197
+ */
198
+ const insertGeneratorAtPath = (
199
+ root: GeneratorNode,
200
+ pathStr: string,
201
+ generator: GeneratorDefinition,
202
+ origin: GeneratorOrigin = "package",
203
+ ): void => {
204
+ const segments = pathStr.split("/").filter(Boolean);
205
+ let current = root;
206
+
207
+ // Cache the generator for later lookup
208
+ generatorCache.set(pathStr, generator);
209
+
210
+ for (let i = 0; i < segments.length; i++) {
211
+ const segment = segments[i];
212
+ const isLast = i === segments.length - 1;
213
+
214
+ if (!current.children.has(segment)) {
215
+ current.children.set(segment, {
216
+ name: segment,
217
+ path: "",
218
+ children: new Map(),
219
+ origin,
220
+ });
221
+ }
222
+
223
+ const child = current.children.get(segment);
224
+ if (!child) continue; // Should never happen since we just set it
225
+
226
+ if (isLast) {
227
+ // Mark as having a generator (use path as synthetic indexPath)
228
+ child.indexPath = `cache:${pathStr}`;
229
+ child.origin = origin;
230
+ }
231
+
232
+ current = child;
233
+ }
234
+ };
235
+
236
+ /**
237
+ * Process a summon-* package.
238
+ *
239
+ * Imports the package's main entry and looks for a `generators` export
240
+ * mapping command paths to generator definitions.
241
+ */
242
+ const processPackage = async (
243
+ pkgName: string,
244
+ pkgDir: string,
245
+ root: GeneratorNode,
246
+ origin: GeneratorOrigin = "package",
247
+ ): Promise<void> => {
248
+ // Read package.json to get the main entry
249
+ const pkgJsonPath = path.join(pkgDir, "package.json");
250
+ let mainEntry: string | undefined;
251
+
252
+ try {
253
+ const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, "utf-8"));
254
+ mainEntry = pkgJson.main;
255
+ } catch {
256
+ return; // Can't read package.json
257
+ }
258
+
259
+ if (!mainEntry) return;
260
+
261
+ // Import the package's main entry
262
+ const entryPath = path.join(pkgDir, mainEntry);
263
+ try {
264
+ const module = await import(entryPath);
265
+ const generators =
266
+ module.generators ?? module.default ?? ({} as Record<string, unknown>);
267
+
268
+ // Insert each generator into the tree
269
+ for (const [cmdPath, generator] of Object.entries(generators)) {
270
+ if (generator && typeof generator === "object" && "meta" in generator) {
271
+ insertGeneratorAtPath(
272
+ root,
273
+ cmdPath,
274
+ generator as GeneratorDefinition,
275
+ origin,
276
+ );
277
+ }
278
+ }
279
+ } catch (err) {
280
+ console.error(
281
+ chalk.yellow(`Warning: Could not load generators from '${pkgName}':`),
282
+ (err as Error).message,
283
+ );
284
+ }
285
+ };
286
+
287
+ /**
288
+ * Get bun's global node_modules directory.
289
+ * Default is ~/.bun/install/global/node_modules
290
+ */
291
+ const getBunGlobalNodeModules = (): string => {
292
+ const bunInstallDir =
293
+ process.env.BUN_INSTALL ?? path.join(process.env.HOME ?? "~", ".bun");
294
+ return path.join(bunInstallDir, "install", "global", "node_modules");
295
+ };
296
+
297
+ /**
298
+ * Get npm's global node_modules directory.
299
+ * Uses `npm root -g` equivalent logic.
300
+ */
301
+ const getNpmGlobalNodeModules = async (): Promise<string | null> => {
302
+ // Check common npm global locations
303
+ const npmPrefix = process.env.NPM_CONFIG_PREFIX;
304
+ if (npmPrefix) {
305
+ return path.join(npmPrefix, "lib", "node_modules");
306
+ }
307
+
308
+ // Try NVM location
309
+ const nvmDir = process.env.NVM_DIR;
310
+ if (nvmDir) {
311
+ // NVM stores globals in the current node version's lib/node_modules
312
+ const nodeVersion = process.version;
313
+ const nvmNodeModules = path.join(
314
+ nvmDir,
315
+ "versions",
316
+ "node",
317
+ nodeVersion,
318
+ "lib",
319
+ "node_modules",
320
+ );
321
+ try {
322
+ await fs.access(nvmNodeModules);
323
+ return nvmNodeModules;
324
+ } catch {
325
+ // NVM path doesn't exist
326
+ }
327
+ }
328
+
329
+ // Fallback: check common system locations
330
+ const commonPaths = [
331
+ "/usr/local/lib/node_modules",
332
+ "/usr/lib/node_modules",
333
+ path.join(process.env.HOME ?? "~", ".npm-global", "lib", "node_modules"),
334
+ ];
335
+
336
+ for (const p of commonPaths) {
337
+ try {
338
+ await fs.access(p);
339
+ return p;
340
+ } catch {
341
+ // Path doesn't exist
342
+ }
343
+ }
344
+
345
+ return null;
346
+ };
347
+
348
+ /**
349
+ * Scan a node_modules directory for summon-* packages.
350
+ */
351
+ const scanNodeModulesForSummonPackages = async (
352
+ nodeModulesDir: string,
353
+ root: GeneratorNode,
354
+ origin: GeneratorOrigin = "global",
355
+ ): Promise<void> => {
356
+ try {
357
+ await fs.access(nodeModulesDir);
358
+ const entries = await fs.readdir(nodeModulesDir);
359
+
360
+ for (const entry of entries) {
361
+ const entryPath = path.join(nodeModulesDir, entry);
362
+
363
+ if (entry.startsWith("@")) {
364
+ // Scoped packages
365
+ if (!(await isDirectory(entryPath))) continue;
366
+ try {
367
+ const scopedEntries = await fs.readdir(entryPath);
368
+ for (const scopedEntry of scopedEntries) {
369
+ if (scopedEntry.startsWith("summon-")) {
370
+ const pkgDir = path.join(entryPath, scopedEntry);
371
+ if (await isDirectory(pkgDir)) {
372
+ await processPackage(
373
+ `${entry}/${scopedEntry}`,
374
+ pkgDir,
375
+ root,
376
+ origin,
377
+ );
378
+ }
379
+ }
380
+ }
381
+ } catch {
382
+ // Scope directory doesn't exist
383
+ }
384
+ } else if (entry.startsWith("summon-")) {
385
+ if (await isDirectory(entryPath)) {
386
+ await processPackage(entry, entryPath, root, origin);
387
+ }
388
+ }
389
+ }
390
+ } catch {
391
+ // Directory doesn't exist - that's fine
392
+ }
393
+ };
394
+
395
+ /**
396
+ * Discover globally installed summon-* packages.
397
+ * Looks in global package manager locations:
398
+ * 1. Bun global packages (~/.bun/install/global/node_modules)
399
+ * 2. NPM global packages (npm root -g)
400
+ *
401
+ * Users can link packages globally using:
402
+ * bun link # from the package directory
403
+ * npm link # from the package directory
404
+ */
405
+ const discoverGlobalPackages = async (root: GeneratorNode): Promise<void> => {
406
+ // 1. Bun global packages
407
+ const bunGlobalNodeModules = getBunGlobalNodeModules();
408
+ await scanNodeModulesForSummonPackages(bunGlobalNodeModules, root, "global");
409
+
410
+ // 2. NPM global packages
411
+ const npmGlobalNodeModules = await getNpmGlobalNodeModules();
412
+ if (npmGlobalNodeModules) {
413
+ await scanNodeModulesForSummonPackages(
414
+ npmGlobalNodeModules,
415
+ root,
416
+ "global",
417
+ );
418
+ }
419
+ };
420
+
421
+ /**
422
+ * Create the root generator tree from all sources.
423
+ *
424
+ * When `explicitPath` is provided, ONLY load from that path (for testing).
425
+ *
426
+ * Otherwise, priority (highest to lowest, later overrides earlier):
427
+ * 1. Built-in generators from @canonical/summon
428
+ * 2. Global packages (bun link / npm link locations)
429
+ * 3. Project ./node_modules/summon-* packages (highest priority)
430
+ */
431
+ const discoverGeneratorTree = async (
432
+ explicitPath?: string,
433
+ ): Promise<GeneratorNode> => {
434
+ const root: GeneratorNode = {
435
+ name: "root",
436
+ path: "",
437
+ children: new Map(),
438
+ };
439
+
440
+ if (explicitPath) {
441
+ // Explicit path mode: ONLY load from the specified path
442
+ const absolutePath = path.isAbsolute(explicitPath)
443
+ ? explicitPath
444
+ : path.join(process.cwd(), explicitPath);
445
+
446
+ // Check if it's a package with package.json (barrel export)
447
+ const pkgJsonPath = path.join(absolutePath, "package.json");
448
+ try {
449
+ await fs.access(pkgJsonPath);
450
+ // It's a package - use processPackage to load from barrel
451
+ await processPackage(
452
+ path.basename(absolutePath),
453
+ absolutePath,
454
+ root,
455
+ "local",
456
+ );
457
+ } catch {
458
+ // Not a package - scan directory for generators
459
+ await buildGeneratorTree(absolutePath, root, "local");
460
+ }
461
+ return root;
462
+ }
463
+
464
+ // Normal discovery mode (order matters: later sources override earlier)
465
+
466
+ // 1. Built-in generators (lowest priority)
467
+ await buildGeneratorTree(
468
+ path.join(__dirname, "..", "generators"),
469
+ root,
470
+ "builtin",
471
+ );
472
+
473
+ // 2-3. Global packages and generators
474
+ await discoverGlobalPackages(root);
475
+
476
+ // 3. node_modules packages (project-level, highest priority)
477
+ await discoverNodeModulesPackages(
478
+ path.join(process.cwd(), "node_modules"),
479
+ root,
480
+ );
481
+
482
+ return root;
483
+ };
484
+
485
+ /**
486
+ * Navigate the generator tree by path segments.
487
+ * Returns [node, remainingSegments] where node is as deep as we could go.
488
+ */
489
+ const _navigateTree = (
490
+ root: GeneratorNode,
491
+ segments: string[],
492
+ ): [GeneratorNode, string[]] => {
493
+ let current = root;
494
+ let i = 0;
495
+
496
+ for (; i < segments.length; i++) {
497
+ const child = current.children.get(segments[i]);
498
+ if (!child) break;
499
+ current = child;
500
+ }
501
+
502
+ return [current, segments.slice(i)];
503
+ };
504
+
505
+ /**
506
+ * Load a generator from a path or cache.
507
+ */
508
+ const loadGenerator = async (
509
+ generatorPath: string,
510
+ ): Promise<GeneratorDefinition> => {
511
+ // Check if this is a cached generator from a package barrel
512
+ if (generatorPath.startsWith("cache:")) {
513
+ const cacheKey = generatorPath.slice(6);
514
+ const cached = generatorCache.get(cacheKey);
515
+ if (cached) return cached;
516
+ throw new Error(`Generator not found in cache: ${cacheKey}`);
517
+ }
518
+
519
+ const module = await import(generatorPath);
520
+ const generator = module.default ?? module.generator;
521
+
522
+ if (!generator) {
523
+ throw new Error(
524
+ `No default export or 'generator' export found in ${generatorPath}`,
525
+ );
526
+ }
527
+
528
+ return generator as GeneratorDefinition;
529
+ };
530
+
531
+ // =============================================================================
532
+ // CLI Commands
533
+ // =============================================================================
534
+
535
+ const program = new Command();
536
+
537
+ // =============================================================================
538
+ // Display helpers
539
+ // =============================================================================
540
+
541
+ /**
542
+ * Format origin badge for display.
543
+ */
544
+ const formatOrigin = (origin?: GeneratorOrigin): string => {
545
+ switch (origin) {
546
+ case "local":
547
+ return chalk.green("[local]");
548
+ case "global":
549
+ return chalk.blue("[global]");
550
+ case "package":
551
+ return chalk.magenta("[pkg]");
552
+ case "builtin":
553
+ return chalk.dim("[builtin]");
554
+ default:
555
+ return "";
556
+ }
557
+ };
558
+
559
+ /**
560
+ * Print the available generators/sub-generators at a node.
561
+ */
562
+ const printNode = (node: GeneratorNode, pathSegments: string[]) => {
563
+ const prefix = pathSegments.length > 0 ? `${pathSegments.join(" ")} ` : "";
564
+ const isRoot = pathSegments.length === 0;
565
+
566
+ if (node.children.size === 0) {
567
+ console.log(chalk.yellow("No generators found"));
568
+ console.log(chalk.dim("\nInstall a generator package:"));
569
+ console.log(chalk.dim(" bun add @scope/summon-<name>"));
570
+ console.log(chalk.dim("\nOr link globally (from the package directory):"));
571
+ console.log(chalk.dim(" bun link # for bun users"));
572
+ console.log(chalk.dim(" npm link # for npm users"));
573
+ return;
574
+ }
575
+
576
+ if (isRoot) {
577
+ console.log(chalk.bold("\nAvailable topics:\n"));
578
+ } else {
579
+ console.log(chalk.bold(`\nAvailable under '${pathSegments.join(" ")}':\n`));
580
+ }
581
+
582
+ for (const [name, child] of node.children) {
583
+ const hasChildren = child.children.size > 0;
584
+ const isRunnable = !!child.indexPath;
585
+ const originBadge = child.origin ? ` ${formatOrigin(child.origin)}` : "";
586
+
587
+ let suffix = "";
588
+ if (hasChildren && isRunnable) {
589
+ suffix = chalk.dim(" (runnable, has subtopics)");
590
+ } else if (hasChildren) {
591
+ suffix = chalk.dim(" (has subtopics)");
592
+ }
593
+
594
+ console.log(chalk.cyan(` ${name}`) + originBadge + suffix);
595
+
596
+ // Show immediate children as hints
597
+ if (hasChildren) {
598
+ const childNames = [...child.children.keys()].slice(0, 5);
599
+ const more =
600
+ child.children.size > 5 ? `, +${child.children.size - 5} more` : "";
601
+ console.log(chalk.dim(` └─ ${childNames.join(", ")}${more}`));
602
+ }
603
+ }
604
+
605
+ console.log(chalk.dim(`\nUsage: summon ${prefix}<topic>`));
606
+ console.log();
607
+ };
608
+
609
+ /**
610
+ * Print detailed help for a generator (meta.help and examples).
611
+ */
612
+ const _printGeneratorHelp = async (
613
+ node: GeneratorNode,
614
+ pathSegments: string[],
615
+ ) => {
616
+ if (!node.indexPath) return;
617
+
618
+ try {
619
+ const generator = await loadGenerator(node.indexPath);
620
+ const { meta, prompts } = generator;
621
+ const commandPath = pathSegments.join(" ");
622
+
623
+ console.log();
624
+ console.log(chalk.bold.cyan(`summon ${commandPath}`));
625
+ console.log(chalk.dim(`v${meta.version}`));
626
+ console.log();
627
+ console.log(meta.description);
628
+
629
+ // Print extended help if available
630
+ if (meta.help) {
631
+ console.log();
632
+ console.log(meta.help);
633
+ }
634
+
635
+ // Print available options (from prompts)
636
+ if (prompts.length > 0) {
637
+ console.log();
638
+ console.log(chalk.bold("Options:"));
639
+ console.log();
640
+ for (const prompt of prompts) {
641
+ const flagName = `--${prompt.name}`;
642
+ const typeHint =
643
+ prompt.type === "confirm"
644
+ ? "[boolean]"
645
+ : prompt.type === "select"
646
+ ? `[${prompt.choices?.map((c) => c.value).join("|")}]`
647
+ : prompt.type === "multiselect"
648
+ ? "[value,value,...]"
649
+ : "<value>";
650
+
651
+ const defaultHint =
652
+ prompt.default !== undefined
653
+ ? chalk.dim(` (default: ${JSON.stringify(prompt.default)})`)
654
+ : "";
655
+
656
+ console.log(` ${chalk.cyan(flagName)} ${typeHint}${defaultHint}`);
657
+ console.log(` ${prompt.message}`);
658
+ }
659
+ }
660
+
661
+ // Print examples if available
662
+ if (meta.examples && meta.examples.length > 0) {
663
+ console.log();
664
+ console.log(chalk.bold("Examples:"));
665
+ console.log();
666
+ for (const example of meta.examples) {
667
+ console.log(` ${chalk.dim("$")} ${example}`);
668
+ }
669
+ }
670
+
671
+ // Print available subtopics if any
672
+ if (node.children.size > 0) {
673
+ console.log();
674
+ console.log(chalk.bold("Subtopics:"));
675
+ for (const [name, child] of node.children) {
676
+ const desc = child.meta?.description ?? "";
677
+ console.log(` ${chalk.cyan(name)}${desc ? ` - ${desc}` : ""}`);
678
+ }
679
+ }
680
+
681
+ console.log();
682
+ } catch {
683
+ // Couldn't load generator, fall back to basic node printing
684
+ printNode(node, pathSegments);
685
+ }
686
+ };
687
+
688
+ // =============================================================================
689
+ // Run generator helper
690
+ // =============================================================================
691
+
692
+ /**
693
+ * Check if all required prompts have answers.
694
+ */
695
+ const hasAllRequiredAnswers = (
696
+ prompts: GeneratorDefinition["prompts"],
697
+ answers: Record<string, unknown>,
698
+ ): boolean => {
699
+ for (const prompt of prompts) {
700
+ // Skip prompts with `when` conditions - they may be optional
701
+ if (prompt.when) continue;
702
+
703
+ // Check if we have an answer (including falsy values like false, 0, "")
704
+ if (!(prompt.name in answers) && prompt.default === undefined) {
705
+ return false;
706
+ }
707
+ }
708
+ return true;
709
+ };
710
+
711
+ /**
712
+ * Apply defaults for prompts that don't have answers.
713
+ */
714
+ const applyDefaults = (
715
+ prompts: GeneratorDefinition["prompts"],
716
+ answers: Record<string, unknown>,
717
+ ): Record<string, unknown> => {
718
+ const result = { ...answers };
719
+ for (const prompt of prompts) {
720
+ if (!(prompt.name in result) && prompt.default !== undefined) {
721
+ result[prompt.name] = prompt.default;
722
+ }
723
+ }
724
+ return result;
725
+ };
726
+
727
+ // =============================================================================
728
+ // CLI Setup
729
+ // =============================================================================
730
+
731
+ /**
732
+ * Build option info for a prompt (flags, description, default).
733
+ */
734
+ interface OptionInfo {
735
+ flags: string;
736
+ description: string;
737
+ defaultValue?: string;
738
+ group?: string;
739
+ /** The original camelCase prompt name */
740
+ promptName: string;
741
+ /** The kebab-case flag name (without --) */
742
+ kebabName: string;
743
+ }
744
+
745
+ /**
746
+ * Convert camelCase to kebab-case.
747
+ * withTests -> with-tests
748
+ * installDeps -> install-deps
749
+ */
750
+ const toKebabCase = (str: string): string =>
751
+ str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
752
+
753
+ /**
754
+ * Convert kebab-case to camelCase.
755
+ * with-tests -> withTests
756
+ * install-deps -> installDeps
757
+ */
758
+ const _toCamelCase = (str: string): string =>
759
+ str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
760
+
761
+ const buildOptionInfo = (prompt: PromptDefinition): OptionInfo => {
762
+ const kebabName = toKebabCase(prompt.name);
763
+ const flagName = `--${kebabName}`;
764
+
765
+ // NOTE: We intentionally do NOT pass defaultValue to Commander.
766
+ // Defaults are handled by applyDefaults() after prompting, so we can
767
+ // distinguish between "user didn't provide" vs "user provided default value".
768
+
769
+ switch (prompt.type) {
770
+ case "confirm": {
771
+ const defaultVal = prompt.default === true;
772
+ if (defaultVal) {
773
+ return {
774
+ flags: `--no-${kebabName}`,
775
+ description: `${prompt.message}`,
776
+ group: prompt.group,
777
+ promptName: prompt.name,
778
+ kebabName,
779
+ };
780
+ }
781
+ return {
782
+ flags: flagName,
783
+ description: `${prompt.message}`,
784
+ group: prompt.group,
785
+ promptName: prompt.name,
786
+ kebabName,
787
+ };
788
+ }
789
+ case "select": {
790
+ const choices = prompt.choices?.map((c) => c.value).join("|") ?? "";
791
+ return {
792
+ flags: `${flagName} <value>`,
793
+ description: `${prompt.message} [${choices}]`,
794
+ group: prompt.group,
795
+ promptName: prompt.name,
796
+ kebabName,
797
+ };
798
+ }
799
+ case "multiselect": {
800
+ return {
801
+ flags: `${flagName} <values>`,
802
+ description: `${prompt.message} (comma-separated)`,
803
+ group: prompt.group,
804
+ promptName: prompt.name,
805
+ kebabName,
806
+ };
807
+ }
808
+ default: {
809
+ return {
810
+ flags: `${flagName} <value>`,
811
+ description: `${prompt.message}`,
812
+ group: prompt.group,
813
+ promptName: prompt.name,
814
+ kebabName,
815
+ };
816
+ }
817
+ }
818
+ };
819
+
820
+ /**
821
+ * Add prompt-based options to a Commander command.
822
+ */
823
+ const addPromptOptions = (cmd: Command, prompts: PromptDefinition[]): void => {
824
+ for (const prompt of prompts) {
825
+ const info = buildOptionInfo(prompt);
826
+ cmd.option(info.flags, info.description, info.defaultValue);
827
+ }
828
+ };
829
+
830
+ /**
831
+ * Configure custom help with grouped options for a command.
832
+ */
833
+ const configureGroupedHelp = (
834
+ cmd: Command,
835
+ prompts: PromptDefinition[],
836
+ ): void => {
837
+ // Collect option info by group
838
+ const groups = new Map<string, OptionInfo[]>();
839
+ const defaultGroupName = "Generator Options";
840
+
841
+ for (const prompt of prompts) {
842
+ const info = buildOptionInfo(prompt);
843
+ const groupName = info.group ?? defaultGroupName;
844
+ if (!groups.has(groupName)) {
845
+ groups.set(groupName, []);
846
+ }
847
+ groups.get(groupName)?.push(info);
848
+ }
849
+
850
+ // Only configure custom help if there are grouped options
851
+ if (groups.size <= 1 && !prompts.some((p) => p.group)) {
852
+ return;
853
+ }
854
+
855
+ // Override help output using configureHelp
856
+ cmd.configureHelp({
857
+ formatHelp: (cmd, helper) => {
858
+ const termWidth = 28; // Fixed width for option flags column
859
+
860
+ const formatItem = (
861
+ term: string,
862
+ description: string,
863
+ defaultVal?: string,
864
+ ): string => {
865
+ const fullDesc = defaultVal
866
+ ? `${description} (default: ${JSON.stringify(defaultVal)})`
867
+ : description;
868
+
869
+ if (description) {
870
+ const padding = " ".repeat(Math.max(termWidth - term.length, 2));
871
+ return ` ${term}${padding}${fullDesc}`;
872
+ }
873
+ return ` ${term}`;
874
+ };
875
+
876
+ let output = "";
877
+
878
+ // Usage
879
+ output += `Usage: ${helper.commandUsage(cmd)}\n`;
880
+
881
+ // Description
882
+ const desc = helper.commandDescription(cmd);
883
+ if (desc) {
884
+ output += `\n${desc}\n`;
885
+ }
886
+
887
+ // Built-in options (Global Options group)
888
+ output += "\nGlobal Options:\n";
889
+ output += formatItem("-d, --dry-run", "Preview without writing files");
890
+ output += "\n";
891
+ output += formatItem("-y, --yes", "Skip confirmation prompts");
892
+ output += "\n";
893
+ output += formatItem("-v, --verbose", "Show debug output");
894
+ output += "\n";
895
+ output += formatItem("--no-preview", "Skip the file preview");
896
+ output += "\n";
897
+ output += formatItem(
898
+ "--no-generated-stamp",
899
+ "Disable generated file stamp comments",
900
+ );
901
+ output += "\n";
902
+ output += formatItem("-h, --help", "display help for command");
903
+ output += "\n";
904
+
905
+ // Grouped prompt options
906
+ for (const [groupName, options] of groups) {
907
+ output += `\n${groupName}:\n`;
908
+ for (const opt of options) {
909
+ output += formatItem(opt.flags, opt.description, opt.defaultValue);
910
+ output += "\n";
911
+ }
912
+ }
913
+
914
+ return output;
915
+ },
916
+ });
917
+ };
918
+
919
+ /**
920
+ * Extract answers from Commander options based on prompts.
921
+ * Commander converts kebab-case flags to camelCase option keys automatically,
922
+ * so --with-tests becomes options.withTests.
923
+ *
924
+ * IMPORTANT: For confirm (boolean) prompts with default: true, Commander's
925
+ * --no-X flag pattern means the option is ALWAYS present (true by default,
926
+ * false when --no-X is used). We skip these unless the value differs from
927
+ * the prompt's default, indicating the user explicitly used the flag.
928
+ */
929
+ const extractAnswers = (
930
+ options: Record<string, unknown>,
931
+ prompts: PromptDefinition[],
932
+ ): Record<string, unknown> => {
933
+ const answers: Record<string, unknown> = {};
934
+
935
+ for (const prompt of prompts) {
936
+ // Commander auto-converts kebab-case to camelCase, so we can use the prompt name directly
937
+ const value = options[prompt.name];
938
+
939
+ if (value !== undefined) {
940
+ switch (prompt.type) {
941
+ case "confirm": {
942
+ const boolValue = Boolean(value);
943
+ // For confirm prompts, Commander always sets a value due to --no-X pattern.
944
+ // Only include if the value differs from the prompt's default,
945
+ // which indicates the user explicitly used the flag.
946
+ if (boolValue !== prompt.default) {
947
+ answers[prompt.name] = boolValue;
948
+ }
949
+ break;
950
+ }
951
+ case "multiselect":
952
+ answers[prompt.name] =
953
+ typeof value === "string"
954
+ ? value.split(",").map((v) => v.trim())
955
+ : value;
956
+ break;
957
+ default:
958
+ answers[prompt.name] = value;
959
+ }
960
+ }
961
+ }
962
+
963
+ return answers;
964
+ };
965
+
966
+ /**
967
+ * Command Barrel - A flattened representation of all commands to register.
968
+ * This separates command discovery from registration, making the process cleaner.
969
+ */
970
+ interface CommandEntry {
971
+ /** Path segments to this command (e.g., ["component", "react"]) */
972
+ path: string[];
973
+ /** The generator definition if this is a runnable command */
974
+ generator?: GeneratorDefinition;
975
+ /** Description for namespace-only commands */
976
+ description?: string;
977
+ }
978
+
979
+ /**
980
+ * Build a command barrel from a generator tree.
981
+ * This flattens the tree into a list of commands, sorted by depth so parents are created first.
982
+ */
983
+ const buildCommandBarrel = async (
984
+ node: GeneratorNode,
985
+ pathSegments: string[] = [],
986
+ ): Promise<CommandEntry[]> => {
987
+ const entries: CommandEntry[] = [];
988
+
989
+ for (const [name, child] of node.children) {
990
+ const childPath = [...pathSegments, name];
991
+
992
+ if (child.indexPath) {
993
+ // Runnable generator
994
+ try {
995
+ const generator = await loadGenerator(child.indexPath);
996
+ entries.push({ path: childPath, generator });
997
+
998
+ // If it also has children, we need to ensure parent exists and recurse
999
+ if (child.children.size > 0) {
1000
+ const childEntries = await buildCommandBarrel(child, childPath);
1001
+ entries.push(...childEntries);
1002
+ }
1003
+ } catch (err) {
1004
+ console.error(
1005
+ chalk.yellow(`Warning: Could not load generator '${name}':`),
1006
+ (err as Error).message,
1007
+ );
1008
+ }
1009
+ } else if (child.children.size > 0) {
1010
+ // Namespace-only (no indexPath but has children)
1011
+ // Add a placeholder entry so we create the parent command
1012
+ entries.push({
1013
+ path: childPath,
1014
+ description: `${name} generators`,
1015
+ });
1016
+
1017
+ // Recurse into children
1018
+ const childEntries = await buildCommandBarrel(child, childPath);
1019
+ entries.push(...childEntries);
1020
+ }
1021
+ }
1022
+
1023
+ // Sort by path length so parents are registered before children
1024
+ return entries.sort((a, b) => a.path.length - b.path.length);
1025
+ };
1026
+
1027
+ /**
1028
+ * Register all commands from a command barrel.
1029
+ * Commands are registered in order (parents before children) using a map to track created commands.
1030
+ */
1031
+ const registerFromBarrel = (rootCmd: Command, barrel: CommandEntry[]): void => {
1032
+ const commandMap = new Map<string, Command>();
1033
+ commandMap.set("", rootCmd);
1034
+
1035
+ for (const entry of barrel) {
1036
+ const name = entry.path[entry.path.length - 1];
1037
+ const parentPath = entry.path.slice(0, -1).join("/");
1038
+ const currentPath = entry.path.join("/");
1039
+
1040
+ // Skip if already registered (can happen with namespace + runnable at same path)
1041
+ const existingCmd = commandMap.get(currentPath);
1042
+ if (existingCmd) {
1043
+ // But if we now have a generator, update the command
1044
+ if (entry.generator) {
1045
+ configureGeneratorCommand(existingCmd, entry.generator);
1046
+ }
1047
+ continue;
1048
+ }
1049
+
1050
+ // Get or create parent command
1051
+ const parentCmd = commandMap.get(parentPath) ?? rootCmd;
1052
+
1053
+ if (entry.generator) {
1054
+ // Check if there's a positional prompt
1055
+ const positionalPrompt = entry.generator.prompts.find(
1056
+ (p) => p.positional,
1057
+ );
1058
+
1059
+ // Create runnable generator command
1060
+ // If there's a positional prompt, add it as an optional argument
1061
+ const commandSpec = positionalPrompt
1062
+ ? `${name} [${toKebabCase(positionalPrompt.name)}]`
1063
+ : name;
1064
+
1065
+ const cmd = parentCmd
1066
+ .command(commandSpec)
1067
+ .description(entry.generator.meta.description)
1068
+ .option("-d, --dry-run", "Preview without writing files")
1069
+ .option("-y, --yes", "Skip confirmation prompts")
1070
+ .option("-v, --verbose", "Show debug output")
1071
+ .option("--no-preview", "Skip the file preview")
1072
+ .option(
1073
+ "--no-generated-stamp",
1074
+ "Disable generated file stamp comments",
1075
+ );
1076
+
1077
+ // Set custom usage to show positional arg before [options]
1078
+ if (positionalPrompt) {
1079
+ const positionalName = toKebabCase(positionalPrompt.name);
1080
+ cmd.usage(`[${positionalName}] [options]`);
1081
+ }
1082
+
1083
+ configureGeneratorCommand(cmd, entry.generator, positionalPrompt);
1084
+ commandMap.set(currentPath, cmd);
1085
+ } else {
1086
+ // Create namespace-only command (just a container for subcommands)
1087
+ const cmd = parentCmd
1088
+ .command(name)
1089
+ .description(entry.description ?? `${name} commands`);
1090
+
1091
+ commandMap.set(currentPath, cmd);
1092
+ }
1093
+ }
1094
+ };
1095
+
1096
+ /**
1097
+ * Configure a command with generator options and action.
1098
+ */
1099
+ const configureGeneratorCommand = (
1100
+ cmd: Command,
1101
+ generator: GeneratorDefinition,
1102
+ positionalPrompt?: PromptDefinition,
1103
+ ): void => {
1104
+ // Add prompt-based options
1105
+ addPromptOptions(cmd, generator.prompts);
1106
+
1107
+ // Configure grouped help display
1108
+ configureGroupedHelp(cmd, generator.prompts);
1109
+
1110
+ // Add action
1111
+ // If there's a positional prompt, the first argument is the positional value
1112
+ cmd.action(
1113
+ async (
1114
+ positionalArg: string | Record<string, unknown> | undefined,
1115
+ cmdOptions?: Record<string, unknown>,
1116
+ ) => {
1117
+ // Commander passes different argument patterns:
1118
+ // - No positional defined: (options)
1119
+ // - Positional defined, value provided: (positionalValue, options)
1120
+ // - Positional defined, no value: (undefined, options)
1121
+ let actualOptions: Record<string, unknown>;
1122
+
1123
+ if (positionalPrompt) {
1124
+ // When there's a positional prompt, cmdOptions is always the second arg
1125
+ actualOptions = cmdOptions ?? {};
1126
+ } else {
1127
+ // When there's no positional prompt, the first arg is options
1128
+ actualOptions = (positionalArg as Record<string, unknown>) ?? {};
1129
+ }
1130
+
1131
+ // Extract only explicitly provided CLI answers (not defaults)
1132
+ const cliAnswers = extractAnswers(actualOptions, generator.prompts);
1133
+
1134
+ // If positional argument was provided, add it to the answers
1135
+ if (
1136
+ positionalPrompt &&
1137
+ typeof positionalArg === "string" &&
1138
+ positionalArg
1139
+ ) {
1140
+ cliAnswers[positionalPrompt.name] = positionalArg;
1141
+ }
1142
+
1143
+ // Apply defaults for checking if we have all required answers
1144
+ const answersWithDefaults = applyDefaults(generator.prompts, cliAnswers);
1145
+
1146
+ // Determine execution mode
1147
+ const hasAllAnswers = hasAllRequiredAnswers(
1148
+ generator.prompts,
1149
+ answersWithDefaults,
1150
+ );
1151
+ const isTTY = process.stdin.isTTY === true;
1152
+ const skipPrompts = actualOptions.yes === true;
1153
+
1154
+ // Build stamp config if stamps are enabled (default: enabled)
1155
+ // Commander's --no-X pattern sets generatedStamp to false when --no-generated-stamp is used
1156
+ const stampEnabled = actualOptions.generatedStamp !== false;
1157
+ const stamp: StampConfig | undefined = stampEnabled
1158
+ ? {
1159
+ generator: generator.meta.name,
1160
+ version: generator.meta.version,
1161
+ }
1162
+ : undefined;
1163
+
1164
+ if (hasAllAnswers && actualOptions.dryRun && !isTTY) {
1165
+ // Batch dry-run mode (non-interactive)
1166
+ const { dryRun } = await import("./dry-run.js");
1167
+ const { isVisibleEffect, formatEffectLine } = await import(
1168
+ "./cli-format.js"
1169
+ );
1170
+
1171
+ const verbose = actualOptions.verbose === true;
1172
+
1173
+ console.log();
1174
+ console.log(chalk.bold.magenta(generator.meta.name));
1175
+ console.log(chalk.dim(generator.meta.description));
1176
+ console.log();
1177
+
1178
+ const task = generator.generate(answersWithDefaults);
1179
+ const result = dryRun(task);
1180
+
1181
+ // Filter and deduplicate effects
1182
+ const seenDirPaths = new Set<string>();
1183
+ const visibleEffects = result.effects.filter((e) => {
1184
+ if (!isVisibleEffect(e, verbose)) return false;
1185
+ if (e._tag === "MakeDir") {
1186
+ if (seenDirPaths.has(e.path)) return false;
1187
+ seenDirPaths.add(e.path);
1188
+ }
1189
+ return true;
1190
+ });
1191
+
1192
+ console.log(chalk.dim.bold("Plan:"));
1193
+ visibleEffects.forEach((effect, index) => {
1194
+ const isLast = index === visibleEffects.length - 1;
1195
+ console.log(formatEffectLine(effect, isLast));
1196
+ });
1197
+
1198
+ console.log();
1199
+ console.log(chalk.dim("Dry-run complete. No files were modified."));
1200
+ } else {
1201
+ // Interactive mode
1202
+ // Only pass answers if:
1203
+ // 1. User explicitly used --yes to skip prompts, OR
1204
+ // 2. User provided CLI arguments (not just defaults)
1205
+ const shouldSkipPrompts =
1206
+ skipPrompts || Object.keys(cliAnswers).length > 0;
1207
+ const passedAnswers = shouldSkipPrompts
1208
+ ? answersWithDefaults
1209
+ : undefined;
1210
+
1211
+ const { waitUntilExit } = render(
1212
+ <App
1213
+ generator={generator}
1214
+ preview={actualOptions.preview as boolean}
1215
+ dryRunOnly={actualOptions.dryRun as boolean}
1216
+ verbose={actualOptions.verbose as boolean}
1217
+ answers={passedAnswers}
1218
+ stamp={stamp}
1219
+ />,
1220
+ );
1221
+
1222
+ await waitUntilExit();
1223
+ }
1224
+ },
1225
+ );
1226
+ };
1227
+
1228
+ /**
1229
+ * Register all generator commands from the tree.
1230
+ * Uses the barrel pattern: build flat list → sort by depth → register in order.
1231
+ */
1232
+ const registerGeneratorCommands = async (
1233
+ parentCmd: Command,
1234
+ node: GeneratorNode,
1235
+ _pathSegments: string[],
1236
+ ): Promise<void> => {
1237
+ const barrel = await buildCommandBarrel(node);
1238
+ registerFromBarrel(parentCmd, barrel);
1239
+ };
1240
+
1241
+ /**
1242
+ * Convert GeneratorNode to CompletionNode for the completion module.
1243
+ */
1244
+ const toCompletionNode = (node: GeneratorNode): CompletionNode => ({
1245
+ name: node.name,
1246
+ indexPath: node.indexPath,
1247
+ children: new Map(
1248
+ [...node.children.entries()].map(([name, child]) => [
1249
+ name,
1250
+ toCompletionNode(child),
1251
+ ]),
1252
+ ),
1253
+ });
1254
+
1255
+ // Main CLI setup
1256
+ const main = async () => {
1257
+ // Get generators path from args early (before full parse)
1258
+ const generatorsIdx = process.argv.indexOf("--generators");
1259
+ const gIdx = process.argv.indexOf("-g");
1260
+ const generatorsPath =
1261
+ generatorsIdx !== -1
1262
+ ? process.argv[generatorsIdx + 1]
1263
+ : gIdx !== -1
1264
+ ? process.argv[gIdx + 1]
1265
+ : undefined;
1266
+
1267
+ // Discover generators (needed for both completion and normal CLI)
1268
+ const root = await discoverGeneratorTree(generatorsPath);
1269
+
1270
+ // Initialize shell completion (must happen before commander parsing)
1271
+ // This intercepts --completion, --completion-fish, etc.
1272
+ if (isCompletionRequest() || isSetupRequest()) {
1273
+ const completionTree = toCompletionNode(root);
1274
+ const complete = await initCompletion(completionTree, loadGenerator);
1275
+
1276
+ // Handle setup/cleanup requests
1277
+ if (isSetupRequest()) {
1278
+ handleSetupRequest(complete);
1279
+ return; // handleSetupRequest calls process.exit
1280
+ }
1281
+
1282
+ // For completion requests, omelette handles everything via init()
1283
+ // We just need to exit cleanly
1284
+ return;
1285
+ }
1286
+
1287
+ program
1288
+ .name("summon")
1289
+ .description("A monadic task-centric code generator framework")
1290
+ .version("0.1.0")
1291
+ .option(
1292
+ "-g, --generators <path>",
1293
+ "Load generators ONLY from this path (for testing)",
1294
+ )
1295
+ .option("--setup-completion", "Install shell autocompletion")
1296
+ .option("--cleanup-completion", "Remove shell autocompletion");
1297
+
1298
+ // If no arguments or just help, show available topics
1299
+ if (process.argv.length === 2 || process.argv.includes("--help")) {
1300
+ if (process.argv.length === 2) {
1301
+ printNode(root, []);
1302
+ console.log(
1303
+ chalk.dim(
1304
+ "Tip: Run 'summon --setup-completion' to enable TAB completion\n",
1305
+ ),
1306
+ );
1307
+ return;
1308
+ }
1309
+ }
1310
+
1311
+ // Register generator commands dynamically
1312
+ await registerGeneratorCommands(program, root, []);
1313
+
1314
+ // Parse and execute
1315
+ program.parse();
1316
+ };
1317
+
1318
+ // Run main
1319
+ main().catch((err) => {
1320
+ console.error(chalk.red("Error:"), err.message);
1321
+ process.exit(1);
1322
+ });