speci 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 (70) hide show
  1. package/README.md +523 -0
  2. package/bin/speci.ts +228 -0
  3. package/lib/commands/init.ts +299 -0
  4. package/lib/commands/monitor.ts +579 -0
  5. package/lib/commands/plan.ts +112 -0
  6. package/lib/commands/refactor.ts +157 -0
  7. package/lib/commands/run.ts +531 -0
  8. package/lib/commands/status.ts +209 -0
  9. package/lib/commands/task.ts +133 -0
  10. package/lib/config.ts +644 -0
  11. package/lib/copilot.ts +229 -0
  12. package/lib/errors.ts +166 -0
  13. package/lib/state.ts +148 -0
  14. package/lib/ui/banner.ts +109 -0
  15. package/lib/ui/box.ts +161 -0
  16. package/lib/ui/colors.ts +110 -0
  17. package/lib/ui/glyphs.ts +91 -0
  18. package/lib/ui/palette.ts +118 -0
  19. package/lib/ui/terminal.ts +118 -0
  20. package/lib/utils/atomic-write.ts +147 -0
  21. package/lib/utils/gate.ts +197 -0
  22. package/lib/utils/i18n.ts +92 -0
  23. package/lib/utils/lock.ts +189 -0
  24. package/lib/utils/logger.ts +143 -0
  25. package/lib/utils/preflight.ts +236 -0
  26. package/lib/utils/process.ts +127 -0
  27. package/lib/utils/signals.ts +145 -0
  28. package/lib/utils/suggest.ts +71 -0
  29. package/package.json +38 -0
  30. package/templates/agents/speci-fix.md +107 -0
  31. package/templates/agents/speci-impl.md +152 -0
  32. package/templates/agents/speci-plan.md +771 -0
  33. package/templates/agents/speci-refactor.md +652 -0
  34. package/templates/agents/speci-review.md +169 -0
  35. package/templates/agents/speci-task.md +369 -0
  36. package/templates/agents/speci-tidy.md +84 -0
  37. package/templates/agents/subagents/final_reviewer.prompt.md +143 -0
  38. package/templates/agents/subagents/mvt_generator.prompt.md +171 -0
  39. package/templates/agents/subagents/plan_codebase_context.prompt.md +29 -0
  40. package/templates/agents/subagents/plan_initial_planner.prompt.md +31 -0
  41. package/templates/agents/subagents/plan_refine_architecture.prompt.md +21 -0
  42. package/templates/agents/subagents/plan_refine_dataflow.prompt.md +23 -0
  43. package/templates/agents/subagents/plan_refine_edgecases.prompt.md +23 -0
  44. package/templates/agents/subagents/plan_refine_errors.prompt.md +22 -0
  45. package/templates/agents/subagents/plan_refine_final.prompt.md +25 -0
  46. package/templates/agents/subagents/plan_refine_integration.prompt.md +22 -0
  47. package/templates/agents/subagents/plan_refine_performance.prompt.md +23 -0
  48. package/templates/agents/subagents/plan_refine_requirements.prompt.md +16 -0
  49. package/templates/agents/subagents/plan_refine_security.prompt.md +22 -0
  50. package/templates/agents/subagents/plan_refine_testing.prompt.md +21 -0
  51. package/templates/agents/subagents/plan_requirements_deep_dive.prompt.md +30 -0
  52. package/templates/agents/subagents/progress_generator.prompt.md +178 -0
  53. package/templates/agents/subagents/refactor_analyze_crosscutting.prompt.md +66 -0
  54. package/templates/agents/subagents/refactor_analyze_duplication.prompt.md +65 -0
  55. package/templates/agents/subagents/refactor_analyze_errors.prompt.md +65 -0
  56. package/templates/agents/subagents/refactor_analyze_functions.prompt.md +66 -0
  57. package/templates/agents/subagents/refactor_analyze_naming.prompt.md +65 -0
  58. package/templates/agents/subagents/refactor_analyze_performance.prompt.md +66 -0
  59. package/templates/agents/subagents/refactor_analyze_state.prompt.md +66 -0
  60. package/templates/agents/subagents/refactor_analyze_structure.prompt.md +64 -0
  61. package/templates/agents/subagents/refactor_analyze_testing.prompt.md +66 -0
  62. package/templates/agents/subagents/refactor_analyze_types.prompt.md +66 -0
  63. package/templates/agents/subagents/refactor_review_completeness.prompt.md +63 -0
  64. package/templates/agents/subagents/refactor_review_final.prompt.md +63 -0
  65. package/templates/agents/subagents/refactor_review_risks.prompt.md +63 -0
  66. package/templates/agents/subagents/refactor_review_roadmap.prompt.md +63 -0
  67. package/templates/agents/subagents/refactor_review_technical.prompt.md +63 -0
  68. package/templates/agents/subagents/task_generator.prompt.md +145 -0
  69. package/templates/agents/subagents/task_reviewer.prompt.md +85 -0
  70. package/templates/speci.config.json +36 -0
