feat(cli): implement output abstraction layer

This commit is contained in:
Mohamed Boudra
2026-01-28 22:57:15 +07:00
parent aa5663a854
commit a7f39f79ba
12 changed files with 916 additions and 2 deletions
+5
View File
@@ -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 <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
+62
View File
@@ -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<Agent> = {
* 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<Agent> = {
* 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'
+34
View File
@@ -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<T>(
result: AnyCommandResult<T>,
_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<T>(item: T, serialize?: (data: T) => unknown): string {
const output = serialize ? serialize(item) : item
return JSON.stringify(output)
}
+27
View File
@@ -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<T>(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<T>(
result: AnyCommandResult<T>,
_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')
}
}
+103
View File
@@ -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<T>(
result: AnyCommandResult<T>,
options: Partial<OutputOptions> = {}
): 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<OutputOptions> = {}
): 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}`
}
+196
View File
@@ -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<string, ChalkInstance> = {
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<T>(item: T, field: keyof T | ((item: T) => unknown)): unknown {
return typeof field === 'function' ? field(item) : item[field]
}
/** Render a single table row */
function renderRow<T>(
item: T,
columns: ColumnDef<T>[],
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<T>(
columns: ColumnDef<T>[],
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<T>(
data: T[],
columns: ColumnDef<T>[],
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<T>(
result: AnyCommandResult<T>,
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<T>[]
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<T>(
schema: OutputSchema<T>,
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<T>(
item: T,
schema: OutputSchema<T>,
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)
}
+79
View File
@@ -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<T> 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<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 - returns chalk color name */
color?: (value: unknown, item: T) => string | undefined
}
/** Schema describing how to render command output */
export 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
}
/** Result type for commands returning a single item */
export interface SingleResult<T> {
type: 'single'
/** The structured data to render */
data: T
/** Schema describing how to render this data (for item type T) */
schema: OutputSchema<T>
}
/** Result type for commands returning a list */
export interface ListResult<T> {
type: 'list'
/** The structured data to render */
data: T[]
/** Schema describing how to render this data (for item type T) */
schema: OutputSchema<T>
}
/** Union type for all command results */
export type AnyCommandResult<T> = SingleResult<T> | ListResult<T>
/** Base interface for command results (deprecated, use SingleResult or ListResult) */
export type CommandResult<T> = SingleResult<T> | ListResult<T>
/** Structured error for command failures */
export interface CommandError {
/** Machine-readable error code */
code: string
/** Human-readable message */
message: string
/** Additional context */
details?: unknown
}
+77
View File
@@ -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<OutputOptions> {
[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<T, Args extends unknown[]>(
handler: (...args: [...Args, CommandOptions, Command]) => Promise<AnyCommandResult<T>>
): (...args: [...Args, CommandOptions, Command]) => Promise<void> {
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> = {}
): OutputOptions {
return { ...defaultOutputOptions, ...partial }
}
+35
View File
@@ -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<T>(
result: AnyCommandResult<T>,
_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<T>(item: T, serialize?: (data: T) => unknown): string {
const output = serialize ? serialize(item) : item
return YAML.stringify(output)
}