/ src / output.ts
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  }