Files
jackwener__opencli/src/output.ts
jackwener 3a4eb582ad fix(output): show error rows in table/csv/markdown when columns declared
When a command declares columns (e.g. ['rank', 'title', 'value']) but
returns an error row ({ error, help }), the declared columns would
render empty cells. Now resolveColumns detects the error key and falls
back to the row's actual keys, making diagnostics visible in all output
formats.
2026-04-10 02:18:39 +08:00

151 lines
4.9 KiB
TypeScript

/**
* Output formatting: table, JSON, Markdown, CSV, YAML.
*/
import chalk from 'chalk';
import Table from 'cli-table3';
import yaml from 'js-yaml';
export interface RenderOptions {
fmt?: string;
/** True when the user explicitly passed -f on the command line */
fmtExplicit?: boolean;
columns?: string[];
title?: string;
elapsed?: number;
source?: string;
footerExtra?: string;
}
function normalizeRows(data: unknown): Record<string, unknown>[] {
if (Array.isArray(data)) return data;
if (data && typeof data === 'object') return [data as Record<string, unknown>];
return [{ value: data }];
}
function resolveColumns(rows: Record<string, unknown>[], opts: RenderOptions): string[] {
// When a command returns an error row ({ error, help }), override the declared
// columns so the error is visible in table/csv/markdown output.
if (opts.columns && rows.length > 0 && 'error' in rows[0] && !opts.columns.includes('error')) {
return Object.keys(rows[0]);
}
return opts.columns ?? Object.keys(rows[0] ?? {});
}
export function render(data: unknown, opts: RenderOptions = {}): void {
let fmt = opts.fmt ?? 'table';
// Non-TTY auto-downgrade only when format was NOT explicitly passed by user.
// Priority: explicit -f (any value) > OUTPUT env var > TTY auto-detect > table
if (!opts.fmtExplicit) {
const envFmt = process.env.OUTPUT?.trim().toLowerCase();
if (envFmt) fmt = envFmt;
else if (fmt === 'table' && !process.stdout.isTTY) fmt = 'yaml';
}
if (data === null || data === undefined) {
console.log(data);
return;
}
switch (fmt) {
case 'json': renderJson(data); break;
case 'plain': renderPlain(data, opts); break;
case 'md': case 'markdown': renderMarkdown(data, opts); break;
case 'csv': renderCsv(data, opts); break;
case 'yaml': case 'yml': renderYaml(data); break;
default: renderTable(data, opts); break;
}
}
function renderTable(data: unknown, opts: RenderOptions): void {
const rows = normalizeRows(data);
if (!rows.length) { console.log(chalk.dim('(no data)')); return; }
const columns = resolveColumns(rows, opts);
const header = columns.map(c => capitalize(c));
const table = new Table({
head: header.map(h => chalk.bold(h)),
style: { head: [], border: [] },
wordWrap: true,
wrapOnWordBoundary: true,
});
for (const row of rows) {
table.push(columns.map(c => {
const v = (row as Record<string, unknown>)[c];
return v === null || v === undefined ? '' : String(v);
}));
}
console.log();
if (opts.title) console.log(chalk.dim(` ${opts.title}`));
console.log(table.toString());
const footer: string[] = [];
footer.push(`${rows.length} items`);
if (opts.elapsed) footer.push(`${opts.elapsed.toFixed(1)}s`);
if (opts.source) footer.push(opts.source);
if (opts.footerExtra) footer.push(opts.footerExtra);
console.log(chalk.dim(footer.join(' · ')));
}
function renderJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
function renderPlain(data: unknown, opts: RenderOptions): void {
const rows = normalizeRows(data);
if (!rows.length) return;
// Single-row single-field shortcuts for chat-style commands.
if (rows.length === 1) {
const row = rows[0];
const entries = Object.entries(row);
if (entries.length === 1) {
const [key, value] = entries[0];
if (key === 'response' || key === 'content' || key === 'text' || key === 'value') {
console.log(String(value ?? ''));
return;
}
}
}
rows.forEach((row, index) => {
const entries = Object.entries(row).filter(([, value]) => value !== undefined && value !== null && String(value) !== '');
entries.forEach(([key, value]) => {
console.log(`${key}: ${value}`);
});
if (index < rows.length - 1) console.log('');
});
}
function renderMarkdown(data: unknown, opts: RenderOptions): void {
const rows = normalizeRows(data);
if (!rows.length) return;
const columns = resolveColumns(rows, opts);
console.log('| ' + columns.join(' | ') + ' |');
console.log('| ' + columns.map(() => '---').join(' | ') + ' |');
for (const row of rows) {
console.log('| ' + columns.map(c => String((row as Record<string, unknown>)[c] ?? '')).join(' | ') + ' |');
}
}
function renderCsv(data: unknown, opts: RenderOptions): void {
const rows = normalizeRows(data);
if (!rows.length) return;
const columns = resolveColumns(rows, opts);
console.log(columns.join(','));
for (const row of rows) {
console.log(columns.map(c => {
const v = String((row as Record<string, unknown>)[c] ?? '');
return v.includes(',') || v.includes('"') || v.includes('\n') || v.includes('\r')
? `"${v.replace(/"/g, '""')}"` : v;
}).join(','));
}
}
function renderYaml(data: unknown): void {
console.log(yaml.dump(data, { sortKeys: false, lineWidth: 120, noRefs: true }));
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}