feat(cli): implement output abstraction layer
This commit is contained in:
Generated
+17
-1
@@ -26369,7 +26369,8 @@
|
|||||||
"@paseo/server": "*",
|
"@paseo/server": "*",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -26402,6 +26403,21 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"packages/desktop": {
|
||||||
"name": "@paseo/desktop",
|
"name": "@paseo/desktop",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"@paseo/server": "*",
|
"@paseo/server": "*",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ws": "^8.5.8",
|
"@types/ws": "^8.5.8",
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export function createCli(): Command {
|
|||||||
.name('paseo')
|
.name('paseo')
|
||||||
.description('Paseo CLI - control your AI coding agents from the command line')
|
.description('Paseo CLI - control your AI coding agents from the command line')
|
||||||
.version(VERSION, '-v, --version', 'output the version number')
|
.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
|
// Placeholder subcommands
|
||||||
program
|
program
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<Agent> = {
|
||||||
|
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<Agent> = {
|
||||||
|
type: 'list',
|
||||||
|
data: testAgents,
|
||||||
|
schema: agentSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleResult: SingleResult<Agent> = {
|
||||||
|
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<Agent> = {
|
||||||
|
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<Agent> = {
|
||||||
|
...agentSchema,
|
||||||
|
serialize: (agent) => ({ agentId: agent.id, name: agent.title }),
|
||||||
|
}
|
||||||
|
const customResult: SingleResult<Agent> = {
|
||||||
|
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<Agent> = {
|
||||||
|
...agentSchema,
|
||||||
|
idField: (agent) => `${agent.provider}/${agent.id}`,
|
||||||
|
}
|
||||||
|
const customResult: SingleResult<Agent> = {
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user