import { Command } from 'commander'; import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync } from 'fs'; import { resolve } from 'path'; import type { CommandDispatcher, CustomResolver } from '../../types'; import type { PluginRegistry } from '../../plugin/registry'; import { SessionManager } from '../../session'; import { loadRoutineFile, loadSpecFile, listRoutines, validateRoutine, formatRoutineTree, formatRoutineList } from '../../routine/loader'; import { executeRoutine, type RoutineResult } from '../../routine/executor'; import { routineListAction } from './list/list'; import { routineRunAction } from './run/run'; import { routineValidateAction } from './validate/validate'; import { routineTestAction } from './test/test'; import { routineInitAction } from './init/init'; export function loadBuiltinRoutines(builtinDir: string): Record | undefined { if (!existsSync(builtinDir)) return undefined; const map: Record = {}; function collect(dir: string, prefix: string) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = resolve(dir, entry.name); const key = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) { collect(fullPath, key); } else if ( entry.isFile() && (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml')) ) { map[key] = readFileSync(fullPath, 'utf-8'); } } } collect(builtinDir, ''); return Object.keys(map).length > 0 ? map : undefined; } export function registerRoutineCommand( program: Command, cliName: string, routinesDir: string, dispatch: CommandDispatcher | undefined, builtinRoutinesDir?: string, customResolvers?: Map, pluginRegistry?: PluginRegistry, /** Display name for user-facing hints. Defaults to cliName. */ displayName?: string, ): void { const builtinsMap = builtinRoutinesDir ? loadBuiltinRoutines(builtinRoutinesDir) : undefined; const display = displayName ?? cliName; const routine = program .command('routine') .description('Manage and run routines'); routine .command('list [path]') .description('List available routines (optionally drill into a group)') .option('--tree', 'Show full tree structure') .action((path: string | undefined, opts: { tree?: boolean }) => { const result = routineListAction({ listRoutines: () => listRoutines(routinesDir, builtinsMap), path, }); if (result.length === 0) { if (path) { console.log(`No routines found under '${path.replace(/\/+$/, '')}/'`); } else { console.log(`No routines found in ~/.${cliName}/routines/`); console.log(`Run '${display} routine init' to install built-in routines.`); } return; } console.log( opts.tree ? formatRoutineTree(result) : formatRoutineList(result, path?.replace(/\/+$/, '')), ); }); routine .command('run ') .description('Execute a routine') .option('--set ', 'Override variables (key=value)') .option('--dry-run', 'Print resolved commands without executing') .option('--json', 'Emit RoutineResult as JSON to stdout (silences progress output)') .action(async (name: string, opts: { set?: string[]; dryRun?: boolean; json?: boolean }) => { if (!dispatch) { if (opts.json) { const failure: RoutineResult = { status: 'failed', success: false, output: {}, steps: [], durationMs: 0, stepsRun: 0, stepsSkipped: 0, stepsFailed: 0, error: `No active session. Run '${display} setup' first.`, }; process.stdout.write(JSON.stringify(failure) + '\n'); process.exit(2); } console.error(`No active session. Run '${display} setup' first.`); process.exit(2); } const overrides: Record = {}; for (const s of opts.set || []) { const eq = s.indexOf('='); if (eq > 0) overrides[s.slice(0, eq)] = s.slice(eq + 1); } const sessionMgr = new SessionManager(cliName); if (opts.json) { // JSON mode — silent, structured output. try { const def = loadRoutineFile(name, routinesDir, builtinsMap); const result = await routineRunAction({ loadRoutine: () => def, validateRoutine, executeRoutine, dispatch, overrides, dryRun: opts.dryRun, silent: true, customResolvers, pluginRegistry, invalidateSession: () => sessionMgr.invalidate(), }); // Strip fields routineRunAction appends for the CLI banner — the // JSON contract is RoutineResult only. const { name: _name, description: _description, ...routineResult } = result; process.stdout.write(JSON.stringify(routineResult) + '\n'); if (result.status !== 'ok') process.exit(1); } catch (err) { const msg = err instanceof Error ? err.message : String(err); const failure: RoutineResult = { status: 'failed', success: false, output: {}, steps: [], durationMs: 0, stepsRun: 0, stepsSkipped: 0, stepsFailed: 0, error: msg, }; process.stdout.write(JSON.stringify(failure) + '\n'); process.exit(1); } return; } // Default mode — colored progress output (unchanged from before). try { const def = loadRoutineFile(name, routinesDir, builtinsMap); console.log( `Running routine: ${def.name}${def.description ? ` — ${def.description}` : ''}\n`, ); const startTime = Date.now(); const result = await routineRunAction({ loadRoutine: () => def, validateRoutine, executeRoutine, dispatch, overrides, dryRun: opts.dryRun, customResolvers, pluginRegistry, invalidateSession: () => sessionMgr.invalidate(), onStep: (step, i, total) => { console.log(`\x1b[36m[${i + 1}/${total}]\x1b[0m ${step.name}`); }, onIteration: (step, current, total, stepIndex, stepTotal) => { process.stderr.write(`\r\x1b[36m[${stepIndex + 1}/${stepTotal}]\x1b[0m ${step.name} \x1b[36m[${current}/${total}]\x1b[0m\x1b[K`); }, }); const elapsed = Date.now() - startTime; const mins = Math.floor(elapsed / 60000); const secs = ((elapsed % 60000) / 1000).toFixed(1); const timeStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; console.log( `\nRoutine ${result.success ? '\x1b[32mcompleted\x1b[0m' : '\x1b[31mfailed\x1b[0m'}: ${result.stepsRun} run, ${result.stepsSkipped} skipped, ${result.stepsFailed} failed (${timeStr})`, ); if (!result.success) process.exit(1); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); routine .command('validate ') .description('Validate a routine YAML file') .action((name: string) => { const result = routineValidateAction({ loadRoutine: () => loadRoutineFile(name, routinesDir, builtinsMap), validateRoutine, }); if (!result.valid) { console.error('Validation errors:'); for (const e of result.errors) console.error(` - ${e}`); process.exit(1); } console.log(`Routine "${result.name}" is valid.`); }); routine .command('test ') .description("Run a routine's spec (test) file") .option('--set ', 'Override variables (key=value)') .action(async (name: string, opts: { set?: string[] }) => { if (!dispatch) { console.error(`No active session. Run '${display} setup' first.`); process.exit(2); } const overrides: Record = {}; for (const s of opts.set || []) { const eq = s.indexOf('='); if (eq > 0) overrides[s.slice(0, eq)] = s.slice(eq + 1); } try { console.log(`\x1b[36mTesting routine: ${name}\x1b[0m\n`); const result = await routineTestAction({ loadSpec: () => loadSpecFile(name, routinesDir, builtinsMap), validateRoutine, executeRoutine, dispatch, overrides, customResolvers, pluginRegistry, routineName: name, onStep: (step, i, total) => { console.log( `\x1b[36m[${i + 1}/${total}]\x1b[0m ${step.name}${step.assert ? ' \x1b[33m(assert)\x1b[0m' : ''}`, ); }, onIteration: (step, current, total, stepIndex, stepTotal) => { process.stderr.write(`\r\x1b[36m[${stepIndex + 1}/${stepTotal}]\x1b[0m ${step.name} \x1b[36m[${current}/${total}]\x1b[0m\x1b[K`); }, }); console.log(''); if (result.success) { console.log(`\x1b[32mPASSED\x1b[0m: ${result.stepsRun} steps run, ${result.stepsSkipped} skipped`); } else { console.log(`\x1b[31mFAILED\x1b[0m: ${result.stepsRun} steps run, ${result.stepsFailed} failed`); process.exit(1); } } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); routine .command('init') .description(`Copy built-in routines to ~/.${cliName}/routines/`) .action(() => { try { const result = routineInitAction({ routinesDir, builtinDir: builtinRoutinesDir, exists: existsSync, mkdir: mkdirSync, copy: cpSync, listDir: (path: string) => readdirSync(path) as string[], }); console.log(`Installed ${result.installed} routines to ${result.routinesDir}`); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); } }); }