// ───────────────────────────────────────────────────────────── // Hermes CLI client — type-safe wrapper for `hermes kanban ...` // ADR §3.5: "uses stable hermes kanban ... --json CLI commands first" // ADR §3.5: "fixed argv and shell: false" // ───────────────────────────────────────────────────────────── import { execFile as execFileCb } from 'node:child_process'; import { promisify } from 'node:util'; import type { Board, TaskSummary, TaskDetail, } from '@kanban/contracts'; const execFile = promisify(execFileCb); // ─── Types ─────────────────────────────────────────────────── export interface HermesClientConfig { /** Path to hermes binary (default: 'hermes') */ bin: string; /** HERMES_HOME for target profile */ home: string; /** Default board slug */ defaultBoard: string; /** Per-command timeout in ms */ timeoutMs: number; } // ─── Allowed commands ──────────────────────────────────────── /** * Command allowlist. Each entry defines: * - subcommand: the `hermes kanban ` string * - requiredArgs: positional args the caller must provide * - optionalFlags: flags that may be appended * * This prevents arbitrary pass-through to the hermes CLI. */ type KanbanSubcommand = | 'boards' | 'list' | 'show' | 'create' | 'comment' | 'assign' | 'promote' | 'block' | 'unblock' | 'archive' | 'dispatch' | 'log'; const ALLOWED_SUBCOMMANDS = new Set([ 'boards', 'list', 'show', 'create', 'comment', 'assign', 'promote', 'block', 'unblock', 'archive', 'dispatch', 'log', ]); // ─── Client ────────────────────────────────────────────────── export class HermesKanbanClient { private readonly config: HermesClientConfig; constructor(config: HermesClientConfig) { this.config = config; } /** * Execute a hermes kanban subcommand with --json output. * Uses execFile (shell: false) for safety. */ async exec( subcommand: KanbanSubcommand, args: string[], board?: string ): Promise { if (!ALLOWED_SUBCOMMANDS.has(subcommand)) { throw new Error(`Blocked disallowed subcommand: ${subcommand}`); } const fullArgs = [ 'kanban', subcommand, '--board', board ?? this.config.defaultBoard, '--json', ...args, ]; const env = { ...process.env, HERMES_HOME: this.config.home, }; try { const { stdout, stderr } = await execFile(this.config.bin, fullArgs, { shell: false, // CRITICAL: never use shell timeout: this.config.timeoutMs, env, maxBuffer: 5 * 1024 * 1024, // 5MB }); if (stderr) { console.warn(`[hermes-client] stderr: ${redact(stderr)}`); } return JSON.parse(stdout) as T; } catch (err) { if (err instanceof Error) { // Redact any secrets that might leak into error messages throw new Error(`Hermes CLI error: ${redact(err.message)}`); } throw err; } } // ─── Convenience methods (M2 will flesh these out) ──────── async listBoards(): Promise { return this.exec('boards', []); } async listTasks( board: string, opts?: { status?: string; assignee?: string; limit?: number } ): Promise { const args: string[] = []; if (opts?.status) args.push('--status', opts.status); if (opts?.assignee) args.push('--assignee', opts.assignee); if (opts?.limit) args.push('--limit', String(opts.limit)); return this.exec('list', args, board); } async getTask(board: string, taskId: string): Promise { return this.exec('show', [taskId], board); } async createTask( board: string, payload: { title: string; body?: string; assignee?: string; priority?: number } ): Promise { const args = ['--title', payload.title]; if (payload.body) args.push('--body', payload.body); if (payload.assignee) args.push('--assignee', payload.assignee); if (payload.priority !== undefined) args.push('--priority', String(payload.priority)); return this.exec('create', args, board); } async addComment(board: string, taskId: string, body: string): Promise { await this.exec('comment', [taskId, '--body', body], board); } async assignTask(board: string, taskId: string, assignee: string): Promise { await this.exec('assign', [taskId, '--assignee', assignee], board); } async promoteTask(board: string, taskId: string, reason?: string): Promise { const args = [taskId]; if (reason) args.push('--reason', reason); await this.exec('promote', args, board); } async blockTask(board: string, taskId: string, reason: string): Promise { await this.exec('block', [taskId, '--reason', reason], board); } async unblockTask(board: string, taskId: string): Promise { await this.exec('unblock', [taskId], board); } async archiveTask(board: string, taskId: string): Promise { await this.exec('archive', [taskId], board); } async dispatchBoard(board: string): Promise { await this.exec('dispatch', [], board); } async getTaskLog(board: string, taskId: string, lines?: number): Promise { const args = [taskId]; if (lines) args.push('--lines', String(lines)); const result = await this.exec<{ content?: string; log?: string }>('log', args, board); return result.content ?? result.log ?? ''; } } // ─── Redaction helper ──────────────────────────────────────── const SENSITIVE_PATTERNS = [ /token[=:]\s*\S+/gi, /secret[=:]\s*\S+/gi, /password[=:]\s*\S+/gi, /api[_-]?key[=:]\s*\S+/gi, ]; function redact(text: string): string { let result = text; for (const pattern of SENSITIVE_PATTERNS) { result = result.replace(pattern, '[REDACTED]'); } return result; }