@wundr.io/cli 1.0.11 → 1.0.13

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 (180) hide show
  1. package/bin/wundr.js +8 -4
  2. package/dist/ai/ai-service.d.ts.map +1 -1
  3. package/dist/ai/ai-service.js.map +1 -1
  4. package/dist/ai/claude-client.js.map +1 -1
  5. package/dist/ai/conversation-manager.js.map +1 -1
  6. package/dist/commands/ai.d.ts.map +1 -1
  7. package/dist/commands/ai.js +179 -24
  8. package/dist/commands/ai.js.map +1 -1
  9. package/dist/commands/analyze-optimized.d.ts.map +1 -1
  10. package/dist/commands/analyze-optimized.js +15 -6
  11. package/dist/commands/analyze-optimized.js.map +1 -1
  12. package/dist/commands/batch.d.ts +22 -0
  13. package/dist/commands/batch.d.ts.map +1 -1
  14. package/dist/commands/batch.js +130 -14
  15. package/dist/commands/batch.js.map +1 -1
  16. package/dist/commands/chat.d.ts +1 -0
  17. package/dist/commands/chat.d.ts.map +1 -1
  18. package/dist/commands/chat.js +7 -3
  19. package/dist/commands/chat.js.map +1 -1
  20. package/dist/commands/claude-init.d.ts +1 -1
  21. package/dist/commands/claude-init.d.ts.map +1 -1
  22. package/dist/commands/claude-init.js +16 -16
  23. package/dist/commands/claude-init.js.map +1 -1
  24. package/dist/commands/claude-setup.d.ts +5 -5
  25. package/dist/commands/claude-setup.d.ts.map +1 -1
  26. package/dist/commands/claude-setup.js +65 -59
  27. package/dist/commands/claude-setup.js.map +1 -1
  28. package/dist/commands/computer-setup.d.ts +1 -0
  29. package/dist/commands/computer-setup.d.ts.map +1 -1
  30. package/dist/commands/computer-setup.js +35 -7
  31. package/dist/commands/computer-setup.js.map +1 -1
  32. package/dist/commands/dashboard.js.map +1 -1
  33. package/dist/commands/govern.js.map +1 -1
  34. package/dist/commands/init.d.ts.map +1 -1
  35. package/dist/commands/init.js +3 -3
  36. package/dist/commands/init.js.map +1 -1
  37. package/dist/commands/orchestrator.d.ts.map +1 -1
  38. package/dist/commands/orchestrator.js +11 -4
  39. package/dist/commands/orchestrator.js.map +1 -1
  40. package/dist/commands/performance-optimizer.d.ts.map +1 -1
  41. package/dist/commands/performance-optimizer.js.map +1 -1
  42. package/dist/commands/rag.d.ts.map +1 -1
  43. package/dist/commands/rag.js +9 -6
  44. package/dist/commands/rag.js.map +1 -1
  45. package/dist/commands/setup.d.ts +5 -10
  46. package/dist/commands/setup.d.ts.map +1 -1
  47. package/dist/commands/setup.js +35 -260
  48. package/dist/commands/setup.js.map +1 -1
  49. package/dist/commands/watch.d.ts.map +1 -1
  50. package/dist/commands/watch.js.map +1 -1
  51. package/dist/context/session-manager.js.map +1 -1
  52. package/dist/framework/command-interface.d.ts +349 -0
  53. package/dist/framework/command-interface.d.ts.map +1 -0
  54. package/dist/framework/command-interface.js +101 -0
  55. package/dist/framework/command-interface.js.map +1 -0
  56. package/dist/framework/command-registry.d.ts +173 -0
  57. package/dist/framework/command-registry.d.ts.map +1 -0
  58. package/dist/framework/command-registry.js +734 -0
  59. package/dist/framework/command-registry.js.map +1 -0
  60. package/dist/framework/completion-exporter.d.ts +79 -0
  61. package/dist/framework/completion-exporter.d.ts.map +1 -0
  62. package/dist/framework/completion-exporter.js +259 -0
  63. package/dist/framework/completion-exporter.js.map +1 -0
  64. package/dist/framework/debug-logger.d.ts +163 -0
  65. package/dist/framework/debug-logger.d.ts.map +1 -0
  66. package/dist/framework/debug-logger.js +373 -0
  67. package/dist/framework/debug-logger.js.map +1 -0
  68. package/dist/framework/error-handler.d.ts +196 -0
  69. package/dist/framework/error-handler.d.ts.map +1 -0
  70. package/dist/framework/error-handler.js +613 -0
  71. package/dist/framework/error-handler.js.map +1 -0
  72. package/dist/framework/help-generator.d.ts +78 -0
  73. package/dist/framework/help-generator.d.ts.map +1 -0
  74. package/dist/framework/help-generator.js +414 -0
  75. package/dist/framework/help-generator.js.map +1 -0
  76. package/dist/framework/index.d.ts +62 -0
  77. package/dist/framework/index.d.ts.map +1 -0
  78. package/dist/framework/index.js +95 -0
  79. package/dist/framework/index.js.map +1 -0
  80. package/dist/framework/interactive-repl.d.ts +138 -0
  81. package/dist/framework/interactive-repl.d.ts.map +1 -0
  82. package/dist/framework/interactive-repl.js +567 -0
  83. package/dist/framework/interactive-repl.js.map +1 -0
  84. package/dist/framework/output-formatter.d.ts +274 -0
  85. package/dist/framework/output-formatter.d.ts.map +1 -0
  86. package/dist/framework/output-formatter.js +545 -0
  87. package/dist/framework/output-formatter.js.map +1 -0
  88. package/dist/framework/progress-manager.d.ts +192 -0
  89. package/dist/framework/progress-manager.d.ts.map +1 -0
  90. package/dist/framework/progress-manager.js +408 -0
  91. package/dist/framework/progress-manager.js.map +1 -0
  92. package/dist/interactive/interactive-mode.js.map +1 -1
  93. package/dist/nlp/command-mapper.js.map +1 -1
  94. package/dist/nlp/command-parser.js.map +1 -1
  95. package/dist/nlp/intent-parser.d.ts.map +1 -1
  96. package/dist/nlp/intent-parser.js +4 -2
  97. package/dist/nlp/intent-parser.js.map +1 -1
  98. package/dist/plugins/plugin-manager.d.ts +2 -1
  99. package/dist/plugins/plugin-manager.d.ts.map +1 -1
  100. package/dist/plugins/plugin-manager.js +30 -19
  101. package/dist/plugins/plugin-manager.js.map +1 -1
  102. package/dist/utils/backup-rollback-manager.d.ts.map +1 -1
  103. package/dist/utils/backup-rollback-manager.js +1 -2
  104. package/dist/utils/backup-rollback-manager.js.map +1 -1
  105. package/dist/utils/logger.js.map +1 -1
  106. package/package.json +6 -6
  107. package/src/ai/ai-service.ts +16 -17
  108. package/src/ai/claude-client.ts +16 -16
  109. package/src/ai/conversation-manager.ts +29 -29
  110. package/src/cli.ts +4 -4
  111. package/src/commands/ai.ts +246 -78
  112. package/src/commands/alignment.ts +74 -74
  113. package/src/commands/analyze-optimized.ts +111 -78
  114. package/src/commands/analyze.ts +14 -14
  115. package/src/commands/batch.ts +179 -42
  116. package/src/commands/chat.ts +37 -30
  117. package/src/commands/claude-init.ts +41 -45
  118. package/src/commands/claude-setup.ts +204 -119
  119. package/src/commands/computer-setup.ts +85 -43
  120. package/src/commands/create-command.ts +4 -4
  121. package/src/commands/create.ts +27 -27
  122. package/src/commands/dashboard.ts +24 -24
  123. package/src/commands/govern.ts +25 -25
  124. package/src/commands/governance.ts +34 -34
  125. package/src/commands/guardian.ts +56 -56
  126. package/src/commands/init.ts +25 -22
  127. package/src/commands/orchestrator.ts +68 -41
  128. package/src/commands/performance-optimizer.ts +34 -35
  129. package/src/commands/plugins.ts +27 -27
  130. package/src/commands/project-update.ts +175 -72
  131. package/src/commands/rag.ts +185 -78
  132. package/src/commands/session.ts +35 -35
  133. package/src/commands/setup.ts +40 -344
  134. package/src/commands/test-init.ts +3 -3
  135. package/src/commands/test.ts +4 -4
  136. package/src/commands/watch.ts +28 -29
  137. package/src/commands/worktree.ts +49 -49
  138. package/src/context/context-manager.ts +10 -10
  139. package/src/context/session-manager.ts +41 -41
  140. package/src/framework/command-interface.ts +520 -0
  141. package/src/framework/command-registry.ts +942 -0
  142. package/src/framework/completion-exporter.ts +383 -0
  143. package/src/framework/debug-logger.ts +519 -0
  144. package/src/framework/error-handler.ts +867 -0
  145. package/src/framework/help-generator.ts +540 -0
  146. package/src/framework/index.ts +169 -0
  147. package/src/framework/interactive-repl.ts +703 -0
  148. package/src/framework/output-formatter.ts +834 -0
  149. package/src/framework/progress-manager.ts +539 -0
  150. package/src/index.ts +4 -4
  151. package/src/interactive/interactive-mode.ts +16 -16
  152. package/src/lib/conflict-resolution.ts +799 -9
  153. package/src/lib/merge-strategy.ts +529 -7
  154. package/src/lib/safety-mechanisms.ts +422 -18
  155. package/src/lib/state-detection.ts +1015 -13
  156. package/src/nlp/command-mapper.ts +29 -29
  157. package/src/nlp/command-parser.ts +17 -17
  158. package/src/nlp/intent-classifier.ts +7 -7
  159. package/src/nlp/intent-parser.ts +54 -52
  160. package/src/plugins/plugin-manager.ts +61 -39
  161. package/src/tests/computer-setup-integration.test.ts +46 -15
  162. package/src/types/modules.d.ts +424 -1
  163. package/src/utils/backup-rollback-manager.ts +11 -8
  164. package/src/utils/config-manager.ts +3 -3
  165. package/src/utils/error-handler.ts +2 -2
  166. package/src/utils/logger.ts +22 -22
  167. package/templates/batch/ci-cd.yaml +7 -7
  168. package/test-suites/api/health.spec.ts +20 -23
  169. package/test-suites/helpers/test-config.ts +14 -13
  170. package/test-suites/ui/accessibility.spec.ts +27 -22
  171. package/test-suites/ui/smoke.spec.ts +26 -21
  172. package/dist/commands/computer-setup-commands.d.ts +0 -53
  173. package/dist/commands/computer-setup-commands.d.ts.map +0 -1
  174. package/dist/commands/computer-setup-commands.js +0 -705
  175. package/dist/commands/computer-setup-commands.js.map +0 -1
  176. package/dist/commands/vp.d.ts +0 -7
  177. package/dist/commands/vp.d.ts.map +0 -1
  178. package/dist/commands/vp.js +0 -571
  179. package/dist/commands/vp.js.map +0 -1
  180. package/src/commands/computer-setup-commands.ts +0 -872
