From a7f39f79ba995f7f4d570c540d53b3b8a09cc67b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 28 Jan 2026 22:57:15 +0700 Subject: [PATCH] feat(cli): implement output abstraction layer --- package-lock.json | 18 +- packages/cli/package.json | 3 +- packages/cli/src/cli.ts | 5 + packages/cli/src/output/index.ts | 62 ++++++ packages/cli/src/output/json.ts | 34 +++ packages/cli/src/output/quiet.ts | 27 +++ packages/cli/src/output/render.ts | 103 +++++++++ packages/cli/src/output/table.ts | 196 +++++++++++++++++ packages/cli/src/output/types.ts | 79 +++++++ packages/cli/src/output/with-output.ts | 77 +++++++ packages/cli/src/output/yaml.ts | 35 ++++ packages/cli/tests/output.test.mts | 279 +++++++++++++++++++++++++ 12 files changed, 916 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/output/index.ts create mode 100644 packages/cli/src/output/json.ts create mode 100644 packages/cli/src/output/quiet.ts create mode 100644 packages/cli/src/output/render.ts create mode 100644 packages/cli/src/output/table.ts create mode 100644 packages/cli/src/output/types.ts create mode 100644 packages/cli/src/output/with-output.ts create mode 100644 packages/cli/src/output/yaml.ts create mode 100644 packages/cli/tests/output.test.mts diff --git a/package-lock.json b/package-lock.json index 5acabcf6b..b878d0b2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26369,7 +26369,8 @@ "@paseo/server": "*", "chalk": "^5.3.0", "commander": "^12.0.0", - "ws": "^8.14.2" + "ws": "^8.14.2", + "yaml": "^2.8.2" }, "bin": { "paseo": "bin/paseo" @@ -26402,6 +26403,21 @@ "node": ">=18" } }, + "packages/cli/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "packages/desktop": { "name": "@paseo/desktop", "version": "1.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 7a6004fee..c70511ba8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,7 +14,8 @@ "@paseo/server": "*", "chalk": "^5.3.0", "commander": "^12.0.0", - "ws": "^8.14.2" + "ws": "^8.14.2", + "yaml": "^2.8.2" }, "devDependencies": { "@types/ws": "^8.5.8", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2a4339934..6c9a0c6d2 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -10,6 +10,11 @@ export function createCli(): Command { .name('paseo') .description('Paseo CLI - control your AI coding agents from the command line') .version(VERSION, '-v, --version', 'output the version number') + // Global output options + .option('-f, --format ', 'output format: table, json, yaml', 'table') + .option('-q, --quiet', 'minimal output (IDs only)') + .option('--no-headers', 'omit table headers') + .option('--no-color', 'disable colored output') // Placeholder subcommands program diff --git a/packages/cli/src/output/index.ts b/packages/cli/src/output/index.ts new file mode 100644 index 000000000..703da8f03 --- /dev/null +++ b/packages/cli/src/output/index.ts @@ -0,0 +1,62 @@ +/** + * Output abstraction layer for the Paseo CLI. + * + * This module provides structured output rendering with support for multiple formats: + * - table: Human-readable aligned tables (default) + * - json: Machine-readable JSON + * - yaml: Machine-readable YAML + * - quiet: Minimal output (IDs only) + * + * @example + * ```typescript + * import { withOutput, render, type ListResult, type OutputSchema } from './output/index.js' + * + * // Define your data type + * interface Agent { id: string; title: string; status: string } + * + * // Define how to render it + * const schema: OutputSchema = { + * idField: 'id', + * columns: [ + * { header: 'ID', field: 'id' }, + * { header: 'TITLE', field: 'title' }, + * { header: 'STATUS', field: 'status', color: (v) => v === 'running' ? 'green' : undefined }, + * ], + * } + * + * // Return structured data from commands + * const result: ListResult = { + * type: 'list', + * data: agents, + * schema, + * } + * + * // Render with options + * const output = render(result, { format: 'json' }) + * ``` + */ + +// Types +export type { + OutputFormat, + OutputOptions, + ColumnDef, + OutputSchema, + CommandResult, + SingleResult, + ListResult, + AnyCommandResult, + CommandError, +} from './types.js' + +// Renderers +export { renderTable, renderTableHeader, renderTableRow } from './table.js' +export { renderJson, renderJsonLine } from './json.js' +export { renderYaml, renderYamlDoc } from './yaml.js' +export { renderQuiet } from './quiet.js' + +// Main render function +export { render, renderError, toCommandError, defaultOutputOptions } from './render.js' + +// Command wrapper +export { withOutput, createOutputOptions, type CommandOptions } from './with-output.js' diff --git a/packages/cli/src/output/json.ts b/packages/cli/src/output/json.ts new file mode 100644 index 000000000..b4d58e122 --- /dev/null +++ b/packages/cli/src/output/json.ts @@ -0,0 +1,34 @@ +/** + * JSON renderer for CLI output. + * + * Renders structured data as formatted JSON for machine consumption. + */ + +import type { AnyCommandResult, OutputOptions } from './types.js' + +/** Render command result as JSON */ +export function renderJson( + result: AnyCommandResult, + _options: OutputOptions +): string { + const { schema } = result + + // Apply custom serializer if provided + if (schema.serialize) { + if (result.type === 'list') { + const serialized = result.data.map((item) => schema.serialize!(item)) + return JSON.stringify(serialized, null, 2) + } else { + const serialized = schema.serialize(result.data) + return JSON.stringify(serialized, null, 2) + } + } + + return JSON.stringify(result.data, null, 2) +} + +/** Render a single item as JSON line (for NDJSON streaming) */ +export function renderJsonLine(item: T, serialize?: (data: T) => unknown): string { + const output = serialize ? serialize(item) : item + return JSON.stringify(output) +} diff --git a/packages/cli/src/output/quiet.ts b/packages/cli/src/output/quiet.ts new file mode 100644 index 000000000..2be3ea29b --- /dev/null +++ b/packages/cli/src/output/quiet.ts @@ -0,0 +1,27 @@ +/** + * Quiet renderer for CLI output. + * + * Outputs only ID fields, one per line. Useful for scripting and pipelines. + */ + +import type { AnyCommandResult, OutputOptions } from './types.js' + +/** Extract ID from item using schema definition */ +function getId(item: T, idField: keyof T | ((item: T) => string)): string { + if (typeof idField === 'function') { + return idField(item) + } + return String(item[idField]) +} + +/** Render command result in quiet mode (IDs only) */ +export function renderQuiet( + result: AnyCommandResult, + _options: OutputOptions +): string { + if (result.type === 'single') { + return getId(result.data, result.schema.idField) + } else { + return result.data.map((item) => getId(item, result.schema.idField)).join('\n') + } +} diff --git a/packages/cli/src/output/render.ts b/packages/cli/src/output/render.ts new file mode 100644 index 000000000..5215f7e89 --- /dev/null +++ b/packages/cli/src/output/render.ts @@ -0,0 +1,103 @@ +/** + * Main render dispatcher for CLI output. + * + * Selects the appropriate renderer based on output options. + */ + +import chalk from 'chalk' +import YAML from 'yaml' +import type { AnyCommandResult, CommandError, OutputOptions } from './types.js' +import { renderTable } from './table.js' +import { renderJson } from './json.js' +import { renderYaml } from './yaml.js' +import { renderQuiet } from './quiet.js' + +/** Default output options */ +export const defaultOutputOptions: OutputOptions = { + format: 'table', + quiet: false, + noHeaders: false, + noColor: false, +} + +/** Render command result to string based on output options */ +export function render( + result: AnyCommandResult, + options: Partial = {} +): string { + const opts: OutputOptions = { ...defaultOutputOptions, ...options } + + // Quiet mode takes precedence + if (opts.quiet) { + return renderQuiet(result, opts) + } + + // Dispatch to format-specific renderer + switch (opts.format) { + case 'json': + return renderJson(result, opts) + case 'yaml': + return renderYaml(result, opts) + case 'table': + default: + return renderTable(result, opts) + } +} + +/** Convert an unknown error to a CommandError */ +export function toCommandError(error: unknown): CommandError { + if (isCommandError(error)) { + return error + } + + if (error instanceof Error) { + return { + code: 'UNKNOWN_ERROR', + message: error.message, + details: error.stack, + } + } + + return { + code: 'UNKNOWN_ERROR', + message: String(error), + } +} + +/** Type guard for CommandError */ +function isCommandError(error: unknown): error is CommandError { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + 'message' in error && + typeof (error as CommandError).code === 'string' && + typeof (error as CommandError).message === 'string' + ) +} + +/** Render an error to string based on output options */ +export function renderError( + error: CommandError, + options: Partial = {} +): string { + const opts: OutputOptions = { ...defaultOutputOptions, ...options } + + if (opts.format === 'json') { + return JSON.stringify({ error }, null, 2) + } + + if (opts.format === 'yaml') { + return YAML.stringify({ error }) + } + + // Table/default format: human-readable error + const prefix = opts.noColor ? 'Error: ' : chalk.red('Error: ') + const message = error.message + + if (error.details && typeof error.details === 'string') { + return `${prefix}${message}\n${error.details}` + } + + return `${prefix}${message}` +} diff --git a/packages/cli/src/output/table.ts b/packages/cli/src/output/table.ts new file mode 100644 index 000000000..a22f96796 --- /dev/null +++ b/packages/cli/src/output/table.ts @@ -0,0 +1,196 @@ +/** + * Table renderer for CLI output. + * + * Renders structured data as aligned ASCII tables with optional color support. + */ + +import chalk, { type ChalkInstance } from 'chalk' +import type { + AnyCommandResult, + ColumnDef, + OutputOptions, + OutputSchema, +} from './types.js' + +// ANSI escape code regex for stripping colors when measuring width +const ANSI_REGEX = + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g + +/** Strip ANSI escape codes from a string */ +function stripAnsi(str: string): string { + return str.replace(ANSI_REGEX, '') +} + +/** Get visible string length (excluding ANSI codes) */ +function visibleLength(str: string): number { + return stripAnsi(str).length +} + +/** Pad a cell to the specified width with alignment */ +function padCell( + cell: string, + width: number, + align: 'left' | 'right' | 'center' +): string { + const visible = visibleLength(cell) + const padding = Math.max(0, width - visible) + + switch (align) { + case 'right': + return ' '.repeat(padding) + cell + case 'center': { + const left = Math.floor(padding / 2) + const right = padding - left + return ' '.repeat(left) + cell + ' '.repeat(right) + } + case 'left': + default: + return cell + ' '.repeat(padding) + } +} + +/** Apply a chalk color to a string */ +function applyColor(str: string, colorName: string): string { + // Map color names to chalk methods + const colorMap: Record = { + red: chalk.red, + green: chalk.green, + blue: chalk.blue, + yellow: chalk.yellow, + cyan: chalk.cyan, + magenta: chalk.magenta, + white: chalk.white, + gray: chalk.gray, + grey: chalk.grey, + dim: chalk.dim, + bold: chalk.bold, + } + + const colorFn = colorMap[colorName] + return colorFn ? colorFn(str) : str +} + +/** Extract value from item using field definition */ +function getValue(item: T, field: keyof T | ((item: T) => unknown)): unknown { + return typeof field === 'function' ? field(item) : item[field] +} + +/** Render a single table row */ +function renderRow( + item: T, + columns: ColumnDef[], + widths: number[], + options: OutputOptions +): string { + return columns + .map((col, colIndex) => { + const value = getValue(item, col.field) + let cell = String(value ?? '') + const width = widths[colIndex] + + // Apply color if enabled + if (col.color && !options.noColor) { + const colorName = col.color(value, item) + if (colorName) { + cell = applyColor(cell, colorName) + } + } + + return padCell(cell, width, col.align ?? 'left') + }) + .join(' ') +} + +/** Render header row */ +function renderHeader( + columns: ColumnDef[], + widths: number[], + options: OutputOptions +): string { + const headerRow = columns + .map((col, i) => padCell(col.header, widths[i], col.align ?? 'left')) + .join(' ') + + return options.noColor ? headerRow : chalk.bold(headerRow) +} + +/** Calculate column widths based on content and hints */ +function calculateWidths( + data: T[], + columns: ColumnDef[], + includeHeaders: boolean +): number[] { + return columns.map((col) => { + // Start with header width if including headers + let maxWidth = includeHeaders ? col.header.length : 0 + + // Check all data values + for (const item of data) { + const value = getValue(item, col.field) + const str = String(value ?? '') + maxWidth = Math.max(maxWidth, visibleLength(str)) + } + + // Apply width hint if specified (minimum width) + if (col.width) { + maxWidth = Math.max(maxWidth, col.width) + } + + return maxWidth + }) +} + +/** Render a list result as a table */ +export function renderTable( + result: AnyCommandResult, + options: OutputOptions +): string { + const { schema } = result + const data = result.type === 'list' ? result.data : [result.data] + + if (data.length === 0) { + return '' + } + + const columns = schema.columns as ColumnDef[] + const includeHeaders = !options.noHeaders + const widths = calculateWidths(data, columns, includeHeaders) + + const lines: string[] = [] + + // Add header row + if (includeHeaders) { + lines.push(renderHeader(columns, widths, options)) + } + + // Add data rows + for (const item of data) { + lines.push(renderRow(item, columns, widths, options)) + } + + return lines.join('\n') +} + +/** Render just a table header (for streaming) */ +export function renderTableHeader( + schema: OutputSchema, + options: OutputOptions, + widths?: number[] +): string { + const columns = schema.columns + const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length) + return renderHeader(columns, actualWidths, options) +} + +/** Render just a table row (for streaming) */ +export function renderTableRow( + item: T, + schema: OutputSchema, + options: OutputOptions, + widths?: number[] +): string { + const columns = schema.columns + const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length) + return renderRow(item, columns, actualWidths, options) +} diff --git a/packages/cli/src/output/types.ts b/packages/cli/src/output/types.ts new file mode 100644 index 000000000..f05315d3b --- /dev/null +++ b/packages/cli/src/output/types.ts @@ -0,0 +1,79 @@ +/** + * Output format types for the Paseo CLI. + * + * This module defines the structured data types used by the output abstraction layer. + * Commands return CommandResult which contains both data and rendering metadata. + */ + +/** Supported output formats */ +export type OutputFormat = 'table' | 'json' | 'yaml' + +/** Options controlling output rendering */ +export interface OutputOptions { + /** Output format (table, json, yaml) */ + format: OutputFormat + /** Minimal output - IDs only */ + quiet: boolean + /** Omit table headers */ + noHeaders: boolean + /** Disable color output */ + noColor: boolean +} + +/** Column definition for table output */ +export interface ColumnDef { + /** Header text for the column */ + header: string + /** Field key or accessor function */ + field: keyof T | ((item: T) => unknown) + /** Optional width hint (characters) */ + width?: number + /** Optional alignment */ + align?: 'left' | 'right' | 'center' + /** Optional color function - returns chalk color name */ + color?: (value: unknown, item: T) => string | undefined +} + +/** Schema describing how to render command output */ +export interface OutputSchema { + /** Field to use for quiet mode (--quiet outputs just this) */ + idField: keyof T | ((item: T) => string) + /** Column definitions for table output */ + columns: ColumnDef[] + /** Optional: transform data before JSON/YAML output */ + serialize?: (data: T) => unknown +} + +/** Result type for commands returning a single item */ +export interface SingleResult { + type: 'single' + /** The structured data to render */ + data: T + /** Schema describing how to render this data (for item type T) */ + schema: OutputSchema +} + +/** Result type for commands returning a list */ +export interface ListResult { + type: 'list' + /** The structured data to render */ + data: T[] + /** Schema describing how to render this data (for item type T) */ + schema: OutputSchema +} + +/** Union type for all command results */ +export type AnyCommandResult = SingleResult | ListResult + +/** Base interface for command results (deprecated, use SingleResult or ListResult) */ +export type CommandResult = SingleResult | ListResult + +/** Structured error for command failures */ +export interface CommandError { + /** Machine-readable error code */ + code: string + /** Human-readable message */ + message: string + /** Additional context */ + details?: unknown +} diff --git a/packages/cli/src/output/with-output.ts b/packages/cli/src/output/with-output.ts new file mode 100644 index 000000000..e71bf3e97 --- /dev/null +++ b/packages/cli/src/output/with-output.ts @@ -0,0 +1,77 @@ +/** + * Command wrapper for automatic output rendering. + * + * Wraps command handlers to automatically render results and handle errors. + */ + +import type { Command } from 'commander' +import type { AnyCommandResult, OutputOptions } from './types.js' +import { render, renderError, toCommandError, defaultOutputOptions } from './render.js' + +/** Options that include output settings from global options */ +export interface CommandOptions extends Partial { + [key: string]: unknown +} + +/** Extract output options from command options */ +function extractOutputOptions(options: CommandOptions): OutputOptions { + return { + format: (options.format as OutputOptions['format']) ?? defaultOutputOptions.format, + quiet: options.quiet ?? defaultOutputOptions.quiet, + noHeaders: options.headers === false, // Commander uses --no-headers -> headers: false + noColor: options.color === false, // Commander uses --no-color -> color: false + } +} + +/** + * Wrap a command handler to automatically render output. + * + * The wrapped handler should return a CommandResult. The wrapper will: + * 1. Call the handler + * 2. Render the result using the appropriate format + * 3. Write to stdout + * 4. Handle errors by rendering to stderr and exiting with code 1 + * + * @example + * ```typescript + * program + * .command('list') + * .action(withOutput(async (options) => { + * const data = await fetchData() + * return { type: 'list', data, schema } + * })) + * ``` + */ +export function withOutput( + handler: (...args: [...Args, CommandOptions, Command]) => Promise> +): (...args: [...Args, CommandOptions, Command]) => Promise { + return async (...args) => { + // Last two args are options and command + const options = args[args.length - 2] as CommandOptions + const outputOptions = extractOutputOptions(options) + + try { + const result = await handler(...args) + const output = render(result, outputOptions) + + if (output) { + process.stdout.write(output + '\n') + } + } catch (error) { + const commandError = toCommandError(error) + const errorOutput = renderError(commandError, outputOptions) + process.stderr.write(errorOutput + '\n') + process.exit(1) + } + } +} + +/** + * Helper to create output options from partial input. + * Useful for testing or manual rendering. + */ +export function createOutputOptions( + partial: Partial = {} +): OutputOptions { + return { ...defaultOutputOptions, ...partial } +} diff --git a/packages/cli/src/output/yaml.ts b/packages/cli/src/output/yaml.ts new file mode 100644 index 000000000..d554caef0 --- /dev/null +++ b/packages/cli/src/output/yaml.ts @@ -0,0 +1,35 @@ +/** + * YAML renderer for CLI output. + * + * Renders structured data as YAML for machine consumption and human readability. + */ + +import YAML from 'yaml' +import type { AnyCommandResult, OutputOptions } from './types.js' + +/** Render command result as YAML */ +export function renderYaml( + result: AnyCommandResult, + _options: OutputOptions +): string { + const { schema } = result + + // Apply custom serializer if provided + if (schema.serialize) { + if (result.type === 'list') { + const serialized = result.data.map((item) => schema.serialize!(item)) + return YAML.stringify(serialized) + } else { + const serialized = schema.serialize(result.data) + return YAML.stringify(serialized) + } + } + + return YAML.stringify(result.data) +} + +/** Render a single item as YAML document (for streaming) */ +export function renderYamlDoc(item: T, serialize?: (data: T) => unknown): string { + const output = serialize ? serialize(item) : item + return YAML.stringify(output) +} diff --git a/packages/cli/tests/output.test.mts b/packages/cli/tests/output.test.mts new file mode 100644 index 000000000..d82fd94c4 --- /dev/null +++ b/packages/cli/tests/output.test.mts @@ -0,0 +1,279 @@ +#!/usr/bin/env npx tsx +/** + * Tests for the output abstraction layer. + * + * Verifies that renderers correctly format structured data. + */ + +import assert from 'node:assert' +import { + render, + renderTable, + renderJson, + renderYaml, + renderQuiet, + renderError, + toCommandError, + createOutputOptions, + type ListResult, + type SingleResult, + type OutputSchema, + type CommandError, +} from '../src/output/index.js' + +// Test data types +interface Agent { + id: string + title: string + status: 'running' | 'idle' | 'error' + provider: string +} + +// Schema for agents +const agentSchema: OutputSchema = { + idField: 'id', + columns: [ + { header: 'ID', field: 'id', width: 8 }, + { header: 'TITLE', field: 'title', width: 20 }, + { + header: 'STATUS', + field: 'status', + color: (value) => { + switch (value) { + case 'running': + return 'green' + case 'idle': + return 'dim' + case 'error': + return 'red' + default: + return undefined + } + }, + }, + { header: 'PROVIDER', field: 'provider' }, + ], +} + +// Test data +const testAgents: Agent[] = [ + { id: 'abc123', title: 'Feature Implementation', status: 'running', provider: 'claude' }, + { id: 'def456', title: 'Bug Fix', status: 'idle', provider: 'codex' }, + { id: 'ghi789', title: 'Failed Task', status: 'error', provider: 'claude' }, +] + +const listResult: ListResult = { + type: 'list', + data: testAgents, + schema: agentSchema, +} + +const singleResult: SingleResult = { + type: 'single', + data: testAgents[0], + schema: agentSchema, +} + +// Test utilities +let passed = 0 +let failed = 0 + +function test(name: string, fn: () => void): void { + try { + fn() + console.log(`✓ ${name}`) + passed++ + } catch (error) { + console.error(`✗ ${name}`) + console.error(` ${error instanceof Error ? error.message : error}`) + failed++ + } +} + +// Table renderer tests +console.log('\n=== Table Renderer ===\n') + +test('renderTable formats list data with headers', () => { + const output = renderTable(listResult, createOutputOptions({ noColor: true })) + assert.ok(output.includes('ID'), 'Should include ID header') + assert.ok(output.includes('TITLE'), 'Should include TITLE header') + assert.ok(output.includes('STATUS'), 'Should include STATUS header') + assert.ok(output.includes('abc123'), 'Should include first agent ID') + assert.ok(output.includes('Feature Implementation'), 'Should include first agent title') +}) + +test('renderTable omits headers when noHeaders is true', () => { + const output = renderTable(listResult, createOutputOptions({ noHeaders: true, noColor: true })) + assert.ok(!output.includes('ID '), 'Should not include header row') + assert.ok(output.includes('abc123'), 'Should still include data') +}) + +test('renderTable handles empty data', () => { + const emptyResult: ListResult = { + type: 'list', + data: [], + schema: agentSchema, + } + const output = renderTable(emptyResult, createOutputOptions()) + assert.strictEqual(output, '', 'Should return empty string for empty data') +}) + +test('renderTable handles single result', () => { + const output = renderTable(singleResult, createOutputOptions({ noColor: true })) + assert.ok(output.includes('abc123'), 'Should include agent ID') + assert.ok(output.includes('Feature Implementation'), 'Should include agent title') +}) + +// JSON renderer tests +console.log('\n=== JSON Renderer ===\n') + +test('renderJson outputs valid JSON for list', () => { + const output = renderJson(listResult, createOutputOptions()) + const parsed = JSON.parse(output) + assert.ok(Array.isArray(parsed), 'Should be an array') + assert.strictEqual(parsed.length, 3, 'Should have 3 items') + assert.strictEqual(parsed[0].id, 'abc123', 'Should include correct data') +}) + +test('renderJson outputs valid JSON for single item', () => { + const output = renderJson(singleResult, createOutputOptions()) + const parsed = JSON.parse(output) + assert.strictEqual(parsed.id, 'abc123', 'Should include correct data') + assert.strictEqual(parsed.title, 'Feature Implementation', 'Should include correct title') +}) + +test('renderJson uses custom serializer when provided', () => { + const customSchema: OutputSchema = { + ...agentSchema, + serialize: (agent) => ({ agentId: agent.id, name: agent.title }), + } + const customResult: SingleResult = { + type: 'single', + data: testAgents[0], + schema: customSchema, + } + const output = renderJson(customResult, createOutputOptions()) + const parsed = JSON.parse(output) + assert.ok('agentId' in parsed, 'Should use custom serialization') + assert.ok('name' in parsed, 'Should use custom field names') +}) + +// YAML renderer tests +console.log('\n=== YAML Renderer ===\n') + +test('renderYaml outputs valid YAML for list', () => { + const output = renderYaml(listResult, createOutputOptions()) + assert.ok(output.includes('- id: abc123'), 'Should format as YAML list') + assert.ok(output.includes('title: Feature Implementation'), 'Should include title') +}) + +test('renderYaml outputs valid YAML for single item', () => { + const output = renderYaml(singleResult, createOutputOptions()) + assert.ok(output.includes('id: abc123'), 'Should include ID') + assert.ok(!output.startsWith('-'), 'Should not be a list') +}) + +// Quiet renderer tests +console.log('\n=== Quiet Renderer ===\n') + +test('renderQuiet outputs only IDs for list', () => { + const output = renderQuiet(listResult, createOutputOptions()) + const lines = output.split('\n') + assert.strictEqual(lines.length, 3, 'Should have 3 lines') + assert.strictEqual(lines[0], 'abc123', 'First line should be first ID') + assert.strictEqual(lines[1], 'def456', 'Second line should be second ID') +}) + +test('renderQuiet outputs only ID for single item', () => { + const output = renderQuiet(singleResult, createOutputOptions()) + assert.strictEqual(output, 'abc123', 'Should output only the ID') +}) + +test('renderQuiet supports function idField', () => { + const customSchema: OutputSchema = { + ...agentSchema, + idField: (agent) => `${agent.provider}/${agent.id}`, + } + const customResult: SingleResult = { + type: 'single', + data: testAgents[0], + schema: customSchema, + } + const output = renderQuiet(customResult, createOutputOptions()) + assert.strictEqual(output, 'claude/abc123', 'Should use function to extract ID') +}) + +// Main render dispatcher tests +console.log('\n=== Render Dispatcher ===\n') + +test('render uses table format by default', () => { + const output = render(listResult, { noColor: true }) + assert.ok(output.includes('ID'), 'Should use table format with headers') +}) + +test('render uses json format when specified', () => { + const output = render(listResult, { format: 'json' }) + const parsed = JSON.parse(output) + assert.ok(Array.isArray(parsed), 'Should be valid JSON array') +}) + +test('render uses yaml format when specified', () => { + const output = render(listResult, { format: 'yaml' }) + assert.ok(output.includes('- id:'), 'Should be valid YAML') +}) + +test('render uses quiet mode when quiet is true', () => { + const output = render(listResult, { quiet: true }) + assert.strictEqual(output, 'abc123\ndef456\nghi789', 'Should output only IDs') +}) + +test('quiet mode takes precedence over format', () => { + const output = render(listResult, { format: 'json', quiet: true }) + assert.strictEqual(output, 'abc123\ndef456\nghi789', 'Quiet should override format') +}) + +// Error rendering tests +console.log('\n=== Error Rendering ===\n') + +test('renderError formats error for table format', () => { + const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' } + const output = renderError(error, { noColor: true }) + assert.ok(output.includes('Error:'), 'Should include Error prefix') + assert.ok(output.includes('Agent not found'), 'Should include message') +}) + +test('renderError formats error as JSON', () => { + const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' } + const output = renderError(error, { format: 'json' }) + const parsed = JSON.parse(output) + assert.ok('error' in parsed, 'Should have error property') + assert.strictEqual(parsed.error.code, 'NOT_FOUND', 'Should include error code') +}) + +test('renderError formats error as YAML', () => { + const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' } + const output = renderError(error, { format: 'yaml' }) + assert.ok(output.includes('error:'), 'Should have error key') + assert.ok(output.includes('code: NOT_FOUND'), 'Should include error code') +}) + +test('toCommandError converts Error to CommandError', () => { + const error = new Error('Something went wrong') + const commandError = toCommandError(error) + assert.strictEqual(commandError.code, 'UNKNOWN_ERROR', 'Should use UNKNOWN_ERROR code') + assert.strictEqual(commandError.message, 'Something went wrong', 'Should preserve message') +}) + +test('toCommandError passes through CommandError', () => { + const error: CommandError = { code: 'CUSTOM', message: 'Custom error' } + const commandError = toCommandError(error) + assert.strictEqual(commandError.code, 'CUSTOM', 'Should preserve code') + assert.strictEqual(commandError.message, 'Custom error', 'Should preserve message') +}) + +// Summary +console.log('\n=== Summary ===\n') +console.log(`Passed: ${passed}`) +console.log(`Failed: ${failed}`) + +process.exit(failed > 0 ? 1 : 0)