# Output Architecture Design This document describes the output abstraction layer for the Paseo CLI, enabling structured data output with multiple format options. ## Overview Commands should return **structured data objects**, not formatted strings. A separate rendering layer transforms this data into the requested output format. This separation enables: 1. **Testability** - Tests verify structured data without parsing strings 2. **Flexibility** - Easy to add new output formats 3. **Consistency** - Uniform formatting across all commands ### Inspiration from Existing CLIs This design draws from patterns in established CLIs: - **Docker CLI** - Uses Go templates with `--format` flag, provides `table` and `json` directives - **kubectl** - Supports `-o json`, `-o yaml`, `-o wide`, and custom columns - **GitHub CLI** - Uses `--json` with field selection, plus `--jq` and `--template` post-processors Sources: - [Docker CLI Formatting](https://docs.docker.com/engine/cli/formatting/) - [kubectl Output Formatting](https://www.baeldung.com/ops/kubectl-output-format) - [GitHub CLI Formatting](https://cli.github.com/manual/gh_help_formatting) ## Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ Command Execution │ │ │ │ parseArgs() → executeCommand() → CommandResult │ └─────────────────────────────────┬───────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Output Renderer │ │ │ │ CommandResult + OutputOptions → formatted string │ │ │ │ Renderers: │ │ - TableRenderer (default, human-readable) │ │ - JsonRenderer (machine-readable) │ │ - YamlRenderer (machine-readable) │ │ - QuietRenderer (minimal, IDs only) │ └─────────────────────────────────┬───────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ stdout/stderr │ └─────────────────────────────────────────────────────────────┘ ``` ## Type Definitions ### Output Options ```typescript type OutputFormat = 'table' | 'json' | 'yaml' interface OutputOptions { format: OutputFormat quiet: boolean // Minimal output (IDs only) noHeaders: boolean // Omit table headers noColor: boolean // Disable color output } ``` ### Command Result Commands return a `CommandResult` that contains structured data plus metadata for formatting: ```typescript interface CommandResult { /** The structured data to render */ data: T /** Schema describing how to render this data */ schema: OutputSchema } 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 } 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 */ color?: (value: unknown, item: T) => string | undefined } ``` ### Single vs List Results Commands may return either a single item or a list: ```typescript // For commands returning a single item (e.g., `agent show `) interface SingleResult extends CommandResult { type: 'single' data: T } // For commands returning a list (e.g., `agent list`) interface ListResult extends CommandResult { type: 'list' data: T[] } // Union type for command handlers type AnyCommandResult = SingleResult | ListResult ``` ## Example: Agent List Command ### Data Type ```typescript interface AgentListItem { id: string title: string status: 'running' | 'idle' | 'error' provider: string cwd: string createdAt: string } ``` ### Schema Definition ```typescript const agentListSchema: OutputSchema = { idField: 'id', columns: [ { header: 'ID', field: 'id', width: 8, }, { header: 'TITLE', field: 'title', width: 30, }, { 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', }, { header: 'CWD', field: 'cwd', }, ], } ``` ### Command Implementation ```typescript async function agentListCommand(options: CommandOptions): Promise> { const client = await connectToDaemon(options) const agents = client.listAgents() const data = agents.map(agent => ({ id: agent.agentId, title: agent.title ?? '(untitled)', status: mapLifecycleStatus(agent.lifecycle), provider: agent.agentType, cwd: agent.cwd, createdAt: agent.createdAt, })) return { type: 'list', data, schema: agentListSchema, } } ``` ## Renderer Implementations ### Table Renderer The default renderer for human-readable output: ```typescript function renderTable(result: ListResult, options: OutputOptions): string { const { data, schema } = result if (data.length === 0) { return '' // Or a "no items" message } const rows: string[][] = [] // Add header row (unless noHeaders) if (!options.noHeaders) { rows.push(schema.columns.map(col => col.header)) } // Add data rows for (const item of data) { const row = schema.columns.map(col => { const value = typeof col.field === 'function' ? col.field(item) : item[col.field] return String(value ?? '') }) rows.push(row) } // Calculate column widths const widths = schema.columns.map((col, i) => { const maxContent = Math.max(...rows.map(row => stripAnsi(row[i]).length)) return col.width ? Math.max(col.width, maxContent) : maxContent }) // Format and join return rows.map((row, rowIndex) => { return row.map((cell, colIndex) => { const col = schema.columns[colIndex] const width = widths[colIndex] let formatted = padCell(cell, width, col.align ?? 'left') // Apply color (skip header row) if (rowIndex > 0 && col.color && !options.noColor) { const colorName = col.color(cell, data[rowIndex - 1]) if (colorName) { formatted = applyColor(formatted, colorName) } } return formatted }).join(' ') }).join('\n') } ``` ### JSON Renderer ```typescript function renderJson(result: AnyCommandResult, options: OutputOptions): string { const { data, schema } = result const output = schema.serialize ? schema.serialize(data) : data return JSON.stringify(output, null, 2) } ``` ### YAML Renderer ```typescript import YAML from 'yaml' function renderYaml(result: AnyCommandResult, options: OutputOptions): string { const { data, schema } = result const output = schema.serialize ? schema.serialize(data) : data return YAML.stringify(output) } ``` ### Quiet Renderer Returns only the ID field(s): ```typescript function renderQuiet(result: AnyCommandResult, options: OutputOptions): string { const { data, schema } = result const getId = typeof schema.idField === 'function' ? schema.idField : (item: T) => String(item[schema.idField as keyof T]) if (result.type === 'single') { return getId(data as T) } return (data as T[]).map(getId).join('\n') } ``` ## Error Output Errors are handled separately from success output and always go to stderr: ```typescript interface CommandError { code: string // Machine-readable error code message: string // Human-readable message details?: unknown // Additional context } function renderError(error: CommandError, options: OutputOptions): string { if (options.format === 'json') { return JSON.stringify({ error }, null, 2) } if (options.format === 'yaml') { return YAML.stringify({ error }) } // Table/default format return chalk.red(`Error: ${error.message}`) } ``` ## Streaming Output For commands like `logs -f` and `attach`, streaming requires a different approach: ```typescript interface StreamingResult { type: 'stream' schema: OutputSchema /** Async iterator yielding items as they arrive */ stream: AsyncIterable } ``` ### Streaming Renderer ```typescript async function renderStream( result: StreamingResult, options: OutputOptions, write: (chunk: string) => void ): Promise { const { stream, schema } = result // For JSON, output newline-delimited JSON (NDJSON) if (options.format === 'json') { for await (const item of stream) { write(JSON.stringify(item) + '\n') } return } // For table format, render each item as a row let headerWritten = false for await (const item of stream) { if (!headerWritten && !options.noHeaders) { write(renderTableHeader(schema) + '\n') headerWritten = true } write(renderTableRow(item, schema, options) + '\n') } } ``` ### NDJSON for Streaming When `--json` (or `--format json`) is used with streaming commands, output is newline-delimited JSON (NDJSON) for easy parsing: ``` {"timestamp":"2024-01-15T10:30:00Z","type":"stdout","content":"Hello"} {"timestamp":"2024-01-15T10:30:01Z","type":"stdout","content":"World"} ``` This allows consumers to process output line-by-line without buffering the entire stream. ## Testing ### Testing Structured Data Tests can directly verify the structured data without parsing formatted output: ```typescript describe('agent list', () => { it('returns agents with correct structure', async () => { const result = await agentListCommand({ host: testHost }) expect(result.type).toBe('list') expect(result.data).toHaveLength(2) expect(result.data[0]).toMatchObject({ id: expect.any(String), title: 'Test Agent', status: 'running', }) }) it('uses correct schema for table output', async () => { const result = await agentListCommand({ host: testHost }) expect(result.schema.idField).toBe('id') expect(result.schema.columns.map(c => c.header)).toEqual([ 'ID', 'TITLE', 'STATUS', 'PROVIDER', 'CWD' ]) }) }) ``` ### Testing Renderers Renderer tests verify formatting independently: ```typescript describe('table renderer', () => { it('formats data as aligned table', () => { const result: ListResult = { type: 'list', data: [ { id: 'abc123', title: 'Agent 1', status: 'running', ... }, { id: 'def456', title: 'Agent 2', status: 'idle', ... }, ], schema: agentListSchema, } const output = renderTable(result, { format: 'table', quiet: false, ... }) expect(output).toContain('ID') expect(output).toContain('abc123') expect(output).toContain('Agent 1') }) }) ``` ### E2E Tests E2E tests can verify both structured data (for correctness) and formatted output (for UX): ```typescript // Verify JSON output is valid and contains expected data test('agent list --json', async () => { const output = await ctx.paseo('agent list --json') const data = JSON.parse(output.stdout) expect(data).toBeInstanceOf(Array) expect(data[0]).toHaveProperty('id') }) // Verify table output looks correct test('agent list shows table headers', async () => { const output = await ctx.paseo('agent list') expect(output.stdout).toMatch(/ID\s+TITLE\s+STATUS/) }) ``` ## Integration with Command Framework ### Global Options Add output options to the root command: ```typescript program .option('-o, --format ', 'Output format: table, json, yaml', 'table') .option('--json', 'Output in JSON format (alias for --format json)') .option('-q, --quiet', 'Minimal output (IDs only)') .option('--no-headers', 'Omit table headers') .option('--no-color', 'Disable colored output') ``` ### Command Handler Wrapper A wrapper function handles the rendering: ```typescript function withOutput( handler: (options: CommandOptions) => Promise> ) { return async (options: CommandOptions) => { try { const result = await handler(options) const output = render(result, options) process.stdout.write(output + '\n') } catch (error) { const errorOutput = renderError(toCommandError(error), options) process.stderr.write(errorOutput + '\n') process.exit(1) } } } // Usage program .command('list') .description('List agents') .action(withOutput(agentListCommand)) ``` ## Implementation Plan 1. **Phase 1: Core Types** - Define `CommandResult`, `OutputSchema`, `ColumnDef` types - Implement basic table renderer - Implement JSON renderer 2. **Phase 2: Integration** - Add global output options to CLI - Create `withOutput` wrapper - Migrate `daemon status` command as proof of concept 3. **Phase 3: Full Coverage** - Add YAML renderer - Add quiet renderer - Migrate all existing commands 4. **Phase 4: Streaming** - Implement `StreamingResult` type - Add streaming renderers - Apply to `logs` and `attach` commands ## Open Questions 1. **Should we support Go templates like Docker/gh?** This adds flexibility but also complexity. For v1, predefined formats are likely sufficient. 2. **How to handle nested data in tables?** Options: - Flatten (e.g., `config.timeout` becomes `TIMEOUT` column) - Skip in table, include in JSON/YAML - Use nested tables for detail views 3. **Should quiet mode support custom fields?** e.g., `--quiet=title` to output titles instead of IDs.