@@ -0,0 +1,579 @@
1
+ /**
2
+ * Monitor Command (TUI) Implementation
3
+ *
4
+ * Provides a real-time Terminal User Interface for viewing Speci log output.
5
+ * Runs in alternate screen buffer with keyboard navigation and auto-scrolling.
6
+ */
7
+
8
+ import { createReadStream, statSync, existsSync } from 'node:fs';
9
+ import { basename } from 'node:path';
10
+ import { loadConfig } from '../config.js';
11
+ import { getLockInfo } from '../utils/lock.js';
12
+ import { colorize, visibleLength } from '../ui/colors.js';
13
+ import { terminalState } from '../ui/terminal.js';
14
+ import {
15
+ installSignalHandlers,
16
+ registerCleanup,
17
+ removeSignalHandlers,
18
+ } from '../utils/signals.js';
19
+
20
+ /**
21
+ * Monitor command options
22
+ */
23
+ export interface MonitorOptions {
24
+ logFile?: string;
25
+ pollInterval?: number;
26
+ maxLines?: number;
27
+ verbose?: boolean;
28
+ }
29
+
30
+ /**
31
+ * MonitorUI class - handles TUI rendering and interaction
32
+ */
33
+ class MonitorUI {
34
+ private lines: string[] = [];
35
+ private scrollOffset = 0;
36
+ private autoScroll = true;
37
+ private lastSize = 0;
38
+ private lastMtime = 0;
39
+ private logPath: string;
40
+ private maxLines: number;
41
+ private pollInterval: number;
42
+ private running = false;
43
+ private pollTimer: NodeJS.Timeout | null = null;
44
+ private partialLine = '';
45
+ private verbose: boolean;
46
+
47
+ constructor(
48
+ logPath: string,
49
+ maxLines: number,
50
+ pollInterval: number,
51
+ verbose: boolean
52
+ ) {
53
+ this.logPath = logPath;
54
+ this.maxLines = maxLines;
55
+ this.pollInterval = pollInterval;
56
+ this.verbose = verbose;
57
+ }
58
+
59
+ /**
60
+ * Start the monitor UI
61
+ */
62
+ async start(): Promise<void> {
63
+ this.running = true;
64
+
65
+ // Enter TUI mode
66
+ this.enterTUIMode();
67
+
68
+ // Setup keyboard handler
69
+ this.setupKeyboardHandler();
70
+
71
+ // Initial file load
72
+ await this.loadInitialContent();
73
+
74
+ // Start polling loop
75
+ this.startPolling();
76
+
77
+ // Initial render
78
+ this.render();
79
+ }
80
+
81
+ /**
82
+ * Enter TUI mode - alternate screen, hidden cursor, raw input
83
+ */
84
+ private enterTUIMode(): void {
85
+ // Enter alternate screen buffer
86
+ terminalState.enterAltScreen();
87
+ // Hide cursor
88
+ terminalState.hideCursor();
89
+ // Disable line wrap
90
+ terminalState.disableLineWrap();
91
+ // Enable raw mode
92
+ if (process.stdin.setRawMode) {
93
+ process.stdin.setRawMode(true);
94
+ }
95
+ process.stdin.resume();
96
+ }
97
+
98
+ /**
99
+ * Exit TUI mode - restore terminal state
100
+ */
101
+ private exitTUIMode(): void {
102
+ // Terminal state restoration is handled by signal cleanup
103
+ // Just restore the captured state
104
+ terminalState.restore();
105
+ }
106
+
107
+ /**
108
+ * Setup keyboard event handler
109
+ */
110
+ private setupKeyboardHandler(): void {
111
+ process.stdin.on('data', (data) => {
112
+ const key = data.toString();
113
+
114
+ switch (key) {
115
+ case 'q':
116
+ case '\x03': // Ctrl+C
117
+ this.exit();
118
+ break;
119
+ case 'k':
120
+ case '\x1b[A': // Up arrow
121
+ this.scrollUp(1);
122
+ break;
123
+ case 'j':
124
+ case '\x1b[B': // Down arrow
125
+ this.scrollDown(1);
126
+ break;
127
+ case 'u':
128
+ case '\x1b[5~': // Page Up
129
+ this.scrollUp(this.getPageSize());
130
+ break;
131
+ case 'd':
132
+ case '\x1b[6~': // Page Down
133
+ this.scrollDown(this.getPageSize());
134
+ break;
135
+ case 'g':
136
+ this.scrollToTop();
137
+ break;
138
+ case 'G':
139
+ this.scrollToBottom();
140
+ break;
141
+ }
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Load initial log file content
147
+ */
148
+ private async loadInitialContent(): Promise<void> {
149
+ if (!existsSync(this.logPath)) {
150
+ this.lines.push('Waiting for log file...');
151
+ return;
152
+ }
153
+
154
+ try {
155
+ const stats = statSync(this.logPath);
156
+ this.lastSize = stats.size;
157
+ this.lastMtime = stats.mtimeMs;
158
+
159
+ // Read entire file initially
160
+ const content = await this.readFile(0, stats.size);
161
+ const lines = content.split('\n');
162
+
163
+ // Add all complete lines
164
+ for (const line of lines) {
165
+ if (line.length > 0) {
166
+ this.lines.push(line);
167
+ }
168
+ }
169
+
170
+ // Trim to max lines
171
+ this.trimToMaxLines();
172
+
173
+ // Start at bottom
174
+ this.scrollToBottom();
175
+ } catch (err) {
176
+ this.lines.push(`Error loading log file: ${(err as Error).message}`);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Read file content from start to end position
182
+ */
183
+ private async readFile(start: number, end: number): Promise<string> {
184
+ return new Promise((resolve, reject) => {
185
+ const stream = createReadStream(this.logPath, {
186
+ start,
187
+ end: end - 1,
188
+ encoding: 'utf8',
189
+ });
190
+
191
+ let buffer = '';
192
+ stream.on('data', (chunk) => {
193
+ buffer += chunk;
194
+ });
195
+ stream.on('end', () => resolve(buffer));
196
+ stream.on('error', reject);
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Start polling for file changes
202
+ */
203
+ private startPolling(): void {
204
+ this.pollTimer = setInterval(async () => {
205
+ if (!this.running) return;
206
+
207
+ try {
208
+ const stats = statSync(this.logPath);
209
+ const currentSize = stats.size;
210
+ const currentMtime = stats.mtimeMs;
211
+
212
+ if (currentSize !== this.lastSize || currentMtime !== this.lastMtime) {
213
+ await this.readNewContent(currentSize);
214
+ this.lastSize = currentSize;
215
+ this.lastMtime = currentMtime;
216
+ this.render();
217
+ }
218
+ } catch (err) {
219
+ // File might not exist yet or be temporarily unavailable
220
+ if (this.verbose) {
221
+ this.logDebug(`Poll error: ${(err as Error).message}`);
222
+ }
223
+ }
224
+ }, this.pollInterval);
225
+ }
226
+
227
+ /**
228
+ * Read new content from log file
229
+ */
230
+ private async readNewContent(currentSize: number): Promise<void> {
231
+ if (currentSize < this.lastSize) {
232
+ // File was truncated/rotated - read from beginning
233
+ this.lines = [];
234
+ this.lastSize = 0;
235
+ this.partialLine = '';
236
+ }
237
+
238
+ if (currentSize === this.lastSize) {
239
+ return;
240
+ }
241
+
242
+ try {
243
+ // Read new bytes
244
+ const newContent = await this.readFile(this.lastSize, currentSize);
245
+
246
+ // Prepend any partial line from last read
247
+ const fullContent = this.partialLine + newContent;
248
+
249
+ // Split into lines
250
+ const lines = fullContent.split('\n');
251
+
252
+ // Handle last line (might be partial)
253
+ if (!fullContent.endsWith('\n') && lines.length > 0) {
254
+ // Last line is partial, keep it for next read
255
+ this.partialLine = lines.pop() || '';
256
+ } else {
257
+ this.partialLine = '';
258
+ }
259
+
260
+ // Add complete lines to buffer
261
+ for (const line of lines) {
262
+ if (line.length > 0) {
263
+ this.lines.push(line);
264
+ }
265
+ }
266
+
267
+ // Trim to max lines
268
+ this.trimToMaxLines();
269
+
270
+ // Auto-scroll if at bottom
271
+ if (this.autoScroll) {
272
+ this.scrollToBottom();
273
+ }
274
+ } catch (err) {
275
+ if (this.verbose) {
276
+ this.logDebug(`Read error: ${(err as Error).message}`);
277
+ }
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Trim line buffer to max lines
283
+ */
284
+ private trimToMaxLines(): void {
285
+ while (this.lines.length > this.maxLines) {
286
+ this.lines.shift();
287
+ // Adjust scroll offset
288
+ if (this.scrollOffset > 0) {
289
+ this.scrollOffset--;
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Get page size (terminal height - status bar)
296
+ */
297
+ private getPageSize(): number {
298
+ return Math.max(1, (process.stdout.rows || 24) - 2);
299
+ }
300
+
301
+ /**
302
+ * Scroll up by N lines
303
+ */
304
+ private scrollUp(count: number): void {
305
+ this.scrollOffset = Math.max(0, this.scrollOffset - count);
306
+ this.autoScroll = false;
307
+ this.render();
308
+ }
309
+
310
+ /**
311
+ * Scroll down by N lines
312
+ */
313
+ private scrollDown(count: number): void {
314
+ const maxOffset = Math.max(0, this.lines.length - this.getPageSize());
315
+ this.scrollOffset = Math.min(maxOffset, this.scrollOffset + count);
316
+
317
+ // Re-enable auto-scroll if at bottom
318
+ if (this.scrollOffset >= maxOffset) {
319
+ this.autoScroll = true;
320
+ }
321
+
322
+ this.render();
323
+ }
324
+
325
+ /**
326
+ * Scroll to top
327
+ */
328
+ private scrollToTop(): void {
329
+ this.scrollOffset = 0;
330
+ this.autoScroll = false;
331
+ this.render();
332
+ }
333
+
334
+ /**
335
+ * Scroll to bottom
336
+ */
337
+ private scrollToBottom(): void {
338
+ const maxOffset = Math.max(0, this.lines.length - this.getPageSize());
339
+ this.scrollOffset = maxOffset;
340
+ this.autoScroll = true;
341
+ this.render();
342
+ }
343
+
344
+ /**
345
+ * Render the TUI
346
+ */
347
+ private render(): void {
348
+ const rows = process.stdout.rows || 24;
349
+ const columns = process.stdout.columns || 80;
350
+ const contentRows = rows - 2; // Reserve 2 lines for status bar
351
+
352
+ // Clear screen and move cursor home
353
+ process.stdout.write('\x1b[2J\x1b[H');
354
+
355
+ // Render visible lines
356
+ const startLine = this.scrollOffset;
357
+ const endLine = Math.min(startLine + contentRows, this.lines.length);
358
+
359
+ for (let i = startLine; i < endLine; i++) {
360
+ const line = this.lines[i];
361
+ // Truncate to terminal width
362
+ const truncated = this.truncateToWidth(line, columns);
363
+ process.stdout.write(truncated + '\n');
364
+ }
365
+
366
+ // Pad remaining lines
367
+ for (let i = endLine - startLine; i < contentRows; i++) {
368
+ process.stdout.write('\n');
369
+ }
370
+
371
+ // Render status bar
372
+ this.renderStatusBar(rows, columns);
373
+ }
374
+
375
+ /**
376
+ * Truncate line to visible width (respecting ANSI codes)
377
+ */
378
+ private truncateToWidth(line: string, width: number): string {
379
+ if (visibleLength(line) <= width) {
380
+ return line;
381
+ }
382
+
383
+ // Need to truncate while preserving ANSI codes
384
+ let result = '';
385
+ let visibleCount = 0;
386
+ let inEscape = false;
387
+ let escapeBuffer = '';
388
+
389
+ for (let i = 0; i < line.length; i++) {
390
+ const char = line[i];
391
+
392
+ if (char === '\x1b') {
393
+ inEscape = true;
394
+ escapeBuffer = char;
395
+ continue;
396
+ }
397
+
398
+ if (inEscape) {
399
+ escapeBuffer += char;
400
+ if (char === 'm') {
401
+ // End of escape sequence
402
+ result += escapeBuffer;
403
+ inEscape = false;
404
+ escapeBuffer = '';
405
+ }
406
+ continue;
407
+ }
408
+
409
+ // Regular character
410
+ if (visibleCount < width) {
411
+ result += char;
412
+ visibleCount++;
413
+ } else {
414
+ break;
415
+ }
416
+ }
417
+
418
+ return result;
419
+ }
420
+
421
+ /**
422
+ * Render status bar at bottom
423
+ */
424
+ private async renderStatusBar(rows: number, columns: number): Promise<void> {
425
+ try {
426
+ const config = await loadConfig();
427
+ const lockInfo = await getLockInfo(config);
428
+
429
+ const fileName = basename(this.logPath);
430
+ const updateTime = new Date().toLocaleTimeString();
431
+ const status = lockInfo.locked
432
+ ? `Running (${lockInfo.elapsed || '00:00:00'})`
433
+ : 'Stopped';
434
+ const scrollPos = `${this.scrollOffset + 1}/${this.lines.length}`;
435
+
436
+ // Build status line parts
437
+ const leftPart = ` ${fileName} `;
438
+ const middlePart = ` ${status} `;
439
+ const rightPart = ` ${scrollPos} | ${updateTime} `;
440
+
441
+ // Position cursor at bottom (last row)
442
+ process.stdout.write(`\x1b[${rows};1H`);
443
+
444
+ // Build and render status line
445
+ const statusLine = this.buildStatusLine(
446
+ leftPart,
447
+ middlePart,
448
+ rightPart,
449
+ columns
450
+ );
451
+ process.stdout.write(colorize(statusLine, 'white'));
452
+
453
+ // Second status line (help text)
454
+ const helpText = ' q:quit ↑↓/jk:scroll g/G:top/bottom u/d:page ';
455
+ process.stdout.write(`\x1b[${rows - 1};1H`);
456
+ const paddedHelp = helpText.padEnd(columns, ' ');
457
+ process.stdout.write(colorize(paddedHelp, 'sky200'));
458
+ } catch {
459
+ // Fallback if config/lock fails
460
+ const helpText = ' q:quit ↑↓/jk:scroll g/G:top/bottom u/d:page ';
461
+ process.stdout.write(`\x1b[${rows};1H`);
462
+ const paddedHelp = helpText.padEnd(columns, ' ');
463
+ process.stdout.write(colorize(paddedHelp, 'white'));
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Build status line with proper spacing
469
+ */
470
+ private buildStatusLine(
471
+ left: string,
472
+ middle: string,
473
+ right: string,
474
+ width: number
475
+ ): string {
476
+ const leftLen = left.length;
477
+ const middleLen = middle.length;
478
+ const rightLen = right.length;
479
+
480
+ // Calculate spacing
481
+ const totalContent = leftLen + middleLen + rightLen;
482
+ const totalPadding = width - totalContent;
483
+
484
+ if (totalPadding < 0) {
485
+ // Not enough space, truncate
486
+ return (left + middle + right).substring(0, width);
487
+ }
488
+
489
+ // Distribute padding
490
+ const leftPad = Math.floor(totalPadding / 2);
491
+ const rightPad = totalPadding - leftPad;
492
+
493
+ return left + ' '.repeat(leftPad) + middle + ' '.repeat(rightPad) + right;
494
+ }
495
+
496
+ /**
497
+ * Log debug message (when verbose)
498
+ */
499
+ private logDebug(message: string): void {
500
+ if (this.verbose) {
501
+ this.lines.push(`[DEBUG] ${message}`);
502
+ this.trimToMaxLines();
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Exit the monitor
508
+ */
509
+ private exit(): void {
510
+ this.running = false;
511
+
512
+ // Stop polling
513
+ if (this.pollTimer) {
514
+ clearInterval(this.pollTimer);
515
+ this.pollTimer = null;
516
+ }
517
+
518
+ // Exit TUI mode
519
+ this.exitTUIMode();
520
+
521
+ // Exit process
522
+ process.exit(0);
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Monitor command handler
528
+ *
529
+ * @param options - Command options
530
+ */
531
+ export default async function monitor(
532
+ options: MonitorOptions = {}
533
+ ): Promise<void> {
534
+ try {
535
+ const config = await loadConfig();
536
+
537
+ // Determine log file path
538
+ const logPath = options.logFile || config.paths.logs;
539
+
540
+ // Validate log file exists
541
+ if (!existsSync(logPath)) {
542
+ console.error(colorize(`✗ Log file not found: ${logPath}`, 'error'));
543
+ console.error(' Start a speci run first with: speci run');
544
+ process.exit(1);
545
+ }
546
+
547
+ // Install signal handlers
548
+ installSignalHandlers();
549
+
550
+ // Capture and register terminal restore
551
+ const savedState = terminalState.capture();
552
+ registerCleanup(() => {
553
+ terminalState.restore(savedState);
554
+ });
555
+
556
+ // Create and start monitor UI
557
+ const ui = new MonitorUI(
558
+ logPath,
559
+ options.maxLines ?? 10000,
560
+ options.pollInterval ?? 500,
561
+ options.verbose ?? false
562
+ );
563
+
564
+ await ui.start();
565
+
566
+ // Keep process alive
567
+ await new Promise(() => {
568
+ /* never resolves */
569
+ });
570
+ } catch (err) {
571
+ console.error(
572
+ colorize(`✗ Monitor error: ${(err as Error).message}`, 'error')
573
+ );
574
+ process.exit(1);
575
+ } finally {
576
+ // Cleanup handlers on exit
577
+ removeSignalHandlers();
578
+ }
579
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Plan Command Module
3
+ *
4
+ * Invokes GitHub Copilot CLI in interactive mode for plan generation.
5
+ * Users can have an interactive conversation with Copilot to develop
6
+ * implementation plans for features, projects, or refactoring efforts.
7
+ */
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { loadConfig, resolveAgentPath } from '../config.js';
11
+ import { buildCopilotArgs, spawnCopilot } from '../copilot.js';
12
+ import { preflight } from '../utils/preflight.js';
13
+ import { renderBanner } from '../ui/banner.js';
14
+ import { log } from '../utils/logger.js';
15
+ import { drawBox } from '../ui/box.js';
16
+ import { colorize } from '../ui/colors.js';
17
+
18
+ /**
19
+ * Options for the plan command
20
+ */
21
+ export interface PlanOptions {
22
+ /** Output file path for plan */
23
+ output?: string;
24
+ /** Custom agent path override */
25
+ agent?: string;
26
+ /** Show detailed output */
27
+ verbose?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Display information box about command invocation
32
+ * @param agentPath - Path to agent being used
33
+ * @param outputPath - Output path or 'stdout'
34
+ */
35
+ function displayCommandInfo(agentPath: string, outputPath: string): void {
36
+ const content = [
37
+ colorize('Agent:', 'sky400') + ` ${agentPath}`,
38
+ colorize('Mode:', 'sky400') + ' Interactive',
39
+ colorize('Output:', 'sky400') + ` ${outputPath}`,
40
+ ];
41
+ console.log(
42
+ drawBox(content, { title: 'Plan Generation', borderColor: 'sky500' })
43
+ );
44
+ console.log();
45
+ }
46
+
47
+ /**
48
+ * Plan command handler
49
+ * Initializes interactive Copilot session for plan generation
50
+ * @param options - Command options
51
+ */
52
+ export async function plan(options: PlanOptions = {}): Promise<void> {
53
+ try {
54
+ // Display banner
55
+ renderBanner();
56
+ console.log();
57
+
58
+ // Load configuration
59
+ const config = loadConfig();
60
+
61
+ // Run preflight checks
62
+ await preflight(config, {
63
+ requireCopilot: true,
64
+ requireConfig: true,
65
+ requireProgress: false,
66
+ requireGit: false,
67
+ });
68
+
69
+ // Resolve agent path (override or config)
70
+ const agentPath = options.agent
71
+ ? options.agent
72
+ : resolveAgentPath(config, 'plan');
73
+
74
+ // Validate agent file exists
75
+ if (!existsSync(agentPath)) {
76
+ log.error(`Agent file not found: ${agentPath}`);
77
+ log.info('Check config.agents.plan or provide --agent flag');
78
+ process.exit(1);
79
+ }
80
+
81
+ // Display command info
82
+ displayCommandInfo(agentPath, options.output || 'stdout');
83
+
84
+ // Build Copilot args
85
+ const args = buildCopilotArgs(config, {
86
+ interactive: true,
87
+ agent: agentPath,
88
+ allowAll: config.copilot.permissions === 'allow-all',
89
+ });
90
+
91
+ // Add output flag if specified
92
+ if (options.output) {
93
+ args.push('--output', options.output);
94
+ }
95
+
96
+ // Spawn Copilot with stdio:inherit
97
+ log.debug(`Spawning: copilot ${args.join(' ')}`);
98
+ const exitCode = await spawnCopilot(args, { inherit: true });
99
+
100
+ // Exit with Copilot's exit code
101
+ process.exit(exitCode);
102
+ } catch (error) {
103
+ if (error instanceof Error) {
104
+ log.error(`Plan command failed: ${error.message}`);
105
+ } else {
106
+ log.error(`Plan command failed: ${String(error)}`);
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ export default plan;