15 KiB
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:
- Testability - Tests verify structured data without parsing strings
- Flexibility - Easy to add new output formats
- Consistency - Uniform formatting across all commands
Inspiration from Existing CLIs
This design draws from patterns in established CLIs:
- Docker CLI - Uses Go templates with
--formatflag, providestableandjsondirectives - kubectl - Supports
-o json,-o yaml,-o wide, and custom columns - GitHub CLI - Uses
--jsonwith field selection, plus--jqand--templatepost-processors
Sources:
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Command Execution │
│ │
│ parseArgs() → executeCommand() → CommandResult<T> │
└─────────────────────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Output Renderer │
│ │
│ CommandResult<T> + OutputOptions → formatted string │
│ │
│ Renderers: │
│ - TableRenderer (default, human-readable) │
│ - JsonRenderer (machine-readable) │
│ - YamlRenderer (machine-readable) │
│ - QuietRenderer (minimal, IDs only) │
└─────────────────────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ stdout/stderr │
└─────────────────────────────────────────────────────────────┘
Type Definitions
Output Options
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<T> that contains structured data plus metadata for formatting:
interface CommandResult<T> {
/** The structured data to render */
data: T
/** Schema describing how to render this data */
schema: OutputSchema<T>
}
interface OutputSchema<T> {
/** Field to use for quiet mode (--quiet outputs just this) */
idField: keyof T | ((item: T) => string)
/** Column definitions for table output */
columns: ColumnDef<T>[]
/** Optional: transform data before JSON/YAML output */
serialize?: (data: T) => unknown
}
interface ColumnDef<T> {
/** 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:
// For commands returning a single item (e.g., `agent show <id>`)
interface SingleResult<T> extends CommandResult<T> {
type: 'single'
data: T
}
// For commands returning a list (e.g., `agent list`)
interface ListResult<T> extends CommandResult<T[]> {
type: 'list'
data: T[]
}
// Union type for command handlers
type AnyCommandResult<T> = SingleResult<T> | ListResult<T>
Example: Agent List Command
Data Type
interface AgentListItem {
id: string
title: string
status: 'running' | 'idle' | 'error'
provider: string
cwd: string
createdAt: string
}
Schema Definition
const agentListSchema: OutputSchema<AgentListItem> = {
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
async function agentListCommand(options: CommandOptions): Promise<ListResult<AgentListItem>> {
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:
function renderTable<T>(result: ListResult<T>, 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
function renderJson<T>(result: AnyCommandResult<T>, options: OutputOptions): string {
const { data, schema } = result
const output = schema.serialize ? schema.serialize(data) : data
return JSON.stringify(output, null, 2)
}
YAML Renderer
import YAML from 'yaml'
function renderYaml<T>(result: AnyCommandResult<T>, 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):
function renderQuiet<T>(result: AnyCommandResult<T>, 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:
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:
interface StreamingResult<T> {
type: 'stream'
schema: OutputSchema<T>
/** Async iterator yielding items as they arrive */
stream: AsyncIterable<T>
}
Streaming Renderer
async function renderStream<T>(
result: StreamingResult<T>,
options: OutputOptions,
write: (chunk: string) => void
): Promise<void> {
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:
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:
describe('table renderer', () => {
it('formats data as aligned table', () => {
const result: ListResult<AgentListItem> = {
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):
// 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:
program
.option('-o, --format <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:
function withOutput<T>(
handler: (options: CommandOptions) => Promise<AnyCommandResult<T>>
) {
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
-
Phase 1: Core Types
- Define
CommandResult,OutputSchema,ColumnDeftypes - Implement basic table renderer
- Implement JSON renderer
- Define
-
Phase 2: Integration
- Add global output options to CLI
- Create
withOutputwrapper - Migrate
daemon statuscommand as proof of concept
-
Phase 3: Full Coverage
- Add YAML renderer
- Add quiet renderer
- Migrate all existing commands
-
Phase 4: Streaming
- Implement
StreamingResulttype - Add streaming renderers
- Apply to
logsandattachcommands
- Implement
Open Questions
-
Should we support Go templates like Docker/gh? This adds flexibility but also complexity. For v1, predefined formats are likely sufficient.
-
How to handle nested data in tables? Options:
- Flatten (e.g.,
config.timeoutbecomesTIMEOUTcolumn) - Skip in table, include in JSON/YAML
- Use nested tables for detail views
- Flatten (e.g.,
-
Should quiet mode support custom fields? e.g.,
--quiet=titleto output titles instead of IDs.