@@ -0,0 +1,942 @@
1
+ /**
2
+ * Command Registry - Auto-discovery, registration, and Commander.js integration.
3
+ *
4
+ * The registry is the central hub that:
5
+ * 1. Discovers CommandDefinition files from the commands/ directory
6
+ * 2. Validates definitions at registration time
7
+ * 3. Builds a Commander.js program from registered definitions
8
+ * 4. Provides lookup for shell completion generation
9
+ * 5. Supports legacy command wrapping for incremental migration
10
+ *
11
+ * @module framework/command-registry
12
+ */
13
+
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+
17
+ import chalk from 'chalk';
18
+ import { Command } from 'commander';
19
+
20
+ import type {
21
+ CommandDefinition,
22
+ CommandModule,
23
+ CommandHook,
24
+ CommandCategory,
25
+ CommandContext,
26
+ CommandResult,
27
+ ValidationResult,
28
+ GlobalOptions,
29
+ } from './command-interface';
30
+ import { validationOk } from './command-interface';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Options for the command registry.
38
+ */
39
+ export interface RegistryOptions {
40
+ /**
41
+ * Whether to enable strict mode.
42
+ * In strict mode, duplicate command names cause an error.
43
+ * In non-strict mode, the later registration wins with a warning.
44
+ */
45
+ strict?: boolean;
46
+
47
+ /**
48
+ * Base directory for auto-discovery.
49
+ * Defaults to the `commands/` directory relative to the framework.
50
+ */
51
+ commandsDir?: string;
52
+
53
+ /**
54
+ * File pattern for auto-discovery.
55
+ * Defaults to files ending in `.command.ts` or `.command.js`.
56
+ * Set to `*` to discover all .ts/.js files.
57
+ */
58
+ filePattern?: RegExp;
59
+ }
60
+
61
+ /**
62
+ * Metadata about a registered command for introspection.
63
+ */
64
+ export interface RegisteredCommand {
65
+ definition: CommandDefinition;
66
+ hooks: CommandHook[];
67
+ registeredAt: Date;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Command Registry
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export class CommandRegistry {
75
+ private commands: Map<string, RegisteredCommand> = new Map();
76
+ private globalHooks: CommandHook[] = [];
77
+ private options: Required<RegistryOptions>;
78
+
79
+ constructor(options: RegistryOptions = {}) {
80
+ this.options = {
81
+ strict: options.strict ?? false,
82
+ commandsDir:
83
+ options.commandsDir ?? path.join(__dirname, '..', 'commands'),
84
+ filePattern: options.filePattern ?? /\.(command)\.(ts|js)$/,
85
+ };
86
+ }
87
+
88
+ // -------------------------------------------------------------------------
89
+ // Registration
90
+ // -------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Register a single command definition.
94
+ *
95
+ * @param definition - The command to register
96
+ * @param hooks - Optional lifecycle hooks for this command
97
+ * @throws Error in strict mode if command name is already registered
98
+ */
99
+ register(definition: CommandDefinition, hooks: CommandHook[] = []): void {
100
+ const name = definition.name;
101
+
102
+ if (this.commands.has(name)) {
103
+ if (this.options.strict) {
104
+ throw new Error(
105
+ `Command "${name}" is already registered. ` +
106
+ `Disable strict mode or use a different name.`
107
+ );
108
+ }
109
+ // Non-strict: warn and overwrite
110
+ console.warn(
111
+ chalk.yellow(`[registry] Overwriting existing command: ${name}`)
112
+ );
113
+ }
114
+
115
+ // Validate the definition at registration time
116
+ this.validateDefinition(definition);
117
+
118
+ this.commands.set(name, {
119
+ definition,
120
+ hooks,
121
+ registeredAt: new Date(),
122
+ });
123
+
124
+ // Recursively register subcommands
125
+ if (definition.subcommands) {
126
+ for (const sub of definition.subcommands) {
127
+ // Prefix subcommand names with parent for flat lookup
128
+ const qualifiedName = `${name}:${sub.name}`;
129
+ const qualifiedSub = { ...sub, name: qualifiedName };
130
+ this.register(qualifiedSub, hooks);
131
+ }
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Register a command module (definition + hooks bundle).
137
+ */
138
+ registerModule(mod: CommandModule): void {
139
+ this.register(mod.command, mod.hooks);
140
+ }
141
+
142
+ /**
143
+ * Register a global hook that runs for all commands.
144
+ */
145
+ registerGlobalHook(hook: CommandHook): void {
146
+ this.globalHooks.push(hook);
147
+ }
148
+
149
+ /**
150
+ * Remove a registered command.
151
+ */
152
+ unregister(name: string): boolean {
153
+ return this.commands.delete(name);
154
+ }
155
+
156
+ // -------------------------------------------------------------------------
157
+ // Auto-Discovery
158
+ // -------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Discover and register commands from a directory.
162
+ *
163
+ * Scans the directory for files matching the configured pattern.
164
+ * Each file should export either:
165
+ * - A `module` property conforming to `CommandModule`
166
+ * - A `command` property conforming to `CommandDefinition`
167
+ * - A default export conforming to `CommandDefinition`
168
+ *
169
+ * @param directory - Directory to scan. Defaults to configured commandsDir.
170
+ * @returns Number of commands discovered and registered.
171
+ */
172
+ async discoverCommands(directory?: string): Promise<number> {
173
+ const dir = directory ?? this.options.commandsDir;
174
+ let count = 0;
175
+
176
+ if (!fs.existsSync(dir)) {
177
+ console.warn(
178
+ chalk.yellow(`[registry] Commands directory not found: ${dir}`)
179
+ );
180
+ return 0;
181
+ }
182
+
183
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
184
+
185
+ for (const entry of entries) {
186
+ if (!entry.isFile()) {
187
+ continue;
188
+ }
189
+
190
+ // Check file pattern
191
+ if (!this.options.filePattern.test(entry.name)) {
192
+ continue;
193
+ }
194
+
195
+ const filePath = path.join(dir, entry.name);
196
+
197
+ try {
198
+ const exported = await this.loadModule(filePath);
199
+
200
+ if (exported.module && typeof exported.module === 'object') {
201
+ // CommandModule export
202
+ const mod = exported.module as CommandModule;
203
+ if (
204
+ mod.command &&
205
+ mod.command.name &&
206
+ typeof mod.command.execute === 'function'
207
+ ) {
208
+ this.registerModule(mod);
209
+ count++;
210
+ }
211
+ } else if (exported.command && typeof exported.command === 'object') {
212
+ // Direct CommandDefinition export
213
+ const def = exported.command as CommandDefinition;
214
+ if (def.name && typeof def.execute === 'function') {
215
+ this.register(def);
216
+ count++;
217
+ }
218
+ } else if (exported.default && typeof exported.default === 'object') {
219
+ // Default export
220
+ const def = exported.default as CommandDefinition;
221
+ if (def.name && typeof def.execute === 'function') {
222
+ this.register(def);
223
+ count++;
224
+ }
225
+ }
226
+ } catch (error) {
227
+ console.warn(
228
+ chalk.yellow(
229
+ `[registry] Failed to load command from ${entry.name}: ` +
230
+ `${error instanceof Error ? error.message : String(error)}`
231
+ )
232
+ );
233
+ }
234
+ }
235
+
236
+ return count;
237
+ }
238
+
239
+ // -------------------------------------------------------------------------
240
+ // Multi-Directory Discovery
241
+ // -------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Discover commands from multiple directories.
245
+ *
246
+ * @param directories - Array of directory paths to scan
247
+ * @returns Total number of commands discovered
248
+ */
249
+ async discoverFromDirectories(directories: string[]): Promise<number> {
250
+ let total = 0;
251
+ for (const dir of directories) {
252
+ total += await this.discoverCommands(dir);
253
+ }
254
+ return total;
255
+ }
256
+
257
+ // -------------------------------------------------------------------------
258
+ // Lookup
259
+ // -------------------------------------------------------------------------
260
+
261
+ /**
262
+ * Get a registered command by name.
263
+ */
264
+ get(name: string): CommandDefinition | undefined {
265
+ return this.commands.get(name)?.definition;
266
+ }
267
+
268
+ /**
269
+ * Find a command by name or alias.
270
+ * Searches direct name first, then aliases.
271
+ */
272
+ findByNameOrAlias(nameOrAlias: string): CommandDefinition | undefined {
273
+ // Direct name lookup
274
+ const direct = this.commands.get(nameOrAlias);
275
+ if (direct) return direct.definition;
276
+
277
+ // Search aliases
278
+ for (const registered of Array.from(this.commands.values())) {
279
+ const { definition } = registered;
280
+ if (definition.aliases && definition.aliases.includes(nameOrAlias)) {
281
+ return definition;
282
+ }
283
+ }
284
+
285
+ return undefined;
286
+ }
287
+
288
+ /**
289
+ * Check if a command is registered.
290
+ */
291
+ has(name: string): boolean {
292
+ return this.commands.has(name);
293
+ }
294
+
295
+ /**
296
+ * List all registered commands, optionally filtered by category.
297
+ */
298
+ list(category?: CommandCategory): CommandDefinition[] {
299
+ const all = Array.from(this.commands.values()).map(r => r.definition);
300
+
301
+ if (category) {
302
+ return all.filter(cmd => cmd.category === category);
303
+ }
304
+
305
+ return all;
306
+ }
307
+
308
+ /**
309
+ * List all registered command names.
310
+ */
311
+ names(): string[] {
312
+ return Array.from(this.commands.keys());
313
+ }
314
+
315
+ /**
316
+ * Get commands grouped by category.
317
+ */
318
+ grouped(): Map<CommandCategory | 'uncategorized', CommandDefinition[]> {
319
+ const groups = new Map<
320
+ CommandCategory | 'uncategorized',
321
+ CommandDefinition[]
322
+ >();
323
+
324
+ for (const registered of Array.from(this.commands.values())) {
325
+ const cat = registered.definition.category ?? 'uncategorized';
326
+ const existing = groups.get(cat) ?? [];
327
+ existing.push(registered.definition);
328
+ groups.set(cat, existing);
329
+ }
330
+
331
+ return groups;
332
+ }
333
+
334
+ /**
335
+ * Get all command names for shell completion.
336
+ */
337
+ getCompletionWords(): string[] {
338
+ const words: string[] = [];
339
+
340
+ for (const registered of Array.from(this.commands.values())) {
341
+ if (registered.definition.hidden) {
342
+ continue;
343
+ }
344
+
345
+ words.push(registered.definition.name);
346
+
347
+ if (registered.definition.aliases) {
348
+ words.push(...registered.definition.aliases);
349
+ }
350
+ }
351
+
352
+ return words.sort();
353
+ }
354
+
355
+ // -------------------------------------------------------------------------
356
+ // Commander.js Integration
357
+ // -------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Build a Commander.js program from all registered commands.
361
+ *
362
+ * This is the bridge between the registry's CommandDefinition world
363
+ * and Commander.js's imperative API. Call this once after all commands
364
+ * are registered.
365
+ *
366
+ * @param program - The root Commander.js Command instance
367
+ * @param contextFactory - Factory that creates a CommandContext for each invocation
368
+ */
369
+ buildProgram(
370
+ program: Command,
371
+ contextFactory: (globalOpts: GlobalOptions) => CommandContext
372
+ ): void {
373
+ for (const registered of Array.from(this.commands.values())) {
374
+ const { definition, hooks } = registered;
375
+
376
+ // Skip qualified subcommand names (they are handled by their parent)
377
+ if (definition.name.includes(':')) {
378
+ continue;
379
+ }
380
+
381
+ // Check for legacy factory
382
+ const legacyFactory = (
383
+ definition as CommandDefinition & { _legacyFactory?: () => Command }
384
+ )._legacyFactory;
385
+
386
+ if (legacyFactory) {
387
+ // Legacy command: add the pre-built Commander.Command directly
388
+ program.addCommand(legacyFactory());
389
+ continue;
390
+ }
391
+
392
+ // Build a new Commander.Command from the definition
393
+ const cmd = this.buildCommand(definition, hooks, contextFactory);
394
+ program.addCommand(cmd);
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Build a single Commander.Command from a CommandDefinition.
400
+ */
401
+ private buildCommand(
402
+ definition: CommandDefinition,
403
+ hooks: CommandHook[],
404
+ contextFactory: (globalOpts: GlobalOptions) => CommandContext
405
+ ): Command {
406
+ const cmd = new Command(definition.name);
407
+ cmd.description(definition.description);
408
+
409
+ // Aliases
410
+ if (definition.aliases) {
411
+ for (const alias of definition.aliases) {
412
+ cmd.alias(alias);
413
+ }
414
+ }
415
+
416
+ // Hidden - Commander.js doesn't expose hideHelp, so we use the internal approach
417
+ if (definition.hidden) {
418
+ (cmd as unknown as { _hidden: boolean })._hidden = true;
419
+ }
420
+
421
+ // Arguments
422
+ if (definition.arguments) {
423
+ for (const arg of definition.arguments) {
424
+ const spec = arg.required
425
+ ? arg.variadic
426
+ ? `<${arg.name}...>`
427
+ : `<${arg.name}>`
428
+ : arg.variadic
429
+ ? `[${arg.name}...]`
430
+ : `[${arg.name}]`;
431
+ cmd.argument(spec, arg.description, arg.defaultValue);
432
+ }
433
+ }
434
+
435
+ // Options
436
+ if (definition.options) {
437
+ for (const opt of definition.options) {
438
+ if (opt.required) {
439
+ cmd.requiredOption(
440
+ opt.flags,
441
+ opt.description,
442
+ opt.defaultValue as string | boolean | undefined
443
+ );
444
+ } else {
445
+ cmd.option(
446
+ opt.flags,
447
+ opt.description,
448
+ opt.defaultValue as string | boolean | undefined
449
+ );
450
+ }
451
+
452
+ if (opt.choices) {
453
+ // Commander.js doesn't have built-in choices on options;
454
+ // we handle this in validation instead.
455
+ }
456
+ }
457
+ }
458
+
459
+ // Examples in help text
460
+ if (definition.examples && definition.examples.length > 0) {
461
+ const examplesText = definition.examples
462
+ .map(
463
+ ex => ` ${chalk.green(ex.command)} ${chalk.gray(ex.description)}`
464
+ )
465
+ .join('\n');
466
+
467
+ cmd.addHelpText(
468
+ 'after',
469
+ `\n${chalk.gray('Examples:')}\n${examplesText}\n`
470
+ );
471
+ }
472
+
473
+ // Subcommands
474
+ if (definition.subcommands) {
475
+ for (const sub of definition.subcommands) {
476
+ const subCmd = this.buildCommand(sub, hooks, contextFactory);
477
+ cmd.addCommand(subCmd);
478
+ }
479
+ }
480
+
481
+ // Action handler
482
+ cmd.action(async (...actionArgs: unknown[]) => {
483
+ // Commander passes positional args first, then options object, then the Command
484
+ const commanderCmd = actionArgs[actionArgs.length - 1] as Command;
485
+ const options = actionArgs[actionArgs.length - 2] as Record<
486
+ string,
487
+ unknown
488
+ >;
489
+
490
+ // Build positional args map
491
+ const args: Record<string, unknown> = {};
492
+ if (definition.arguments) {
493
+ for (let i = 0; i < definition.arguments.length; i++) {
494
+ const argDef = definition.arguments[i];
495
+ if (argDef) {
496
+ args[argDef.name] = actionArgs[i];
497
+ }
498
+ }
499
+ }
500
+
501
+ // Merge environment variable defaults into options
502
+ if (definition.options) {
503
+ for (const opt of definition.options) {
504
+ if (opt.envVar) {
505
+ const optName = this.flagsToOptionName(opt.flags);
506
+ if (options[optName] === undefined && process.env[opt.envVar]) {
507
+ options[optName] = process.env[opt.envVar];
508
+ }
509
+ }
510
+ }
511
+ }
512
+
513
+ // Create execution context
514
+ const rootOpts = commanderCmd.parent?.opts() ?? commanderCmd.opts();
515
+ const globalOpts: GlobalOptions = {
516
+ verbose: !!rootOpts['verbose'],
517
+ quiet: !!rootOpts['quiet'],
518
+ json: !!rootOpts['json'] || !!options['json'],
519
+ noColor: !!rootOpts['noColor'],
520
+ dryRun: !!rootOpts['dryRun'] || !!options['dryRun'],
521
+ config: rootOpts['config'] as string | undefined,
522
+ };
523
+
524
+ const context = contextFactory(globalOpts);
525
+
526
+ try {
527
+ // Run pre-validate hooks
528
+ const allHooks = [...this.globalHooks, ...hooks];
529
+ await this.runHooks('preValidate', allHooks, definition, context);
530
+
531
+ // Validate
532
+ let validation: ValidationResult = validationOk();
533
+ if (definition.validate) {
534
+ validation = await definition.validate(args, options, context);
535
+ }
536
+
537
+ // Validate option choices
538
+ if (definition.options) {
539
+ for (const opt of definition.options) {
540
+ if (opt.choices) {
541
+ const optName = this.flagsToOptionName(opt.flags);
542
+ const value = options[optName];
543
+ if (value !== undefined && !opt.choices.includes(String(value))) {
544
+ validation = {
545
+ valid: false,
546
+ errors: [
547
+ ...validation.errors,
548
+ {
549
+ field: optName,
550
+ message: `Invalid value "${value}" for --${optName}. Allowed: ${opt.choices.join(', ')}`,
551
+ suggestion: `Use one of: ${opt.choices.join(', ')}`,
552
+ },
553
+ ],
554
+ };
555
+ }
556
+ }
557
+ }
558
+ }
559
+
560
+ // Validate option conflicts
561
+ if (definition.options) {
562
+ for (const opt of definition.options) {
563
+ if (opt.conflicts) {
564
+ const optName = this.flagsToOptionName(opt.flags);
565
+ if (options[optName] !== undefined) {
566
+ for (const conflictName of opt.conflicts) {
567
+ if (options[conflictName] !== undefined) {
568
+ validation = {
569
+ valid: false,
570
+ errors: [
571
+ ...validation.errors,
572
+ {
573
+ field: optName,
574
+ message: `Option --${optName} conflicts with --${conflictName}`,
575
+ suggestion: `Use either --${optName} or --${conflictName}, not both`,
576
+ },
577
+ ],
578
+ };
579
+ }
580
+ }
581
+ }
582
+ }
583
+ }
584
+ }
585
+
586
+ // Run post-validate hooks
587
+ await this.runHooks('postValidate', allHooks, definition, context);
588
+
589
+ if (!validation.valid) {
590
+ for (const err of validation.errors) {
591
+ console.error(
592
+ chalk.red(`Validation error [${err.field}]: ${err.message}`)
593
+ );
594
+ if (err.suggestion) {
595
+ console.error(chalk.yellow(` Suggestion: ${err.suggestion}`));
596
+ }
597
+ }
598
+ process.exitCode = 1;
599
+ return;
600
+ }
601
+
602
+ // Run pre-execute hooks
603
+ await this.runHooks('preExecute', allHooks, definition, context);
604
+
605
+ // Execute
606
+ const result = await definition.execute(args, options, context);
607
+
608
+ // Run post-execute hooks
609
+ await this.runHooks(
610
+ 'postExecute',
611
+ allHooks,
612
+ definition,
613
+ context,
614
+ result
615
+ );
616
+
617
+ // Handle result
618
+ this.handleResult(result, context);
619
+ } catch (error) {
620
+ // Attempt rollback
621
+ if (definition.rollback && error instanceof Error) {
622
+ try {
623
+ await definition.rollback(error, context);
624
+ } catch (rollbackError) {
625
+ context.logger.error(
626
+ `Rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`
627
+ );
628
+ }
629
+ }
630
+
631
+ // Format error output
632
+ if (error instanceof Error) {
633
+ if (globalOpts.verbose) {
634
+ console.error(chalk.red(`\nCommand "${definition.name}" failed:`));
635
+ console.error(chalk.red(error.message));
636
+ if (error.stack) {
637
+ console.error(chalk.gray(error.stack));
638
+ }
639
+ } else {
640
+ console.error(chalk.red(`Error: ${error.message}`));
641
+ console.error(chalk.gray(`Run with --verbose for details`));
642
+ }
643
+ } else {
644
+ console.error(chalk.red(`Error: ${String(error)}`));
645
+ }
646
+
647
+ process.exitCode = 1;
648
+ }
649
+ });
650
+
651
+ return cmd;
652
+ }
653
+
654
+ // -------------------------------------------------------------------------
655
+ // Shell Completion Generation
656
+ // -------------------------------------------------------------------------
657
+
658
+ /**
659
+ * Generate a bash completion script.
660
+ */
661
+ generateBashCompletion(programName: string = 'wundr'): string {
662
+ const commands = this.getCompletionWords();
663
+
664
+ return `
665
+ # Bash completion for ${programName}
666
+ # Generated by @wundr/cli framework
667
+
668
+ _${programName}_completions() {
669
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
670
+ local commands="${commands.join(' ')}"
671
+ local global_opts="--verbose --quiet --json --no-color --dry-run --config --help --version"
672
+
673
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
674
+ COMPREPLY=( $(compgen -W "\${commands} \${global_opts}" -- "\${cur}") )
675
+ else
676
+ local cmd="\${COMP_WORDS[1]}"
677
+ case "\${cmd}" in
678
+ ${this.generateBashCaseClauses()}
679
+ *)
680
+ COMPREPLY=( $(compgen -W "\${global_opts}" -- "\${cur}") )
681
+ ;;
682
+ esac
683
+ fi
684
+ }
685
+
686
+ complete -F _${programName}_completions ${programName}
687
+ `.trim();
688
+ }
689
+
690
+ /**
691
+ * Generate a zsh completion script.
692
+ */
693
+ generateZshCompletion(programName: string = 'wundr'): string {
694
+ const commands = this.list()
695
+ .filter(cmd => !cmd.hidden && !cmd.name.includes(':'))
696
+ .map(cmd => `'${cmd.name}:${cmd.description.replace(/'/g, '')}'`)
697
+ .join('\n ');
698
+
699
+ return `
700
+ #compdef ${programName}
701
+ # Zsh completion for ${programName}
702
+ # Generated by @wundr/cli framework
703
+
704
+ _${programName}() {
705
+ local -a commands
706
+ commands=(
707
+ ${commands}
708
+ )
709
+
710
+ _arguments -C \\
711
+ '--verbose[Enable verbose logging]' \\
712
+ '--quiet[Suppress output]' \\
713
+ '--json[Output as JSON]' \\
714
+ '--no-color[Disable colored output]' \\
715
+ '--dry-run[Show what would be done]' \\
716
+ '--config[Specify config file]:file:_files' \\
717
+ '-h[Show help]' \\
718
+ '-v[Show version]' \\
719
+ '1:command:->command' \\
720
+ '*::arg:->args'
721
+
722
+ case $state in
723
+ command)
724
+ _describe -t commands 'command' commands
725
+ ;;
726
+ args)
727
+ case $words[1] in
728
+ ${this.generateZshCaseClauses()}
729
+ esac
730
+ ;;
731
+ esac
732
+ }
733
+
734
+ _${programName}
735
+ `.trim();
736
+ }
737
+
738
+ // -------------------------------------------------------------------------
739
+ // Private Helpers
740
+ // -------------------------------------------------------------------------
741
+
742
+ /**
743
+ * Load a module from a file path. Handles both ESM and CJS.
744
+ */
745
+ private async loadModule(filePath: string): Promise<Record<string, unknown>> {
746
+ try {
747
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
748
+ return require(filePath);
749
+ } catch {
750
+ // Fall back to dynamic import for ESM
751
+ return await import(filePath);
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Validate a command definition at registration time.
757
+ */
758
+ private validateDefinition(definition: CommandDefinition): void {
759
+ if (!definition.name || typeof definition.name !== 'string') {
760
+ throw new Error('Command definition must have a non-empty name');
761
+ }
762
+
763
+ if (!definition.description || typeof definition.description !== 'string') {
764
+ throw new Error(`Command "${definition.name}" must have a description`);
765
+ }
766
+
767
+ if (typeof definition.execute !== 'function') {
768
+ throw new Error(
769
+ `Command "${definition.name}" must have an execute function`
770
+ );
771
+ }
772
+
773
+ // Validate arguments don't have duplicates
774
+ if (definition.arguments) {
775
+ const names = new Set<string>();
776
+ for (const arg of definition.arguments) {
777
+ if (names.has(arg.name)) {
778
+ throw new Error(
779
+ `Command "${definition.name}" has duplicate argument name: ${arg.name}`
780
+ );
781
+ }
782
+ names.add(arg.name);
783
+ }
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Run hooks for a specific phase.
789
+ */
790
+ private async runHooks(
791
+ phase: CommandHook['phase'],
792
+ hooks: CommandHook[],
793
+ command: CommandDefinition,
794
+ context: CommandContext,
795
+ result?: CommandResult
796
+ ): Promise<void> {
797
+ const matching = hooks.filter(h => {
798
+ if (h.phase !== phase) return false;
799
+ if (h.commands && !h.commands.includes(command.name)) return false;
800
+ return true;
801
+ });
802
+
803
+ for (const hook of matching) {
804
+ const shouldContinue = await hook.handler(command, context, result);
805
+ if (
806
+ shouldContinue === false &&
807
+ (phase === 'preValidate' || phase === 'preExecute')
808
+ ) {
809
+ throw new Error(`Command "${command.name}" aborted by ${phase} hook`);
810
+ }
811
+ }
812
+ }
813
+
814
+ /**
815
+ * Handle a CommandResult by formatting output appropriately.
816
+ */
817
+ private handleResult(result: CommandResult, context: CommandContext): void {
818
+ if (result.exitCode !== 0) {
819
+ if (result.message) {
820
+ console.error(chalk.red(result.message));
821
+ }
822
+ process.exitCode = result.exitCode;
823
+ return;
824
+ }
825
+
826
+ // Warnings
827
+ if (result.warnings && result.warnings.length > 0) {
828
+ for (const warning of result.warnings) {
829
+ console.error(chalk.yellow(`Warning: ${warning}`));
830
+ }
831
+ }
832
+
833
+ // JSON mode: output data directly
834
+ if (context.globalOptions.json && result.data !== undefined) {
835
+ const output =
836
+ typeof result.data === 'string'
837
+ ? result.data
838
+ : JSON.stringify(result.data, null, 2);
839
+ console.log(output);
840
+ return;
841
+ }
842
+
843
+ // Quiet mode: no message output
844
+ if (context.globalOptions.quiet) {
845
+ return;
846
+ }
847
+
848
+ // Normal mode: output message
849
+ if (result.message) {
850
+ console.log(result.message);
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Convert Commander.js flag specification to a camelCase option name.
856
+ * e.g., '-p, --port <number>' -> 'port'
857
+ * e.g., '--dry-run' -> 'dryRun'
858
+ */
859
+ private flagsToOptionName(flags: string): string {
860
+ const match = flags.match(/--([a-z-]+)/);
861
+ if (!match || !match[1]) return flags;
862
+
863
+ return match[1].replace(/-([a-z])/g, (_, char) => char.toUpperCase());
864
+ }
865
+
866
+ /**
867
+ * Generate bash case clauses for subcommand completion.
868
+ */
869
+ private generateBashCaseClauses(): string {
870
+ const clauses: string[] = [];
871
+
872
+ for (const registered of Array.from(this.commands.values())) {
873
+ const { definition } = registered;
874
+ if (definition.hidden || definition.name.includes(':')) continue;
875
+
876
+ const subNames: string[] = [];
877
+ const optFlags: string[] = [];
878
+
879
+ if (definition.subcommands) {
880
+ for (const sub of definition.subcommands) {
881
+ subNames.push(sub.name);
882
+ }
883
+ }
884
+
885
+ if (definition.options) {
886
+ for (const opt of definition.options) {
887
+ const longMatch = opt.flags.match(/--[a-z-]+/);
888
+ if (longMatch) optFlags.push(longMatch[0]);
889
+ }
890
+ }
891
+
892
+ const words = [...subNames, ...optFlags].join(' ');
893
+ if (words) {
894
+ clauses.push(
895
+ ` ${definition.name})\n COMPREPLY=( $(compgen -W "${words}" -- "\${cur}") )\n ;;`
896
+ );
897
+ }
898
+ }
899
+
900
+ return clauses.join('\n');
901
+ }
902
+
903
+ /**
904
+ * Generate zsh case clauses for subcommand completion.
905
+ */
906
+ private generateZshCaseClauses(): string {
907
+ const clauses: string[] = [];
908
+
909
+ for (const registered of Array.from(this.commands.values())) {
910
+ const { definition } = registered;
911
+ if (definition.hidden || definition.name.includes(':')) continue;
912
+
913
+ const args: string[] = [];
914
+
915
+ if (definition.subcommands) {
916
+ const subs = definition.subcommands
917
+ .map(s => `'${s.name}:${s.description.replace(/'/g, '')}'`)
918
+ .join(' ');
919
+ args.push(`_values 'subcommand' ${subs}`);
920
+ }
921
+
922
+ if (definition.options) {
923
+ for (const opt of definition.options) {
924
+ const longMatch = opt.flags.match(/--([a-z-]+)/);
925
+ if (longMatch) {
926
+ args.push(
927
+ `'--${longMatch[1]}[${opt.description.replace(/'/g, '')}]'`
928
+ );
929
+ }
930
+ }
931
+ }
932
+
933
+ if (args.length > 0) {
934
+ clauses.push(
935
+ ` ${definition.name})\n _arguments ${args.join(' \\\n ')}\n ;;`
936
+ );
937
+ }
938
+ }
939
+
940
+ return clauses.join('\n');
941
+ }
942
+ }