diff --git a/src/cli/dashboard/_shared/base.ts b/src/cli/dashboard/_shared/base.ts index de089e21..673289a6 100644 --- a/src/cli/dashboard/_shared/base.ts +++ b/src/cli/dashboard/_shared/base.ts @@ -3,9 +3,11 @@ import { TRPCClientError } from '@trpc/client'; import chalk from 'chalk'; import { type DashboardClient, createDashboardClient } from './client.js'; import { type CliConfig, loadConfig } from './config.js'; -import { printDetail, printTable } from './format.js'; +import { printCompact, printCsv, printDetail, printTable } from './format.js'; import { withSpinner } from './spinner.js'; +export type OutputFormat = 'table' | 'json' | 'csv' | 'compact'; + export function extractBaseFlags(argv: string[]): { server?: string; org?: string } | undefined { let server: string | undefined; let org: string | undefined; @@ -28,7 +30,18 @@ export function extractBaseFlags(argv: string[]): { server?: string; org?: strin export abstract class DashboardCommand extends Command { static override baseFlags = { - json: Flags.boolean({ description: 'Output as JSON', default: false }), + format: Flags.string({ + description: 'Output format (table, json, csv, compact)', + options: ['table', 'json', 'csv', 'compact'], + default: 'table', + }), + json: Flags.boolean({ + description: 'Output as JSON (alias for --format json)', + default: false, + }), + columns: Flags.string({ + description: 'Comma-separated list of columns to display (e.g. --columns id,status,agent)', + }), server: Flags.string({ description: 'Override server URL' }), org: Flags.string({ description: 'Override organization context (admin/superadmin only)' }), }; @@ -67,6 +80,14 @@ export abstract class DashboardCommand extends Command { return extractBaseFlags(this.argv); } + /** + * Resolve the effective output format. --json flag takes precedence as alias for json format. + */ + protected resolveFormat(flags: { format?: string; json?: boolean }): OutputFormat { + if (flags.json) return 'json'; + return (flags.format as OutputFormat | undefined) ?? 'table'; + } + protected outputJson(data: unknown): void { console.log(JSON.stringify(data, null, 2)); } @@ -74,8 +95,9 @@ export abstract class DashboardCommand extends Command { protected outputTable( rows: Record[], columns: { key: string; header: string; format?: (v: unknown) => string }[], + emptyMessage?: string, ): void { - printTable(rows, columns); + printTable(rows, columns, emptyMessage); } protected outputDetail( @@ -85,6 +107,50 @@ export abstract class DashboardCommand extends Command { printDetail(obj, fields); } + /** + * Filter columns based on the --columns flag value. + * Returns the original columns if no filter is specified. + */ + protected filterColumns(columns: T[], columnsFlag?: string): T[] { + if (!columnsFlag) return columns; + const keys = columnsFlag + .split(',') + .map((k) => k.trim()) + .filter(Boolean); + if (keys.length === 0) return columns; + return columns.filter((col) => keys.includes(col.key)); + } + + /** + * Output rows in the format specified by the --format / --json flags. + * Handles column filtering via --columns flag automatically. + */ + protected outputFormatted( + rows: Record[], + columns: { key: string; header: string; format?: (v: unknown) => string }[], + flags: { format?: string; json?: boolean; columns?: string }, + data?: unknown, + emptyMessage?: string, + ): void { + const fmt = this.resolveFormat(flags); + const filteredColumns = this.filterColumns(columns, flags.columns); + + switch (fmt) { + case 'json': + this.outputJson(data ?? rows); + break; + case 'csv': + printCsv(rows, filteredColumns); + break; + case 'compact': + printCompact(rows, filteredColumns); + break; + default: + printTable(rows, filteredColumns, emptyMessage); + break; + } + } + /** * Print a success message with a green ✓ prefix. */ @@ -104,9 +170,14 @@ export abstract class DashboardCommand extends Command { * Automatically suppressed when --json flag is active, NO_COLOR=1, or CI=1. */ protected withSpinner(message: string, fn: () => Promise): Promise { - // Suppress spinner when --json flag is present + // Suppress spinner when --json flag or non-table format is present const isJson = this.argv.includes('--json'); - return withSpinner(message, fn, { silent: isJson }); + const hasFormat = this.argv.some((a) => a === '--format' || a.startsWith('--format=')); + const formatVal = + this.argv.find((a) => a.startsWith('--format='))?.slice('--format='.length) ?? + (hasFormat ? this.argv[this.argv.indexOf('--format') + 1] : undefined); + const silent = isJson || (formatVal !== undefined && formatVal !== 'table'); + return withSpinner(message, fn, { silent }); } protected handleError(err: unknown): never { diff --git a/src/cli/dashboard/_shared/format.ts b/src/cli/dashboard/_shared/format.ts index df2a23e8..e35403f4 100644 --- a/src/cli/dashboard/_shared/format.ts +++ b/src/cli/dashboard/_shared/format.ts @@ -10,9 +10,13 @@ interface Column { format?: (value: unknown) => string; } -export function printTable(rows: Record[], columns: Column[]): void { +export function printTable( + rows: Record[], + columns: Column[], + emptyMessage?: string, +): void { if (rows.length === 0) { - console.log(' (no results)'); + console.log(` ${emptyMessage ?? '(no results)'}`); return; } @@ -47,6 +51,43 @@ export function printTable(rows: Record[], columns: Column[]): } } +export function printCsv(rows: Record[], columns: Column[]): void { + // Print header row + const headers = columns.map((col) => csvQuote(col.header)); + console.log(headers.join(',')); + + // Print data rows + for (const row of columns.length === 0 ? [] : rows) { + const values = columns.map((col) => { + const raw = col.format ? col.format(row[col.key]) : String(row[col.key] ?? ''); + // Strip ANSI escape codes for CSV output + const plain = raw.replace(ANSI_STRIP_RE, ''); + return csvQuote(plain); + }); + console.log(values.join(',')); + } +} + +function csvQuote(value: string): string { + // Quote the value if it contains commas, quotes, or newlines + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export function printCompact(rows: Record[], columns: Column[]): void { + for (const row of rows) { + const parts = columns.map((col) => { + const raw = col.format ? col.format(row[col.key]) : String(row[col.key] ?? ''); + // Strip ANSI escape codes for compact output + const plain = raw.replace(ANSI_STRIP_RE, ''); + return `${col.key}=${plain}`; + }); + console.log(parts.join(' ')); + } +} + interface FieldMap { label: string; format?: (value: unknown) => string; diff --git a/src/cli/dashboard/agents/list.ts b/src/cli/dashboard/agents/list.ts index b8802229..47312867 100644 --- a/src/cli/dashboard/agents/list.ts +++ b/src/cli/dashboard/agents/list.ts @@ -18,25 +18,23 @@ export default class AgentsList extends DashboardCommand { projectId: flags['project-id'], }); - if (flags.json) { - this.outputJson(configs); - return; - } - - if (configs.length === 0) { - this.log('No agents enabled for this project. Use `cascade agents create` to enable one.'); - return; - } - - this.outputTable(configs as unknown as Record[], [ + const columns = [ { key: 'id', header: 'ID' }, { key: 'agentType', header: 'Agent Type' }, { key: 'projectId', header: 'Project' }, { key: 'model', header: 'Model' }, { key: 'maxIterations', header: 'Max Iter' }, { key: 'agentEngine', header: 'Engine' }, - { key: 'prompt', header: 'Prompt', format: (v) => (v ? 'custom' : '-') }, - ]); + { key: 'prompt', header: 'Prompt', format: (v: unknown) => (v ? 'custom' : '-') }, + ]; + + this.outputFormatted( + configs as unknown as Record[], + columns, + flags, + configs, + 'No agents enabled for this project. Use `cascade agents create` to enable one.', + ); } catch (err) { this.handleError(err); } diff --git a/src/cli/dashboard/definitions/list.ts b/src/cli/dashboard/definitions/list.ts index 0cbb5fc6..8c1c5365 100644 --- a/src/cli/dashboard/definitions/list.ts +++ b/src/cli/dashboard/definitions/list.ts @@ -13,28 +13,30 @@ export default class DefinitionsList extends DashboardCommand { try { const definitions = await this.client.agentDefinitions.list.query(); - if (flags.json) { - this.outputJson(definitions); - return; - } + const rows = definitions.map((d) => ({ + agentType: d.agentType, + label: d.definition.identity.label, + emoji: d.definition.identity.emoji, + isBuiltin: d.isBuiltin, + })); - this.outputTable( - definitions.map((d) => ({ - agentType: d.agentType, - label: d.definition.identity.label, - emoji: d.definition.identity.emoji, - isBuiltin: d.isBuiltin, - })), - [ - { key: 'agentType', header: 'Agent Type' }, - { key: 'label', header: 'Label' }, - { key: 'emoji', header: 'Emoji' }, - { - key: 'isBuiltin', - header: 'Built-in', - format: (v) => (v ? 'yes' : 'no'), - }, - ], + const columns = [ + { key: 'agentType', header: 'Agent Type' }, + { key: 'label', header: 'Label' }, + { key: 'emoji', header: 'Emoji' }, + { + key: 'isBuiltin', + header: 'Built-in', + format: (v: unknown) => (v ? 'yes' : 'no'), + }, + ]; + + this.outputFormatted( + rows, + columns, + flags, + definitions, + 'No agent definitions found. Import one with: cascade definitions import --file ', ); } catch (err) { this.handleError(err); diff --git a/src/cli/dashboard/projects/list.ts b/src/cli/dashboard/projects/list.ts index 23d374ae..c0102269 100644 --- a/src/cli/dashboard/projects/list.ts +++ b/src/cli/dashboard/projects/list.ts @@ -13,19 +13,22 @@ export default class ProjectsList extends DashboardCommand { try { const projects = await this.client.projects.listFull.query(); - if (flags.json) { - this.outputJson(projects); - return; - } - - this.outputTable(projects as unknown as Record[], [ + const columns = [ { key: 'id', header: 'ID' }, { key: 'name', header: 'Name' }, { key: 'repo', header: 'Repo' }, { key: 'baseBranch', header: 'Base Branch' }, { key: 'model', header: 'Model' }, { key: 'agentEngine', header: 'Engine' }, - ]); + ]; + + this.outputFormatted( + projects as unknown as Record[], + columns, + flags, + projects, + 'No projects found. Create one with: cascade projects create --id --name --repo ', + ); } catch (err) { this.handleError(err); } diff --git a/src/cli/dashboard/runs/list.ts b/src/cli/dashboard/runs/list.ts index de9129d6..8dae14e4 100644 --- a/src/cli/dashboard/runs/list.ts +++ b/src/cli/dashboard/runs/list.ts @@ -34,24 +34,28 @@ export default class RunsList extends DashboardCommand { order: flags.order as 'asc' | 'desc', }); - if (flags.json) { - this.outputJson(runs); - return; - } - const { data, total } = runs as { data: Record[]; total: number }; - this.outputTable(data, [ - { key: 'id', header: 'ID', format: (v) => String(v ?? '').slice(0, 8) }, + const columns = [ + { key: 'id', header: 'ID', format: (v: unknown) => String(v ?? '').slice(0, 8) }, { key: 'projectId', header: 'Project' }, { key: 'agentType', header: 'Agent' }, { key: 'status', header: 'Status', format: formatStatus }, { key: 'startedAt', header: 'Started', format: formatDate }, { key: 'durationMs', header: 'Duration', format: formatDuration }, { key: 'costUsd', header: 'Cost', format: formatCost }, - ]); + ]; + + this.outputFormatted( + data, + columns, + flags, + runs, + 'No runs found. Try `cascade runs trigger --project --agent-type `', + ); - if (total > data.length) { + const fmt = this.resolveFormat(flags); + if (fmt === 'table' && total > data.length) { this.log(`\nShowing ${data.length} of ${total} runs.`); } } catch (err) { diff --git a/src/cli/dashboard/users/list.ts b/src/cli/dashboard/users/list.ts index f71c6752..59612a85 100644 --- a/src/cli/dashboard/users/list.ts +++ b/src/cli/dashboard/users/list.ts @@ -14,18 +14,21 @@ export default class UsersList extends DashboardCommand { try { const users = await this.client.users.list.query(); - if (flags.json) { - this.outputJson(users); - return; - } - - this.outputTable(users as unknown as Record[], [ + const columns = [ { key: 'id', header: 'ID' }, { key: 'email', header: 'Email' }, { key: 'name', header: 'Name' }, { key: 'role', header: 'Role' }, { key: 'createdAt', header: 'Created', format: formatDate }, - ]); + ]; + + this.outputFormatted( + users as unknown as Record[], + columns, + flags, + users, + 'No users found. Create one with: cascade users create --email --password ', + ); } catch (err) { this.handleError(err); } diff --git a/src/cli/dashboard/webhooklogs/list.ts b/src/cli/dashboard/webhooklogs/list.ts index 62c4bd6e..d23367bd 100644 --- a/src/cli/dashboard/webhooklogs/list.ts +++ b/src/cli/dashboard/webhooklogs/list.ts @@ -24,24 +24,27 @@ export default class WebhookLogsList extends DashboardCommand { offset: flags.offset, }); - if (flags.json) { - this.outputJson(result); - return; - } - - this.outputTable(result.data as unknown as Record[], [ - { key: 'id', header: 'ID', format: (v) => String(v ?? '').slice(0, 8) }, + const columns = [ + { key: 'id', header: 'ID', format: (v: unknown) => String(v ?? '').slice(0, 8) }, { key: 'source', header: 'Source' }, { key: 'eventType', header: 'Event' }, { key: 'statusCode', header: 'Status' }, - { key: 'processed', header: 'Processed', format: (v) => (v ? 'yes' : 'no') }, + { key: 'processed', header: 'Processed', format: (v: unknown) => (v ? 'yes' : 'no') }, { key: 'decisionReason', header: 'Reason', - format: (v) => (v ? String(v).slice(0, 50) : '-'), + format: (v: unknown) => (v ? String(v).slice(0, 50) : '-'), }, { key: 'receivedAt', header: 'Time', format: formatDate }, - ]); + ]; + + this.outputFormatted( + result.data as unknown as Record[], + columns, + flags, + result, + 'No webhook logs found. Webhook logs appear when CASCADE receives events from Trello, GitHub, or JIRA.', + ); } catch (err) { this.handleError(err); } diff --git a/tests/unit/cli/dashboard/base.test.ts b/tests/unit/cli/dashboard/base.test.ts index 754e3474..868fefd4 100644 --- a/tests/unit/cli/dashboard/base.test.ts +++ b/tests/unit/cli/dashboard/base.test.ts @@ -73,6 +73,24 @@ class TestOutputCommand extends DashboardCommand { async callWithSpinner(message: string, fn: () => Promise): Promise { return this.withSpinner(message, fn); } + + callFilterColumns(columns: T[], columnsFlag?: string): T[] { + return this.filterColumns(columns, columnsFlag); + } + + callResolveFormat(flags: { format?: string; json?: boolean }): string { + return this.resolveFormat(flags); + } + + callOutputFormatted( + rows: Record[], + columns: { key: string; header: string; format?: (v: unknown) => string }[], + flags: { format?: string; json?: boolean; columns?: string }, + data?: unknown, + emptyMessage?: string, + ): void { + this.outputFormatted(rows, columns, flags, data, emptyMessage); + } } describe('extractBaseFlags', () => { @@ -283,5 +301,190 @@ describe('DashboardCommand', () => { expect.objectContaining({ silent: true }), ); }); + + it('passes silent=true when --format=csv flag is present', async () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + mockWithSpinner.mockImplementation((_msg: string, fn: () => Promise) => fn()); + + const cmd = new TestOutputCommand(['--format=csv'], {} as never); + await cmd.callWithSpinner('Loading...', async () => null); + + expect(mockWithSpinner).toHaveBeenCalledWith( + 'Loading...', + expect.any(Function), + expect.objectContaining({ silent: true }), + ); + }); + }); + + describe('resolveFormat', () => { + it('returns table by default', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + expect(cmd.callResolveFormat({})).toBe('table'); + }); + + it('returns json when --json flag is true', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + expect(cmd.callResolveFormat({ json: true })).toBe('json'); + }); + + it('returns json when --format json is set', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + expect(cmd.callResolveFormat({ format: 'json' })).toBe('json'); + }); + + it('--json flag takes precedence over --format', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + expect(cmd.callResolveFormat({ format: 'csv', json: true })).toBe('json'); + }); + + it('returns csv when --format csv is set', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + expect(cmd.callResolveFormat({ format: 'csv' })).toBe('csv'); + }); + + it('returns compact when --format compact is set', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + expect(cmd.callResolveFormat({ format: 'compact' })).toBe('compact'); + }); + }); + + describe('filterColumns', () => { + const columns = [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'status', header: 'Status' }, + ]; + + it('returns all columns when no filter provided', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + expect(cmd.callFilterColumns(columns)).toEqual(columns); + }); + + it('returns all columns when empty string provided', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + expect(cmd.callFilterColumns(columns, '')).toEqual(columns); + }); + + it('filters to specific columns', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + const result = cmd.callFilterColumns(columns, 'id,status'); + expect(result).toHaveLength(2); + expect(result.map((c) => c.key)).toEqual(['id', 'status']); + }); + + it('handles whitespace around column names', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + const result = cmd.callFilterColumns(columns, 'id , name'); + expect(result).toHaveLength(2); + expect(result.map((c) => c.key)).toEqual(['id', 'name']); + }); + + it('returns empty array when no columns match', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + const result = cmd.callFilterColumns(columns, 'nonexistent'); + expect(result).toHaveLength(0); + }); + }); + + describe('outputFormatted', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + const rows = [ + { id: '1', name: 'Alice', status: 'active' }, + { id: '2', name: 'Bob', status: 'inactive' }, + ]; + const columns = [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'status', header: 'Status' }, + ]; + + it('outputs table format by default', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + cmd.callOutputFormatted(rows, columns, {}); + + // header + separator + 2 rows = 4 calls + expect(consoleSpy).toHaveBeenCalledTimes(4); + expect(consoleSpy.mock.calls[0][0]).toContain('ID'); + }); + + it('outputs JSON format when format=json', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + const data = { items: rows }; + cmd.callOutputFormatted(rows, columns, { format: 'json' }, data); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + const output = consoleSpy.mock.calls[0][0]; + expect(output).toContain('"items"'); + }); + + it('outputs CSV format when format=csv', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + cmd.callOutputFormatted(rows, columns, { format: 'csv' }); + + // header + 2 rows = 3 calls + expect(consoleSpy).toHaveBeenCalledTimes(3); + expect(consoleSpy.mock.calls[0][0]).toBe('ID,Name,Status'); + expect(consoleSpy.mock.calls[1][0]).toBe('1,Alice,active'); + }); + + it('outputs compact format when format=compact', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + cmd.callOutputFormatted(rows, columns, { format: 'compact' }); + + expect(consoleSpy).toHaveBeenCalledTimes(2); + expect(consoleSpy.mock.calls[0][0]).toBe('id=1 name=Alice status=active'); + }); + + it('filters columns when --columns flag provided', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + cmd.callOutputFormatted(rows, columns, { format: 'csv', columns: 'id,status' }); + + expect(consoleSpy.mock.calls[0][0]).toBe('ID,Status'); + expect(consoleSpy.mock.calls[1][0]).toBe('1,active'); + }); + + it('uses rows as JSON data when no data param provided', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + cmd.callOutputFormatted(rows, columns, { format: 'json' }); + + const output = JSON.parse(consoleSpy.mock.calls[0][0]); + expect(output).toHaveLength(2); + expect(output[0].id).toBe('1'); + }); + + it('shows emptyMessage when table format with empty rows', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const cmd = new TestOutputCommand([], {} as never); + cmd.callOutputFormatted([], columns, {}, undefined, 'No items yet. Create one!'); + + expect(consoleSpy).toHaveBeenCalledWith(' No items yet. Create one!'); + }); }); }); diff --git a/tests/unit/cli/dashboard/format.test.ts b/tests/unit/cli/dashboard/format.test.ts index f8d2eb71..04bd834b 100644 --- a/tests/unit/cli/dashboard/format.test.ts +++ b/tests/unit/cli/dashboard/format.test.ts @@ -17,6 +17,8 @@ import { formatDate, formatDuration, formatStatus, + printCompact, + printCsv, printDetail, printTable, } from '../../../../src/cli/dashboard/_shared/format.js'; @@ -138,12 +140,18 @@ describe('printTable', () => { consoleSpy.mockRestore(); }); - it('prints "(no results)" for empty rows', () => { + it('prints "(no results)" for empty rows when no emptyMessage provided', () => { printTable([], [{ key: 'id', header: 'ID' }]); expect(consoleSpy).toHaveBeenCalledWith(' (no results)'); }); + it('prints custom emptyMessage for empty rows', () => { + printTable([], [{ key: 'id', header: 'ID' }], 'No items found. Create one first.'); + + expect(consoleSpy).toHaveBeenCalledWith(' No items found. Create one first.'); + }); + it('prints header and rows', () => { printTable( [ @@ -189,6 +197,151 @@ describe('printTable', () => { }); }); +describe('printCsv', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('prints header row for empty data', () => { + printCsv( + [], + [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + ], + ); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy.mock.calls[0][0]).toBe('ID,Name'); + }); + + it('prints header + data rows', () => { + printCsv( + [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ], + [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + ], + ); + + expect(consoleSpy).toHaveBeenCalledTimes(3); + expect(consoleSpy.mock.calls[0][0]).toBe('ID,Name'); + expect(consoleSpy.mock.calls[1][0]).toBe('1,Alice'); + expect(consoleSpy.mock.calls[2][0]).toBe('2,Bob'); + }); + + it('quotes values containing commas', () => { + printCsv([{ name: 'Smith, John' }], [{ key: 'name', header: 'Name' }]); + + expect(consoleSpy.mock.calls[1][0]).toBe('"Smith, John"'); + }); + + it('quotes values containing double quotes and escapes them', () => { + printCsv([{ name: 'Say "hello"' }], [{ key: 'name', header: 'Name' }]); + + expect(consoleSpy.mock.calls[1][0]).toBe('"Say ""hello"""'); + }); + + it('quotes header containing comma', () => { + printCsv([{ val: 'x' }], [{ key: 'val', header: 'Key, Value' }]); + + expect(consoleSpy.mock.calls[0][0]).toBe('"Key, Value"'); + }); + + it('applies format function and strips ANSI codes', () => { + printCsv( + [{ cost: 1.5 }], + [{ key: 'cost', header: 'Cost', format: (v) => `$${Number(v).toFixed(2)}` }], + ); + + expect(consoleSpy.mock.calls[0][0]).toBe('Cost'); + expect(consoleSpy.mock.calls[1][0]).toBe('$1.50'); + }); + + it('handles undefined values as empty string', () => { + printCsv( + [{ id: 1 }], + [ + { key: 'id', header: 'ID' }, + { key: 'missing', header: 'Missing' }, + ], + ); + + expect(consoleSpy.mock.calls[1][0]).toBe('1,'); + }); +}); + +describe('printCompact', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('prints nothing for empty rows', () => { + printCompact([], [{ key: 'id', header: 'ID' }]); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('prints one line per row in key=value format', () => { + printCompact( + [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ], + [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + ], + ); + + expect(consoleSpy).toHaveBeenCalledTimes(2); + expect(consoleSpy.mock.calls[0][0]).toBe('id=1 name=Alice'); + expect(consoleSpy.mock.calls[1][0]).toBe('id=2 name=Bob'); + }); + + it('applies format function and strips ANSI codes', () => { + printCompact( + [{ cost: 1.5 }], + [{ key: 'cost', header: 'Cost', format: (v) => `$${Number(v).toFixed(2)}` }], + ); + + expect(consoleSpy.mock.calls[0][0]).toBe('cost=$1.50'); + }); + + it('handles undefined values as empty string', () => { + printCompact( + [{ id: '1' }], + [ + { key: 'id', header: 'ID' }, + { key: 'missing', header: 'Missing' }, + ], + ); + + expect(consoleSpy.mock.calls[0][0]).toBe('id=1 missing='); + }); + + it('uses column key (not header) in output', () => { + printCompact([{ agentType: 'implementation' }], [{ key: 'agentType', header: 'Agent Type' }]); + + expect(consoleSpy.mock.calls[0][0]).toBe('agentType=implementation'); + }); +}); + describe('printDetail', () => { let consoleSpy: ReturnType;