UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

641 lines (515 loc) 17.6 kB
/** * V3 CLI Output Formatter * Advanced output formatting with tables, progress bars, and colors */ import type { TableOptions, TableColumn, ProgressOptions, SpinnerOptions } from './types.js'; // ============================================ // Color Support // ============================================ const COLORS = { // Standard colors reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', italic: '\x1b[3m', underline: '\x1b[4m', // Foreground colors black: '\x1b[30m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', white: '\x1b[37m', gray: '\x1b[90m', // Bright foreground colors brightRed: '\x1b[91m', brightGreen: '\x1b[92m', brightYellow: '\x1b[93m', brightBlue: '\x1b[94m', brightMagenta: '\x1b[95m', brightCyan: '\x1b[96m', brightWhite: '\x1b[97m', // Background colors bgBlack: '\x1b[40m', bgRed: '\x1b[41m', bgGreen: '\x1b[42m', bgYellow: '\x1b[43m', bgBlue: '\x1b[44m', bgMagenta: '\x1b[45m', bgCyan: '\x1b[46m', bgWhite: '\x1b[47m' } as const; type ColorName = keyof typeof COLORS; export type VerbosityLevel = 'quiet' | 'normal' | 'verbose' | 'debug'; export class OutputFormatter { private colorEnabled: boolean; private outputStream: NodeJS.WriteStream; private errorStream: NodeJS.WriteStream; private verbosity: VerbosityLevel; constructor(options: { color?: boolean; verbosity?: VerbosityLevel } = {}) { this.colorEnabled = options.color ?? this.supportsColor(); this.outputStream = process.stdout; this.errorStream = process.stderr; this.verbosity = options.verbosity ?? 'normal'; } /** * Set verbosity level * - quiet: Only errors and direct results * - normal: Errors, warnings, info, and results * - verbose: All of normal + debug messages * - debug: All output including trace */ setVerbosity(level: VerbosityLevel): void { this.verbosity = level; } getVerbosity(): VerbosityLevel { return this.verbosity; } isQuiet(): boolean { return this.verbosity === 'quiet'; } isVerbose(): boolean { return this.verbosity === 'verbose' || this.verbosity === 'debug'; } isDebug(): boolean { return this.verbosity === 'debug'; } private supportsColor(): boolean { // Check for NO_COLOR environment variable if (process.env.NO_COLOR !== undefined) return false; // Check for FORCE_COLOR environment variable if (process.env.FORCE_COLOR !== undefined) return true; // Check if stdout is a TTY return process.stdout.isTTY ?? false; } // ============================================ // Color Methods // ============================================ color(text: string, ...colors: ColorName[]): string { if (!this.colorEnabled) return text; const codes = colors.map(c => COLORS[c]).join(''); return `${codes}${text}${COLORS.reset}`; } bold(text: string): string { return this.color(text, 'bold'); } dim(text: string): string { return this.color(text, 'dim'); } success(text: string): string { return this.color(text, 'green'); } error(text: string): string { return this.color(text, 'red'); } warning(text: string): string { return this.color(text, 'yellow'); } info(text: string): string { return this.color(text, 'blue'); } highlight(text: string): string { return this.color(text, 'cyan', 'bold'); } // ============================================ // Output Methods // ============================================ write(text: string): void { this.outputStream.write(text); } writeln(text: string = ''): void { this.outputStream.write(text + '\n'); } writeError(text: string): void { this.errorStream.write(text); } writeErrorln(text: string = ''): void { this.errorStream.write(text + '\n'); } // ============================================ // Formatted Output Methods // ============================================ printSuccess(message: string): void { // Success always shows (result output) const icon = this.color('[OK]', 'green', 'bold'); this.writeln(`${icon} ${message}`); } printError(message: string, details?: string): void { // Errors always show const icon = this.color('[ERROR]', 'red', 'bold'); this.writeErrorln(`${icon} ${message}`); if (details) { this.writeErrorln(this.dim(` ${details}`)); } } printWarning(message: string): void { // Warnings suppressed in quiet mode if (this.verbosity === 'quiet') return; const icon = this.color('[WARN]', 'yellow', 'bold'); this.writeln(`${icon} ${message}`); } printInfo(message: string): void { // Info suppressed in quiet mode if (this.verbosity === 'quiet') return; const icon = this.color('[INFO]', 'blue', 'bold'); this.writeln(`${icon} ${message}`); } printDebug(message: string): void { // Debug only shows in verbose/debug mode if (this.verbosity !== 'verbose' && this.verbosity !== 'debug') return; const icon = this.color('[DEBUG]', 'gray'); this.writeln(`${icon} ${this.dim(message)}`); } printTrace(message: string): void { // Trace only shows in debug mode if (this.verbosity !== 'debug') return; const icon = this.color('[TRACE]', 'gray', 'dim'); this.writeln(`${icon} ${this.dim(message)}`); } // ============================================ // Table Formatting // ============================================ table(options: TableOptions): string { const { columns, data, border = true, header = true, padding = 1, maxWidth } = options; // Calculate column widths const widths = this.calculateColumnWidths(columns, data, maxWidth); const lines: string[] = []; const pad = ' '.repeat(padding); // Border characters const borderChars = border ? { topLeft: '+', topRight: '+', bottomLeft: '+', bottomRight: '+', horizontal: '-', vertical: '|', leftT: '+', rightT: '+', topT: '+', bottomT: '+', cross: '+' } : { topLeft: '', topRight: '', bottomLeft: '', bottomRight: '', horizontal: '', vertical: ' ', leftT: '', rightT: '', topT: '', bottomT: '', cross: '' }; // Top border if (border) { lines.push(this.createBorderLine(widths, borderChars, 'top', padding)); } // Header row if (header) { const headerRow = columns.map((col, i) => { const text = this.truncate(col.header, widths[i]); return pad + this.alignText(this.bold(text), widths[i], col.align) + pad; }).join(borderChars.vertical); lines.push(`${borderChars.vertical}${headerRow}${borderChars.vertical}`); // Header separator if (border) { lines.push(this.createBorderLine(widths, borderChars, 'middle', padding)); } } // Data rows for (const row of data) { const rowCells = columns.map((col, i) => { let value = row[col.key]; // Apply formatter if provided if (col.format) { value = col.format(value); } else { value = String(value ?? ''); } const text = this.truncate(String(value), widths[i]); return pad + this.alignText(text, widths[i], col.align) + pad; }).join(borderChars.vertical); lines.push(`${borderChars.vertical}${rowCells}${borderChars.vertical}`); } // Bottom border if (border) { lines.push(this.createBorderLine(widths, borderChars, 'bottom', padding)); } return lines.join('\n'); } printTable(options: TableOptions): void { this.writeln(this.table(options)); } private calculateColumnWidths( columns: TableColumn[], data: Record<string, unknown>[], maxWidth?: number ): number[] { const widths = columns.map((col, i) => { // Start with header width let width = col.header.length; // Check all data values for (const row of data) { let value = row[col.key]; if (col.format) { value = col.format(value); } const len = this.stripAnsi(String(value ?? '')).length; width = Math.max(width, len); } // Apply column-specific width limit if (col.width) { width = Math.min(width, col.width); } return width; }); // Apply max width constraint if (maxWidth) { const totalWidth = widths.reduce((a, b) => a + b, 0) + (columns.length * 3) + 1; if (totalWidth > maxWidth) { const reduction = (totalWidth - maxWidth) / columns.length; return widths.map(w => Math.max(3, Math.floor(w - reduction))); } } return widths; } private createBorderLine( widths: number[], chars: Record<string, string>, position: 'top' | 'middle' | 'bottom', padding: number ): string { const cellWidth = (w: number) => chars.horizontal.repeat(w + (padding * 2)); const cells = widths.map(cellWidth).join( position === 'top' ? chars.topT : position === 'bottom' ? chars.bottomT : chars.cross ); const left = position === 'top' ? chars.topLeft : position === 'bottom' ? chars.bottomLeft : chars.leftT; const right = position === 'top' ? chars.topRight : position === 'bottom' ? chars.bottomRight : chars.rightT; return `${left}${cells}${right}`; } private alignText(text: string, width: number, align: 'left' | 'center' | 'right' = 'left'): string { const len = this.stripAnsi(text).length; const padding = width - len; if (padding <= 0) return text; switch (align) { case 'right': return ' '.repeat(padding) + text; case 'center': const left = Math.floor(padding / 2); const right = padding - left; return ' '.repeat(left) + text + ' '.repeat(right); default: return text + ' '.repeat(padding); } } private truncate(text: string, maxLength: number): string { const stripped = this.stripAnsi(text); if (stripped.length <= maxLength) return text; return stripped.slice(0, maxLength - 3) + '...'; } private stripAnsi(text: string): string { return text.replace(/\x1b\[[0-9;]*m/g, ''); } // ============================================ // Progress Bar // ============================================ createProgress(options: ProgressOptions): Progress { return new Progress(this, options); } progressBar(current: number, total: number, width: number = 40): string { const percent = Math.min(100, Math.max(0, (current / total) * 100)); const filled = Math.round((width * percent) / 100); const empty = width - filled; const bar = this.color('#'.repeat(filled), 'green') + this.dim('-'.repeat(empty)); return `[${bar}] ${percent.toFixed(1)}%`; } // ============================================ // Spinner // ============================================ createSpinner(options: SpinnerOptions): Spinner { return new Spinner(this, options); } // ============================================ // JSON Output // ============================================ json(data: unknown, pretty: boolean = true): string { return pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data); } printJson(data: unknown, pretty: boolean = true): void { this.writeln(this.json(data, pretty)); } // ============================================ // List Output // ============================================ list(items: string[], bullet: string = '-'): string { return items.map(item => ` ${bullet} ${item}`).join('\n'); } printList(items: string[], bullet: string = '-'): void { this.writeln(this.list(items, bullet)); } numberedList(items: string[]): string { return items.map((item, i) => ` ${i + 1}. ${item}`).join('\n'); } printNumberedList(items: string[]): void { this.writeln(this.numberedList(items)); } // ============================================ // Box Output // ============================================ box(content: string, title?: string): string { const lines = content.split('\n'); const maxLen = Math.max(...lines.map(l => this.stripAnsi(l).length), title?.length ?? 0); const width = maxLen + 4; const border = { topLeft: '+', topRight: '+', bottomLeft: '+', bottomRight: '+', horizontal: '-', vertical: '|' }; const result: string[] = []; // Top border with optional title if (title) { const titleText = ` ${title} `; const leftPad = Math.floor((width - titleText.length - 2) / 2); const rightPad = width - titleText.length - leftPad - 2; result.push( border.topLeft + border.horizontal.repeat(leftPad) + this.bold(titleText) + border.horizontal.repeat(rightPad) + border.topRight ); } else { result.push(border.topLeft + border.horizontal.repeat(width - 2) + border.topRight); } // Content lines for (const line of lines) { const stripped = this.stripAnsi(line); const padding = maxLen - stripped.length; result.push(`${border.vertical} ${line}${' '.repeat(padding)} ${border.vertical}`); } // Bottom border result.push(border.bottomLeft + border.horizontal.repeat(width - 2) + border.bottomRight); return result.join('\n'); } printBox(content: string, title?: string): void { this.writeln(this.box(content, title)); } setColorEnabled(enabled: boolean): void { this.colorEnabled = enabled; } isColorEnabled(): boolean { return this.colorEnabled; } } // ============================================ // Progress Class // ============================================ export class Progress { private current: number; private total: number; private width: number; private startTime: number; private formatter: OutputFormatter; private showPercentage: boolean; private showETA: boolean; private lastRender: string = ''; constructor(formatter: OutputFormatter, options: ProgressOptions) { this.formatter = formatter; this.current = options.current ?? 0; this.total = options.total; this.width = options.width ?? 40; this.showPercentage = options.showPercentage ?? true; this.showETA = options.showETA ?? true; this.startTime = Date.now(); } update(current: number): void { this.current = current; this.render(); } increment(amount: number = 1): void { this.update(this.current + amount); } render(): void { const bar = this.formatter.progressBar(this.current, this.total, this.width); let output = bar; if (this.showETA && this.current > 0) { const elapsed = Date.now() - this.startTime; const rate = this.current / elapsed; const remaining = this.total - this.current; const eta = remaining / rate; if (isFinite(eta)) { output += ` ETA: ${this.formatTime(eta)}`; } } // Clear previous line and write new if (this.lastRender) { process.stdout.write('\r' + ' '.repeat(this.lastRender.length) + '\r'); } process.stdout.write(output); this.lastRender = output; } finish(): void { this.current = this.total; this.render(); process.stdout.write('\n'); } private formatTime(ms: number): string { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { return `${hours}h ${minutes % 60}m`; } else if (minutes > 0) { return `${minutes}m ${seconds % 60}s`; } else { return `${seconds}s`; } } } // ============================================ // Spinner Class // ============================================ export class Spinner { private formatter: OutputFormatter; private text: string; private frames: string[]; private interval: ReturnType<typeof setInterval> | null = null; private frameIndex: number = 0; private static readonly SPINNERS: Record<string, string[]> = { dots: ['...', '..:' , '.::', ':::', '::.', ':..' ,], line: ['-', '\\', '|', '/'], arc: ['◜', '◠', '◝', '◞', '◡', '◟'], circle: ['◐', '◓', '◑', '◒'], arrows: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'] }; constructor(formatter: OutputFormatter, options: SpinnerOptions) { this.formatter = formatter; this.text = options.text; this.frames = Spinner.SPINNERS[options.spinner ?? 'dots']; } start(): void { if (this.interval) return; this.interval = setInterval(() => { this.render(); this.frameIndex = (this.frameIndex + 1) % this.frames.length; }, 100); this.interval.unref(); this.render(); } stop(message?: string): void { if (this.interval) { clearInterval(this.interval); this.interval = null; } // Clear the line process.stdout.write('\r' + ' '.repeat(this.text.length + 10) + '\r'); if (message) { this.formatter.writeln(message); } } succeed(message?: string): void { this.stop(this.formatter.success(message ?? this.text)); } fail(message?: string): void { this.stop(this.formatter.error(message ?? this.text)); } private render(): void { const frame = this.formatter.info(this.frames[this.frameIndex]); process.stdout.write(`\r${frame} ${this.text}`); } setText(text: string): void { this.text = text; } } // Export singleton instance export const output = new OutputFormatter();