Files
paseo/packages/cli/docs/output-architecture.md

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:

  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:

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

  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.