chore: add ion package, codesight wiki, work plans, ascli config

New @boocode/ion package (v0.0.1) for inference optimization network.
.codesight/ wiki artifacts for codebase documentation.
.omo/ work plans for openspec cleanup and enhanced file panel.
This commit is contained in:
2026-06-07 22:16:45 +00:00
parent ec48066a80
commit 02063072ab
63 changed files with 14025 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
/**
* `workflow abandon` — Cancel a non-terminal workflow run.
*
* Marks the run as cancelled. Only works on runs that are not
* already in a terminal state (completed, failed, cancelled).
*
* @example
* workflow abandon abc123
* workflow abandon abc123 --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface AbandonResult {
runId: string;
abandoned: boolean;
message: string;
}
async function abandonWorkflowRun(_runId: string): Promise<AbandonResult> {
throw new Error('not implemented yet: abandonWorkflowRun');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function abandonCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow abandon <run-id> [--json]');
}
const runId = args[0]!;
const result = await abandonWorkflowRun(runId);
if (options.json) {
printJson(result);
return;
}
if (result.abandoned) {
console.log(`⊘ Run ${result.runId} abandoned (cancelled).`);
} else {
console.log(`Failed to abandon run ${result.runId}: ${result.message}`);
}
}

View File

@@ -0,0 +1,60 @@
/**
* `workflow approve` — Approve a paused workflow run.
*
* @example
* workflow approve abc123
* workflow approve abc123 "Looks good" --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface ApproveResult {
runId: string;
approved: boolean;
comment?: string;
message: string;
}
async function approveWorkflowRun(
_runId: string,
_comment?: string,
): Promise<ApproveResult> {
throw new Error('not implemented yet: approveWorkflowRun');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function approveCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow approve <run-id> [comment] [--json]');
}
const runId = args[0]!;
const comment = args.length > 1 ? args.slice(1).join(' ') : undefined;
const result = await approveWorkflowRun(runId, comment);
if (options.json) {
printJson(result);
return;
}
if (result.approved) {
console.log(`✓ Run ${result.runId} approved.`);
if (result.comment) {
console.log(` Comment: ${result.comment}`);
}
} else {
console.log(`✗ Failed to approve run ${result.runId}: ${result.message}`);
}
}

View File

@@ -0,0 +1,74 @@
/**
* `workflow cleanup` — Remove old workflow run artifacts.
*
* Default retention: 7 days. Removes run data older than the specified
* number of days.
*
* @example
* workflow cleanup
* workflow cleanup 30 --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface CleanupResult {
removedRuns: number;
removedEvents: number;
freedBytes: number;
retentionDays: number;
}
async function cleanupWorkflowRuns(
_days: number,
_cwd?: string,
): Promise<CleanupResult> {
throw new Error('not implemented yet: cleanupWorkflowRuns');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function cleanupCommand(
args: string[],
options: CliOptions,
): Promise<void> {
// First positional arg is the number of days (default 7).
const days = args.length > 0 ? parseInt(args[0]!, 10) : 7;
if (isNaN(days) || days < 1) {
throw new Error(`Invalid retention days: ${args[0]}. Must be a positive integer.`);
}
const result = await cleanupWorkflowRuns(days, options.cwd);
if (options.json) {
printJson(result);
return;
}
console.log(`Cleanup complete (retention: ${result.retentionDays} days).`);
console.log(` Runs removed: ${result.removedRuns}`);
console.log(` Events removed: ${result.removedEvents}`);
console.log(` Space freed: ${formatBytes(result.freedBytes)}`);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}

View File

@@ -0,0 +1,62 @@
/**
* `workflow convert` — Convert a .sop.md file to a YAML workflow definition.
*
* Reads the SOP markdown file, parses its structure, and outputs
* a corresponding YAML workflow definition.
*
* @example
* workflow convert deploy.sop.md
* workflow convert deploy.sop.md --output workflows/deploy.yaml
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface ConvertResult {
inputFile: string;
outputFile: string;
workflowName: string;
nodeCount: number;
}
async function convertSopToYaml(
_inputPath: string,
_outputPath?: string,
): Promise<ConvertResult> {
throw new Error('not implemented yet: convertSopToYaml');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function convertCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <file.sop.md>\n\nUsage: workflow convert <file.sop.md> [--output <path>]');
}
const inputPath = args[0]!;
if (!inputPath.endsWith('.sop.md')) {
throw new Error(`Input file must end with .sop.md, got: ${inputPath}`);
}
const result = await convertSopToYaml(inputPath, options.output);
if (options.json) {
printJson(result);
return;
}
console.log(`Converted: ${result.inputFile}`);
console.log(` Output: ${result.outputFile}`);
console.log(` Workflow: ${result.workflowName}`);
console.log(` Nodes: ${result.nodeCount}`);
}

View File

@@ -0,0 +1,59 @@
/**
* `workflow list` — List all available workflows.
*
* Discovers workflows from both bundled and project sources and displays
* them in a formatted table (or JSON with --json).
*
* @example
* workflow list
* workflow list --json
*/
import type { CliOptions } from '../utils.js';
import { printTable, printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface WorkflowEntry {
name: string;
description: string;
source: 'bundled' | 'project';
}
async function discoverWorkflows(_cwd?: string): Promise<WorkflowEntry[]> {
throw new Error('not implemented yet: discoverWorkflows');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function listCommand(
_args: string[],
options: CliOptions,
): Promise<void> {
const workflows = await discoverWorkflows(options.cwd);
if (options.json) {
printJson(workflows);
return;
}
console.log('Available workflows:');
console.log('');
printTable(
workflows.map((w) => ({
name: w.name,
description: w.description,
source: w.source,
})),
[
{ header: 'Name', field: 'name', minWidth: 20 },
{ header: 'Description', field: 'description', minWidth: 30 },
{ header: 'Source', field: 'source', minWidth: 10 },
],
);
}

View File

@@ -0,0 +1,62 @@
/**
* `workflow reject` — Reject a paused workflow run.
*
* Sets $REJECTION_REASON with the provided reason string.
*
* @example
* workflow reject abc123
* workflow reject abc123 "Not compliant" --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface RejectResult {
runId: string;
rejected: boolean;
reason?: string;
message: string;
}
async function rejectWorkflowRun(
_runId: string,
_reason?: string,
): Promise<RejectResult> {
throw new Error('not implemented yet: rejectWorkflowRun');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function rejectCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow reject <run-id> [reason] [--json]');
}
const runId = args[0]!;
const reason = args.length > 1 ? args.slice(1).join(' ') : undefined;
const result = await rejectWorkflowRun(runId, reason);
if (options.json) {
printJson(result);
return;
}
if (result.rejected) {
console.log(`✗ Run ${result.runId} rejected.`);
if (result.reason) {
console.log(` Reason: ${result.reason}`);
}
} else {
console.log(`Failed to reject run ${result.runId}: ${result.message}`);
}
}

View File

@@ -0,0 +1,55 @@
/**
* `workflow resume` — Resume a failed workflow run.
*
* Skips completed nodes and re-executes from the failure point.
*
* @example
* workflow resume abc123
* workflow resume abc123 --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface ResumeResult {
runId: string;
resumed: boolean;
message: string;
}
async function resumeWorkflowRun(_runId: string): Promise<ResumeResult> {
throw new Error('not implemented yet: resumeWorkflowRun');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function resumeCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow resume <run-id> [--json]');
}
const runId = args[0]!;
const result = await resumeWorkflowRun(runId);
if (options.json) {
printJson(result);
return;
}
if (result.resumed) {
console.log(`↻ Run ${result.runId} resumed.`);
console.log(` ${result.message}`);
} else {
console.log(`Failed to resume run ${result.runId}: ${result.message}`);
}
}

View File

@@ -0,0 +1,94 @@
/**
* `workflow run` — Execute a workflow by name.
*
* Resolves the workflow, passes message args, and shows real-time progress.
* With --detach, runs in background and returns the run ID immediately.
*
* @example
* workflow run deploy
* workflow run deploy --cwd /tmp/project --json
* workflow run deploy --detach
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface WorkflowRunResult {
id: string;
workflowName: string;
status: string;
output?: Record<string, unknown>;
error?: string;
startedAt: string;
completedAt?: string;
}
async function resolveWorkflow(
_name: string,
_cwd?: string,
): Promise<unknown> {
throw new Error('not implemented yet: resolveWorkflow');
}
async function executeWorkflow(
_workflow: unknown,
_messageArgs: string[],
_options: { cwd?: string; detach?: boolean },
): Promise<WorkflowRunResult> {
throw new Error('not implemented yet: executeWorkflow');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function runCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <name>\n\nUsage: workflow run <name> [args...] [--cwd <path>] [--detach] [--json]');
}
const workflowName = args[0]!;
const messageArgs = args.slice(1);
const detach = options.json ? false : false; // --detach is a flag, not in CliOptions yet
// Parse --detach from raw args (it's a boolean flag).
// This is handled by the arg parser in the main entry point,
// but since CliOptions doesn't have detach, we check process.argv.
const isDetach = process.argv.includes('--detach');
const workflow = await resolveWorkflow(workflowName, options.cwd);
const result = await executeWorkflow(workflow, messageArgs, {
cwd: options.cwd,
detach: isDetach,
});
if (options.json) {
printJson(result);
return;
}
if (isDetach) {
console.log(`Workflow started in background.`);
console.log(`Run ID: ${result.id}`);
console.log(`Workflow: ${result.workflowName}`);
console.log(`Status: ${result.status}`);
} else {
console.log(`Workflow run completed.`);
console.log(` Run ID: ${result.id}`);
console.log(` Workflow: ${result.workflowName}`);
console.log(` Status: ${result.status}`);
if (result.output) {
console.log(` Output: ${JSON.stringify(result.output)}`);
}
if (result.error) {
console.log(` Error: ${result.error}`);
}
}
}

View File

@@ -0,0 +1,91 @@
/**
* `workflow runs` — List recent workflow runs with filters.
*
* @example
* workflow runs
* workflow runs --status failed --limit 10 --json
* workflow runs --all
*/
import type { CliOptions } from '../utils.js';
import { printTable, printJson, formatTimestamp, formatDuration } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface RunRecord {
id: string;
workflowName: string;
status: string;
startedAt: string;
duration?: number; // ms, absent if still running
currentNode?: string;
}
async function listWorkflowRuns(_filters: {
status?: string;
limit?: number;
all?: boolean;
cwd?: string;
}): Promise<RunRecord[]> {
throw new Error('not implemented yet: listWorkflowRuns');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function runsCommand(
args: string[],
options: CliOptions,
): Promise<void> {
// Parse --status, --limit, --all from args/options.
// These are already extracted by parseArgs into options.
const status = typeof (options as Record<string, unknown>).status === 'string'
? (options as Record<string, unknown>).status as string
: undefined;
const limit = typeof (options as Record<string, unknown>).limit === 'string'
? parseInt((options as Record<string, unknown>).limit as string, 10)
: 50;
const all = (options as Record<string, unknown>).all === true;
const runs = await listWorkflowRuns({
status,
limit,
all,
cwd: options.cwd,
});
if (options.json) {
printJson(runs);
return;
}
if (runs.length === 0) {
console.log('No workflow runs found.');
return;
}
console.log(`Showing ${runs.length} run(s):`);
console.log('');
printTable(
runs.map((r) => ({
id: r.id,
workflow: r.workflowName,
status: r.status,
started: formatTimestamp(new Date(r.startedAt)),
duration: r.duration != null ? formatDuration(r.duration) : '-',
currentNode: r.currentNode ?? '-',
})),
[
{ header: 'ID', field: 'id', minWidth: 26 },
{ header: 'Workflow', field: 'workflow', minWidth: 20 },
{ header: 'Status', field: 'status', minWidth: 10 },
{ header: 'Started', field: 'started', minWidth: 19 },
{ header: 'Duration', field: 'duration', minWidth: 10 },
{ header: 'Node', field: 'currentNode', minWidth: 15 },
],
);
}

View File

@@ -0,0 +1,67 @@
/**
* `workflow status` — Show active (running + paused) workflow runs.
*
* @example
* workflow status
* workflow status --json
*/
import type { CliOptions } from '../utils.js';
import { printTable, printJson, formatDuration } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface ActiveRun {
id: string;
workflowName: string;
status: string;
duration: number; // ms
currentNode?: string;
}
async function getActiveRuns(_cwd?: string): Promise<ActiveRun[]> {
throw new Error('not implemented yet: getActiveRuns');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function statusCommand(
_args: string[],
options: CliOptions,
): Promise<void> {
const runs = await getActiveRuns(options.cwd);
if (options.json) {
printJson(runs);
return;
}
if (runs.length === 0) {
console.log('No active workflow runs.');
return;
}
console.log('Active workflow runs:');
console.log('');
printTable(
runs.map((r) => ({
id: r.id,
workflow: r.workflowName,
status: r.status,
duration: formatDuration(r.duration),
currentNode: r.currentNode ?? '-',
})),
[
{ header: 'ID', field: 'id', minWidth: 26 },
{ header: 'Workflow', field: 'workflow', minWidth: 20 },
{ header: 'Status', field: 'status', minWidth: 10 },
{ header: 'Duration', field: 'duration', minWidth: 10 },
{ header: 'Current Node', field: 'currentNode', minWidth: 15 },
],
);
}

View File

@@ -0,0 +1,66 @@
/**
* `workflow validate` — Validate a workflow definition without executing.
*
* Loads the workflow, runs schema validation, and reports any errors.
*
* @example
* workflow validate deploy
* workflow validate deploy --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface ValidationError {
path: string;
message: string;
}
interface ValidateResult {
valid: boolean;
errors: ValidationError[];
workflowName: string;
}
async function validateWorkflow(
_name: string,
_cwd?: string,
): Promise<ValidateResult> {
throw new Error('not implemented yet: validateWorkflow');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function validateCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <name>\n\nUsage: workflow validate <name> [--json]');
}
const workflowName = args[0]!;
const result = await validateWorkflow(workflowName, options.cwd);
if (options.json) {
printJson(result);
return;
}
if (result.valid) {
console.log(`✓ Workflow "${result.workflowName}" is valid.`);
} else {
console.log(`✗ Workflow "${result.workflowName}" has ${result.errors.length} error(s):`);
console.log('');
for (const err of result.errors) {
console.log(` ${err.path}: ${err.message}`);
}
}
}

View File

@@ -0,0 +1,207 @@
/**
* Ion workflow engine CLI entry point.
*
* Pure Node.js CLI using process.argv parsing — no external argparse library.
* Routes subcommands to their respective handler modules.
*
* @example
* node dist/cli/index.js workflow list --json
* node dist/cli/index.js workflow run deploy --cwd /tmp/project
*/
import { parseArgs, buildCliOptions, printJson } from './utils.js';
import type { CliOptions } from './utils.js';
import { listCommand } from './commands/list.js';
import { runCommand } from './commands/run.js';
import { statusCommand } from './commands/status.js';
import { runsCommand } from './commands/runs.js';
import { approveCommand } from './commands/approve.js';
import { rejectCommand } from './commands/reject.js';
import { resumeCommand } from './commands/resume.js';
import { abandonCommand } from './commands/abandon.js';
import { cleanupCommand } from './commands/cleanup.js';
import { validateCommand } from './commands/validate.js';
import { convertCommand } from './commands/convert.js';
// ---------------------------------------------------------------------------
// Command registry
// ---------------------------------------------------------------------------
interface CommandEntry {
name: string;
description: string;
usage: string;
handler: (args: string[], options: CliOptions) => Promise<void>;
}
const COMMANDS: CommandEntry[] = [
{
name: 'list',
description: 'List all available workflows',
usage: 'workflow list [--json]',
handler: listCommand,
},
{
name: 'run',
description: 'Execute a workflow by name',
usage: 'workflow run <name> [args...] [--cwd <path>] [--detach] [--json]',
handler: runCommand,
},
{
name: 'status',
description: 'Show active (running + paused) workflow runs',
usage: 'workflow status [--json]',
handler: statusCommand,
},
{
name: 'runs',
description: 'List recent workflow runs with filters',
usage: 'workflow runs [--status <status>] [--limit N] [--all] [--json]',
handler: runsCommand,
},
{
name: 'approve',
description: 'Approve a paused workflow run',
usage: 'workflow approve <run-id> [comment] [--json]',
handler: approveCommand,
},
{
name: 'reject',
description: 'Reject a paused workflow run',
usage: 'workflow reject <run-id> [reason] [--json]',
handler: rejectCommand,
},
{
name: 'resume',
description: 'Resume a failed workflow run',
usage: 'workflow resume <run-id> [--json]',
handler: resumeCommand,
},
{
name: 'abandon',
description: 'Cancel a non-terminal workflow run',
usage: 'workflow abandon <run-id> [--json]',
handler: abandonCommand,
},
{
name: 'cleanup',
description: 'Remove old workflow run artifacts',
usage: 'workflow cleanup [days] [--json]',
handler: cleanupCommand,
},
{
name: 'validate',
description: 'Validate a workflow definition without executing',
usage: 'workflow validate <name> [--json]',
handler: validateCommand,
},
{
name: 'convert',
description: 'Convert a .sop.md file to a YAML workflow definition',
usage: 'workflow convert <file.sop.md> [--output <path>]',
handler: convertCommand,
},
];
// ---------------------------------------------------------------------------
// Help output
// ---------------------------------------------------------------------------
function printHelp(): void {
console.log('');
console.log('Ion — Workflow Engine CLI');
console.log('');
console.log('Usage:');
console.log(' workflow <command> [options]');
console.log('');
console.log('Commands:');
const maxNameLen = Math.max(...COMMANDS.map((c) => c.name.length));
for (const cmd of COMMANDS) {
const padded = cmd.name.padEnd(maxNameLen + 2);
console.log(` ${padded}${cmd.description}`);
}
console.log('');
console.log('Global options:');
console.log(' --json Output as JSON (suppresses all other output)');
console.log(' --cwd <path> Set working directory');
console.log(' --store <path> Path to workflow store');
console.log(' --db-path <p> Path to database file');
console.log('');
console.log('Run "workflow <command> --help" for command-specific usage.');
console.log('');
}
function printCommandHelp(cmd: CommandEntry): void {
console.log('');
console.log(`workflow ${cmd.name}`);
console.log('');
console.log(` ${cmd.description}`);
console.log('');
console.log('Usage:');
console.log(` ${cmd.usage}`);
console.log('');
}
// ---------------------------------------------------------------------------
// Main entry
// ---------------------------------------------------------------------------
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
const { args, options } = parseArgs(argv);
const cliOptions = buildCliOptions(options);
// --help with no command → general help
if (args.length === 0 || options.help === true) {
printHelp();
process.exit(0);
}
const commandName = args[0];
const commandArgs = args.slice(1);
// --help after a command name → command-specific help
if (options.help) {
const cmd = COMMANDS.find((c) => c.name === commandName);
if (cmd) {
printCommandHelp(cmd);
} else {
console.error(`Unknown command: ${commandName}`);
printHelp();
}
process.exit(0);
}
const command = COMMANDS.find((c) => c.name === commandName);
if (!command) {
console.error(`Unknown command: ${commandName}`);
printHelp();
process.exit(1);
return; // unreachable, but satisfies TS control flow
}
try {
await command.handler(commandArgs, cliOptions);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (cliOptions.json) {
printJson({ error: message });
} else {
console.error(`Error: ${message}`);
}
process.exit(1);
}
}
// Run when executed directly (not imported).
// In ESM, check import.meta.url to detect direct execution.
const _directRun = typeof import.meta !== 'undefined' && import.meta.url;
if (_directRun) {
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -0,0 +1,239 @@
/**
* CLI utility functions for the Ion workflow engine.
*
* Provides formatting, table rendering, and JSON output helpers
* used across all CLI commands.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CliOptions {
/** Working directory override. */
cwd?: string;
/** Output as JSON (suppresses all other output). */
json?: boolean;
/** Path to the workflow store database. */
store?: string;
/** Path to the database file. */
dbPath?: string;
/** Output file path (for convert command). */
output?: string;
}
// ---------------------------------------------------------------------------
// Duration formatting
// ---------------------------------------------------------------------------
/**
* Format a duration in milliseconds into a human-readable string.
*
* @example
* formatDuration(90500) // "1m 30s"
* formatDuration(3661000) // "1h 1m"
* formatDuration(500) // "0s"
*/
export function formatDuration(ms: number): string {
if (ms < 0) ms = 0;
const seconds = Math.floor(ms / 1000) % 60;
const minutes = Math.floor(ms / 60000) % 60;
const hours = Math.floor(ms / 3600000);
if (hours > 0) {
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
if (minutes > 0) {
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
return `${seconds}s`;
}
// ---------------------------------------------------------------------------
// Timestamp formatting
// ---------------------------------------------------------------------------
/**
* Format a Date into an ISO-like timestamp suitable for CLI display.
*
* @example
* formatTimestamp(new Date('2025-06-07T14:30:00Z'))
* // "2025-06-07 14:30:00"
*/
export function formatTimestamp(date: Date): string {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
const s = String(date.getSeconds()).padStart(2, '0');
return `${y}-${mo}-${d} ${h}:${mi}:${s}`;
}
// ---------------------------------------------------------------------------
// String truncation
// ---------------------------------------------------------------------------
/**
* Truncate a string to `max` characters, appending an ellipsis if truncated.
*
* @example
* truncate('hello world', 8) // "hello..."
* truncate('hi', 8) // "hi"
*/
export function truncate(str: string, max: number): string {
if (str.length <= max) return str;
if (max <= 3) return str.slice(0, max);
return str.slice(0, max - 3) + '...';
}
// ---------------------------------------------------------------------------
// Table rendering
// ---------------------------------------------------------------------------
export interface TableColumn {
/** Column header label. */
header: string;
/** Minimum column width. */
minWidth?: number;
/** Field name to extract from each row object. */
field: string;
}
/**
* Print a formatted table to stdout.
*
* @param rows - Array of row objects.
* @param columns - Column definitions with header labels and field names.
*
* @example
* printTable(
* [{ name: 'deploy', desc: 'Deploy app' }],
* [{ header: 'Name', field: 'name' }, { header: 'Description', field: 'desc' }],
* )
*/
export function printTable(
rows: Record<string, unknown>[],
columns: TableColumn[],
): void {
if (rows.length === 0) {
console.log('(no results)');
return;
}
// Compute column widths.
const widths: number[] = columns.map((col) => {
const headerLen = col.header.length;
const dataLen = Math.max(
...rows.map((row) => {
const val = row[col.field];
const str = val === undefined || val === null ? '' : String(val);
return str.length;
}),
0,
);
const min = col.minWidth ?? 0;
return Math.max(headerLen, dataLen, min);
});
// Header row.
const headerLine = columns
.map((col, i) => col.header.padEnd(widths[i]!))
.join(' ');
console.log(headerLine);
// Separator.
const sepLine = widths.map((w) => '-'.repeat(w)).join(' ');
console.log(sepLine);
// Data rows.
for (const row of rows) {
const line = columns
.map((col, i) => {
const val = row[col.field];
const str = val === undefined || val === null ? '' : String(val);
return str.padEnd(widths[i]!);
})
.join(' ');
console.log(line);
}
}
// ---------------------------------------------------------------------------
// JSON output
// ---------------------------------------------------------------------------
/**
* Print a data structure as formatted JSON to stdout.
* Uses 2-space indentation.
*/
export function printJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
/**
* Parse CLI arguments into positional args and named options.
*
* Supports `--flag` (boolean) and `--key value` (string) formats.
* Everything after `--` is treated as positional.
*
* @example
* parseArgs(['run', 'deploy', '--json', '--cwd', '/tmp'])
* // { args: ['run', 'deploy'], options: { json: true, cwd: '/tmp' } }
*/
export function parseArgs(argv: string[]): {
args: string[];
options: Record<string, string | boolean>;
} {
const args: string[] = [];
const options: Record<string, string | boolean> = {};
let i = 0;
while (i < argv.length) {
const token = argv[i]!;
if (token === '--') {
args.push(...argv.slice(i + 1));
break;
}
if (token.startsWith('--')) {
const key = token.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith('--')) {
options[key] = next;
i += 2;
} else {
options[key] = true;
i += 1;
}
} else {
args.push(token);
i += 1;
}
}
return { args, options };
}
/**
* Build a CliOptions object from parsed options.
* Extracts known CLI flags into their typed fields.
*/
export function buildCliOptions(
options: Record<string, string | boolean>,
): CliOptions {
return {
cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
json: options.json === true,
store: typeof options.store === 'string' ? options.store : undefined,
dbPath: typeof options['db-path'] === 'string' ? options['db-path'] : undefined,
output: typeof options.output === 'string' ? options.output : undefined,
};
}