output.ts
1 /** 2 * Output formatting: table, JSON, Markdown, CSV, YAML. 3 */ 4 5 import { styleText } from 'node:util'; 6 import Table from 'cli-table3'; 7 import yaml from 'js-yaml'; 8 9 export interface RenderOptions { 10 fmt?: string; 11 /** True when the user explicitly passed -f on the command line */ 12 fmtExplicit?: boolean; 13 columns?: string[]; 14 title?: string; 15 elapsed?: number; 16 source?: string; 17 footerExtra?: string; 18 } 19 20 function normalizeRows(data: unknown): Record<string, unknown>[] { 21 if (Array.isArray(data)) return data; 22 if (data && typeof data === 'object') return [data as Record<string, unknown>]; 23 return [{ value: data }]; 24 } 25 26 function resolveColumns(rows: Record<string, unknown>[], opts: RenderOptions): string[] { 27 return opts.columns ?? Object.keys(rows[0] ?? {}); 28 } 29 30 export function render(data: unknown, opts: RenderOptions = {}): void { 31 let fmt = opts.fmt ?? 'table'; 32 // Non-TTY auto-downgrade only when format was NOT explicitly passed by user. 33 if (!opts.fmtExplicit) { 34 if (fmt === 'table' && !process.stdout.isTTY) fmt = 'yaml'; 35 } 36 if (data === null || data === undefined) { 37 console.log(data); 38 return; 39 } 40 switch (fmt) { 41 case 'json': renderJson(data); break; 42 case 'plain': renderPlain(data, opts); break; 43 case 'md': case 'markdown': renderMarkdown(data, opts); break; 44 case 'csv': renderCsv(data, opts); break; 45 case 'yaml': case 'yml': renderYaml(data); break; 46 default: renderTable(data, opts); break; 47 } 48 } 49 50 function renderTable(data: unknown, opts: RenderOptions): void { 51 const rows = normalizeRows(data); 52 if (!rows.length) { console.log(styleText('dim', '(no data)')); return; } 53 const columns = resolveColumns(rows, opts); 54 55 const header = columns.map(c => capitalize(c)); 56 const table = new Table({ 57 head: header.map(h => styleText('bold', h)), 58 style: { head: [], border: [] }, 59 wordWrap: true, 60 wrapOnWordBoundary: true, 61 }); 62 63 for (const row of rows) { 64 table.push(columns.map(c => { 65 const v = (row as Record<string, unknown>)[c]; 66 return v === null || v === undefined ? '' : String(v); 67 })); 68 } 69 70 console.log(); 71 if (opts.title) console.log(styleText('dim', ` ${opts.title}`)); 72 console.log(table.toString()); 73 const footer: string[] = []; 74 footer.push(`${rows.length} items`); 75 if (opts.elapsed !== undefined) footer.push(`${opts.elapsed.toFixed(1)}s`); 76 if (opts.source) footer.push(opts.source); 77 if (opts.footerExtra) footer.push(opts.footerExtra); 78 console.log(styleText('dim', footer.join(' ยท '))); 79 } 80 81 function renderJson(data: unknown): void { 82 console.log(JSON.stringify(data, null, 2)); 83 } 84 function renderPlain(data: unknown, opts: RenderOptions): void { 85 const rows = normalizeRows(data); 86 if (!rows.length) return; 87 88 // Single-row single-field shortcuts for chat-style commands. 89 if (rows.length === 1) { 90 const row = rows[0]; 91 const entries = Object.entries(row); 92 if (entries.length === 1) { 93 const [key, value] = entries[0]; 94 if (key === 'response' || key === 'content' || key === 'text' || key === 'value') { 95 console.log(String(value ?? '')); 96 return; 97 } 98 } 99 } 100 101 rows.forEach((row, index) => { 102 const entries = Object.entries(row).filter(([, value]) => value !== undefined && value !== null && String(value) !== ''); 103 entries.forEach(([key, value]) => { 104 console.log(`${key}: ${value}`); 105 }); 106 if (index < rows.length - 1) console.log(''); 107 }); 108 } 109 110 111 function renderMarkdown(data: unknown, opts: RenderOptions): void { 112 const rows = normalizeRows(data); 113 if (!rows.length) return; 114 const columns = resolveColumns(rows, opts); 115 console.log('| ' + columns.join(' | ') + ' |'); 116 console.log('| ' + columns.map(() => '---').join(' | ') + ' |'); 117 for (const row of rows) { 118 console.log('| ' + columns.map(c => String((row as Record<string, unknown>)[c] ?? '')).join(' | ') + ' |'); 119 } 120 } 121 122 function renderCsv(data: unknown, opts: RenderOptions): void { 123 const rows = normalizeRows(data); 124 if (!rows.length) return; 125 const columns = resolveColumns(rows, opts); 126 console.log(columns.join(',')); 127 for (const row of rows) { 128 console.log(columns.map(c => { 129 const v = String((row as Record<string, unknown>)[c] ?? ''); 130 return v.includes(',') || v.includes('"') || v.includes('\n') || v.includes('\r') 131 ? `"${v.replace(/"/g, '""')}"` : v; 132 }).join(',')); 133 } 134 } 135 136 function renderYaml(data: unknown): void { 137 console.log(yaml.dump(data, { sortKeys: false, lineWidth: 120, noRefs: true })); 138 } 139 140 function capitalize(s: string): string { 141 return s.charAt(0).toUpperCase() + s.slice(1); 142 }