ex-brain 0.1.0 → 0.2.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.
@@ -0,0 +1,569 @@
1
+ /**
2
+ * CLI Output Utilities
3
+ *
4
+ * Provides colorful, structured output for CLI interactions:
5
+ * - Green for success messages
6
+ * - Red for errors
7
+ * - Yellow for warnings
8
+ * - Task status tracking (working -> [done])
9
+ * - Detailed step-by-step feedback
10
+ */
11
+
12
+ // ANSI color codes
13
+ export const COLORS = {
14
+ // Text colors
15
+ reset: '\x1b[0m',
16
+ bold: '\x1b[1m',
17
+ dim: '\x1b[2m',
18
+
19
+ // Foreground colors
20
+ black: '\x1b[30m',
21
+ red: '\x1b[31m',
22
+ green: '\x1b[32m',
23
+ yellow: '\x1b[33m',
24
+ blue: '\x1b[34m',
25
+ magenta: '\x1b[35m',
26
+ cyan: '\x1b[36m',
27
+ white: '\x1b[37m',
28
+
29
+ // Background colors
30
+ bgRed: '\x1b[41m',
31
+ bgGreen: '\x1b[42m',
32
+ bgYellow: '\x1b[43m',
33
+ bgBlue: '\x1b[44m',
34
+ } as const;
35
+
36
+ // Icons
37
+ export const ICONS = {
38
+ success: '✓',
39
+ error: '✗',
40
+ warning: '⚠',
41
+ info: 'ℹ',
42
+ working: '◌',
43
+ done: '●',
44
+ arrow: '→',
45
+ bullet: '•',
46
+ chevron: '›',
47
+ } as const;
48
+
49
+ // Spinner frames
50
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
51
+ const SPINNER_INTERVAL = 80;
52
+
53
+ /**
54
+ * Strip ANSI color codes from string (for length calculation)
55
+ */
56
+ function stripAnsi(str: string): string {
57
+ // eslint-disable-next-line no-control-regex
58
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
59
+ }
60
+
61
+ /**
62
+ * Pad string to width (considering ANSI codes)
63
+ */
64
+ function padRight(str: string, width: number): string {
65
+ const visibleLen = stripAnsi(str).length;
66
+ return str + ' '.repeat(Math.max(0, width - visibleLen));
67
+ }
68
+
69
+ // ============================================================================
70
+ // Output Stream
71
+ // ============================================================================
72
+
73
+ export type OutputStream = NodeJS.WritableStream;
74
+
75
+ const defaultStderr: OutputStream = process.stderr;
76
+ const defaultStdout: OutputStream = process.stdout;
77
+
78
+ // ============================================================================
79
+ // Basic Output Functions
80
+ // ============================================================================
81
+
82
+ /**
83
+ * Print success message (green)
84
+ */
85
+ export function success(message: string, stream: OutputStream = defaultStderr): void {
86
+ stream.write(`${COLORS.green}${ICONS.success}${COLORS.reset} ${message}\n`);
87
+ }
88
+
89
+ /**
90
+ * Print error message (red)
91
+ */
92
+ export function error(message: string, stream: OutputStream = defaultStderr): void {
93
+ stream.write(`${COLORS.red}${ICONS.error}${COLORS.reset} ${message}\n`);
94
+ }
95
+
96
+ /**
97
+ * Print warning message (yellow)
98
+ */
99
+ export function warning(message: string, stream: OutputStream = defaultStderr): void {
100
+ stream.write(`${COLORS.yellow}${ICONS.warning}${COLORS.reset} ${message}\n`);
101
+ }
102
+
103
+ /**
104
+ * Print info message (cyan)
105
+ */
106
+ export function info(message: string, stream: OutputStream = defaultStderr): void {
107
+ stream.write(`${COLORS.cyan}${ICONS.info}${COLORS.reset} ${message}\n`);
108
+ }
109
+
110
+ /**
111
+ * Print a step/progress message
112
+ */
113
+ export function step(stepNum: number, total: number, message: string, stream: OutputStream = defaultStderr): void {
114
+ const counter = `${COLORS.dim}[${stepNum}/${total}]${COLORS.reset}`;
115
+ stream.write(`${counter} ${message}\n`);
116
+ }
117
+
118
+ /**
119
+ * Print a sub-item (indented)
120
+ */
121
+ export function subItem(message: string, indent: number = 2, stream: OutputStream = defaultStderr): void {
122
+ const spaces = ' '.repeat(indent);
123
+ stream.write(`${spaces}${COLORS.dim}${ICONS.bullet}${COLORS.reset} ${message}\n`);
124
+ }
125
+
126
+ /**
127
+ * Print a key-value pair
128
+ */
129
+ export function keyValue(key: string, value: string, stream: OutputStream = defaultStderr): void {
130
+ stream.write(` ${COLORS.dim}${key}:${COLORS.reset} ${value}\n`);
131
+ }
132
+
133
+ /**
134
+ * Print a header/section title
135
+ */
136
+ export function header(title: string, stream: OutputStream = defaultStderr): void {
137
+ stream.write(`\n${COLORS.bold}${COLORS.blue}■ ${title}${COLORS.reset}\n`);
138
+ }
139
+
140
+ /**
141
+ * Print a separator line
142
+ */
143
+ export function separator(stream: OutputStream = defaultStderr): void {
144
+ stream.write(`${COLORS.dim}${'─'.repeat(50)}${COLORS.reset}\n`);
145
+ }
146
+
147
+ // ============================================================================
148
+ // Task Status Tracker
149
+ // ============================================================================
150
+
151
+ export interface TaskStatus {
152
+ name: string;
153
+ status: 'pending' | 'working' | 'done' | 'failed' | 'skipped';
154
+ message?: string;
155
+ details?: string[];
156
+ duration?: number;
157
+ }
158
+
159
+ export class TaskRunner {
160
+ private tasks: TaskStatus[] = [];
161
+ private currentTaskIndex: number = -1;
162
+ private startTime: number = 0;
163
+ private spinnerInterval: Timer | null = null;
164
+ private frameIndex = 0;
165
+ private stream: OutputStream;
166
+ private json: boolean;
167
+
168
+ constructor(stream: OutputStream = defaultStderr, json: boolean = false) {
169
+ this.stream = stream;
170
+ this.json = json;
171
+ }
172
+
173
+ /**
174
+ * Register a task
175
+ */
176
+ addTask(name: string): this {
177
+ this.tasks.push({ name, status: 'pending' });
178
+ return this;
179
+ }
180
+
181
+ /**
182
+ * Start a task by name or index
183
+ */
184
+ start(taskIdentifier: string | number, message?: string): void {
185
+ if (this.json) return;
186
+
187
+ const index = typeof taskIdentifier === 'number'
188
+ ? taskIdentifier
189
+ : this.tasks.findIndex(t => t.name === taskIdentifier);
190
+
191
+ if (index === -1) return;
192
+
193
+ this.currentTaskIndex = index;
194
+ this.tasks[index].status = 'working';
195
+ this.tasks[index].message = message;
196
+ this.startTime = Date.now();
197
+
198
+ this.render();
199
+ this.startSpinner();
200
+ }
201
+
202
+ /**
203
+ * Complete current task successfully
204
+ */
205
+ succeed(message?: string, details?: string[]): void {
206
+ if (this.json) return;
207
+
208
+ this.stopSpinner();
209
+
210
+ if (this.currentTaskIndex >= 0) {
211
+ const task = this.tasks[this.currentTaskIndex];
212
+ task.status = 'done';
213
+ task.message = message;
214
+ task.details = details;
215
+ task.duration = Date.now() - this.startTime;
216
+ }
217
+
218
+ this.render();
219
+ }
220
+
221
+ /**
222
+ * Fail current task
223
+ */
224
+ fail(message?: string, details?: string[]): void {
225
+ if (this.json) return;
226
+
227
+ this.stopSpinner();
228
+
229
+ if (this.currentTaskIndex >= 0) {
230
+ const task = this.tasks[this.currentTaskIndex];
231
+ task.status = 'failed';
232
+ task.message = message;
233
+ task.details = details;
234
+ task.duration = Date.now() - this.startTime;
235
+ }
236
+
237
+ this.render();
238
+ }
239
+
240
+ /**
241
+ * Skip current task
242
+ */
243
+ skip(reason?: string): void {
244
+ if (this.json) return;
245
+
246
+ this.stopSpinner();
247
+
248
+ if (this.currentTaskIndex >= 0) {
249
+ const task = this.tasks[this.currentTaskIndex];
250
+ task.status = 'skipped';
251
+ task.message = reason;
252
+ }
253
+
254
+ this.render();
255
+ }
256
+
257
+ /**
258
+ * Update current task message (while working)
259
+ */
260
+ update(message: string): void {
261
+ if (this.json) return;
262
+
263
+ if (this.currentTaskIndex >= 0) {
264
+ this.tasks[this.currentTaskIndex].message = message;
265
+ this.render();
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Add detail to current task
271
+ */
272
+ addDetail(detail: string): void {
273
+ if (this.json) return;
274
+
275
+ if (this.currentTaskIndex >= 0) {
276
+ const task = this.tasks[this.currentTaskIndex];
277
+ if (!task.details) task.details = [];
278
+ task.details.push(detail);
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Print summary of all tasks
284
+ */
285
+ summary(): void {
286
+ if (this.json) return;
287
+
288
+ this.stopSpinner();
289
+ this.stream.write('\n');
290
+
291
+ const done = this.tasks.filter(t => t.status === 'done').length;
292
+ const failed = this.tasks.filter(t => t.status === 'failed').length;
293
+ const skipped = this.tasks.filter(t => t.status === 'skipped').length;
294
+ const total = this.tasks.length;
295
+
296
+ // Summary line
297
+ const parts: string[] = [];
298
+ if (done > 0) parts.push(`${COLORS.green}${done} passed${COLORS.reset}`);
299
+ if (failed > 0) parts.push(`${COLORS.red}${failed} failed${COLORS.reset}`);
300
+ if (skipped > 0) parts.push(`${COLORS.yellow}${skipped} skipped${COLORS.reset}`);
301
+
302
+ const summaryText = parts.join(', ') || 'No tasks';
303
+ this.stream.write(`${COLORS.bold}Summary:${COLORS.reset} ${summaryText} (${total} total)\n`);
304
+ }
305
+
306
+ // -----------------------------------------------------------------------
307
+ // Private methods
308
+ // -----------------------------------------------------------------------
309
+
310
+ private startSpinner(): void {
311
+ this.stopSpinner();
312
+ this.spinnerInterval = setInterval(() => {
313
+ this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
314
+ this.render();
315
+ }, SPINNER_INTERVAL);
316
+ }
317
+
318
+ private stopSpinner(): void {
319
+ if (this.spinnerInterval) {
320
+ clearInterval(this.spinnerInterval);
321
+ this.spinnerInterval = null;
322
+ }
323
+ }
324
+
325
+ private render(): void {
326
+ // Clear previous output
327
+ const linesToClear = this.tasks.length +
328
+ this.tasks.reduce((sum, t) => sum + (t.details?.length ?? 0), 0);
329
+
330
+ for (let i = 0; i < linesToClear + 2; i++) {
331
+ this.stream.write('\x1b[F\x1b[K'); // Move up and clear line
332
+ }
333
+
334
+ // Render each task
335
+ for (let i = 0; i < this.tasks.length; i++) {
336
+ const task = this.tasks[i];
337
+ this.renderTask(task, i === this.currentTaskIndex);
338
+ }
339
+
340
+ this.stream.write('\n');
341
+ }
342
+
343
+ private renderTask(task: TaskStatus, isCurrent: boolean): void {
344
+ const icon = this.getStatusIcon(task.status, isCurrent);
345
+ const color = this.getStatusColor(task.status);
346
+ const name = task.name;
347
+ const message = task.message ? ` ${COLORS.dim}${task.message}${COLORS.reset}` : '';
348
+ const duration = task.duration ? ` ${COLORS.dim}(${formatDuration(task.duration)})${COLORS.reset}` : '';
349
+
350
+ this.stream.write(`${color}${icon}${COLORS.reset} ${name}${message}${duration}\n`);
351
+
352
+ // Render details
353
+ if (task.details && task.details.length > 0) {
354
+ for (const detail of task.details) {
355
+ this.stream.write(` ${COLORS.dim}${ICONS.chevron} ${detail}${COLORS.reset}\n`);
356
+ }
357
+ }
358
+ }
359
+
360
+ private getStatusIcon(status: TaskStatus['status'], isCurrent: boolean): string {
361
+ if (status === 'working' && isCurrent) {
362
+ return SPINNER_FRAMES[this.frameIndex];
363
+ }
364
+
365
+ switch (status) {
366
+ case 'pending': return '○';
367
+ case 'working': return SPINNER_FRAMES[this.frameIndex];
368
+ case 'done': return ICONS.done;
369
+ case 'failed': return ICONS.error;
370
+ case 'skipped': return '○';
371
+ default: return '○';
372
+ }
373
+ }
374
+
375
+ private getStatusColor(status: TaskStatus['status']): string {
376
+ switch (status) {
377
+ case 'done': return COLORS.green;
378
+ case 'failed': return COLORS.red;
379
+ case 'skipped': return COLORS.yellow;
380
+ case 'working': return COLORS.cyan;
381
+ default: return COLORS.dim;
382
+ }
383
+ }
384
+ }
385
+
386
+ // ============================================================================
387
+ // Progress Spinner (Simple)
388
+ // ============================================================================
389
+
390
+ export interface ProgressSpinner {
391
+ start(message: string): void;
392
+ update(message: string): void;
393
+ succeed(message?: string): void;
394
+ fail(message?: string): void;
395
+ warn(message?: string): void;
396
+ stop(): void;
397
+ nativeError(message?: string): void;
398
+ }
399
+
400
+ export function createSpinner(stream: OutputStream = defaultStderr): ProgressSpinner {
401
+ // Allow disabling spinner via env var for debugging native errors
402
+ const noSpinner = process.env.EBRAIN_NO_SPINNER === '1' || process.env.EBRAIN_NO_SPINNER === 'true';
403
+
404
+ let frameIndex = 0;
405
+ let interval: Timer | null = null;
406
+ let currentMessage = '';
407
+ let isRunning = false;
408
+
409
+ function clearLine(): void {
410
+ stream.write('\r\x1b[K');
411
+ }
412
+
413
+ function render(): void {
414
+ if (!isRunning) return;
415
+ const frame = SPINNER_FRAMES[frameIndex];
416
+ clearLine();
417
+ stream.write(`\x1b[36m${frame}\x1b[0m ${currentMessage}`);
418
+ frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
419
+ }
420
+
421
+ function start(message: string): void {
422
+ if (isRunning) stop();
423
+ currentMessage = message;
424
+ isRunning = true;
425
+ frameIndex = 0;
426
+
427
+ if (noSpinner) {
428
+ // No spinner mode: just print the message
429
+ stream.write(`${COLORS.cyan}${ICONS.working}${COLORS.reset} ${message}\n`);
430
+ } else {
431
+ render();
432
+ interval = setInterval(render, SPINNER_INTERVAL);
433
+ }
434
+ }
435
+
436
+ function update(message: string): void {
437
+ currentMessage = message;
438
+ if (!isRunning) {
439
+ start(message);
440
+ } else if (noSpinner) {
441
+ stream.write(`${COLORS.cyan}${ICONS.working}${COLORS.reset} ${message}\n`);
442
+ }
443
+ }
444
+
445
+ function stop(): void {
446
+ if (interval) {
447
+ clearInterval(interval);
448
+ interval = null;
449
+ }
450
+ if (isRunning) {
451
+ // Clear the spinner line completely so native errors show on new line
452
+ clearLine();
453
+ isRunning = false;
454
+ }
455
+ }
456
+
457
+ function succeed(message?: string): void {
458
+ stop();
459
+ const text = message || currentMessage;
460
+ stream.write(`\x1b[32m${ICONS.success}\x1b[0m ${text}\n`);
461
+ }
462
+
463
+ function fail(message?: string): void {
464
+ stop();
465
+ const text = message || currentMessage;
466
+ stream.write(`\x1b[31m${ICONS.error}\x1b[0m ${text}\n`);
467
+ }
468
+
469
+ function warn(message?: string): void {
470
+ stop();
471
+ const text = message || currentMessage;
472
+ stream.write(`\x1b[33m${ICONS.warning}\x1b[0m ${text}\n`);
473
+ }
474
+
475
+ /**
476
+ * Stop spinner and show native error output
477
+ */
478
+ function nativeError(message?: string): void {
479
+ stop();
480
+ // Print a blank line first so native stderr output is visible
481
+ stream.write('\n');
482
+ if (message) {
483
+ stream.write(`\x1b[31m${ICONS.error}\x1b[0m ${message}\n`);
484
+ }
485
+ stream.write(`${COLORS.dim}(Native library error output above)${COLORS.reset}\n`);
486
+ }
487
+
488
+ return { start, update, succeed, fail, warn, stop, nativeError };
489
+ }
490
+
491
+ // ============================================================================
492
+ // Result Reporter
493
+ // ============================================================================
494
+
495
+ export interface OperationResult {
496
+ ok: boolean;
497
+ message?: string;
498
+ details?: Record<string, unknown>;
499
+ warnings?: string[];
500
+ errors?: string[];
501
+ }
502
+
503
+ /**
504
+ * Print detailed operation result
505
+ */
506
+ export function reportResult(
507
+ result: OperationResult,
508
+ stream: OutputStream = defaultStderr
509
+ ): void {
510
+ if (result.ok) {
511
+ success(result.message || 'Operation completed successfully', stream);
512
+ } else {
513
+ error(result.message || 'Operation failed', stream);
514
+ }
515
+
516
+ // Print details
517
+ if (result.details) {
518
+ for (const [key, value] of Object.entries(result.details)) {
519
+ keyValue(key, String(value), stream);
520
+ }
521
+ }
522
+
523
+ // Print warnings
524
+ if (result.warnings && result.warnings.length > 0) {
525
+ for (const w of result.warnings) {
526
+ warning(w, stream);
527
+ }
528
+ }
529
+
530
+ // Print errors
531
+ if (result.errors && result.errors.length > 0) {
532
+ for (const e of result.errors) {
533
+ subItem(`${COLORS.red}${e}${COLORS.reset}`, 4, stream);
534
+ }
535
+ }
536
+ }
537
+
538
+ // ============================================================================
539
+ // Helpers
540
+ // ============================================================================
541
+
542
+ /**
543
+ * Format duration in human-readable form
544
+ */
545
+ export function formatDuration(ms: number): string {
546
+ if (ms < 1000) return `${ms}ms`;
547
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
548
+ const minutes = Math.floor(ms / 60000);
549
+ const seconds = Math.floor((ms % 60000) / 1000);
550
+ return `${minutes}m ${seconds}s`;
551
+ }
552
+
553
+ /**
554
+ * Format bytes
555
+ */
556
+ export function formatBytes(bytes: number): string {
557
+ if (bytes === 0) return '0 B';
558
+ const k = 1024;
559
+ const sizes = ['B', 'KB', 'MB', 'GB'];
560
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
561
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
562
+ }
563
+
564
+ /**
565
+ * Format count with singular/plural
566
+ */
567
+ export function formatCount(count: number, singular: string, plural?: string): string {
568
+ return `${count} ${count === 1 ? singular : (plural || singular + 's')}`;
569
+ }