/** * @fileoverview Codeman CLI command definitions * * Defines all CLI commands and subcommands for managing Claude sessions, * tasks, Ralph loops, and the web server. * * @module cli */ import { Command } from 'commander'; import chalk from 'chalk'; import { createRequire } from 'module'; import http from 'node:http'; import https from 'node:https'; import { readFileSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { dataPath } from './config/instance.js'; import { getSessionManager } from './session-manager.js'; import { getTaskQueue } from './task-queue.js'; import { getRalphLoop } from './ralph-loop.js'; import { getStore } from './state-store.js'; import { getErrorMessage } from './types.js'; import { isSupportedAttachmentExtension } from './attachment-registry.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version: string }; const program = new Command(); program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version); function makeAttachmentMagicLink(filePath: string): string { return `codeman://attach?path=${encodeURIComponent(filePath)}`; } function readCodemanEnv(): Record { const envPath = dataPath('.env'); try { const text = readFileSync(envPath, 'utf-8'); const result: Record = {}; for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith('#')) continue; const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!match) continue; let value = match[2].trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } result[match[1]] = value; } return result; } catch { return {}; } } async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise { const envFile = readCodemanEnv(); const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin'; const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD; const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl); const body = JSON.stringify({ path: filePath }); const transport = url.protocol === 'https:' ? https : http; return new Promise((resolve) => { const headers: Record = { Accept: 'application/json', 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), }; if (password) { headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; } const req = transport.request( { protocol: url.protocol, hostname: url.hostname, port: url.port, method: 'POST', path: `${url.pathname}${url.search}`, rejectUnauthorized: false, headers, }, (res) => { res.resume(); res.on('end', () => resolve(Boolean(res.statusCode && res.statusCode >= 200 && res.statusCode < 300))); } ); req.on('error', () => resolve(false)); req.write(body); req.end(); }); } program .command('attach ') .description('Show an attachment card for a local file') .option('-s, --session ', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)') .option('--url ', 'Codeman API URL (defaults to CODEMAN_API_URL or https://127.0.0.1:3000)') .action(async (filePath, options) => { const extension = String(filePath).split('.').pop()?.toLowerCase() || ''; if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) { console.error(chalk.red('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file')); process.exit(1); } const sessionId = options.session || process.env.CODEMAN_SESSION_ID; const apiUrl = options.url || process.env.CODEMAN_API_URL || 'https://127.0.0.1:3000'; if (sessionId && (await postAttachment(apiUrl, sessionId, filePath))) { console.log(chalk.green('✓ Attachment card requested')); return; } console.log(makeAttachmentMagicLink(filePath)); }); // ============ Session Commands ============ const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions'); sessionCmd .command('start') .description('Start a new Claude session') .option('-d, --dir ', 'Working directory', process.cwd()) .action(async (options) => { try { const manager = getSessionManager(); const session = await manager.createSession(options.dir); console.log(chalk.green(`✓ Session started: ${session.id}`)); console.log(` Working directory: ${session.workingDir}`); console.log(` PID: ${session.pid}`); } catch (err) { console.error(chalk.red(`✗ Failed to start session: ${getErrorMessage(err)}`)); process.exit(1); } }); sessionCmd .command('stop ') .description('Stop a session') .action(async (id) => { try { const manager = getSessionManager(); await manager.stopSession(id); console.log(chalk.green(`✓ Session stopped: ${id}`)); } catch (err) { console.error(chalk.red(`✗ Failed to stop session: ${getErrorMessage(err)}`)); process.exit(1); } }); sessionCmd .command('list') .alias('ls') .description('List all sessions') .action(() => { const manager = getSessionManager(); const sessions = manager.getAllSessions(); const stored = manager.getStoredSessions(); if (sessions.length === 0 && Object.keys(stored).length === 0) { console.log(chalk.yellow('No sessions found')); return; } console.log(chalk.bold('\nActive Sessions:')); if (sessions.length === 0) { console.log(' (none)'); } else { for (const session of sessions) { const status = session.status === 'idle' ? chalk.green('idle') : session.status === 'busy' ? chalk.yellow('busy') : chalk.red(session.status); console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`); } } const stoppedSessions = Object.values(stored).filter((s) => s.status === 'stopped'); if (stoppedSessions.length > 0) { console.log(chalk.bold('\nStopped Sessions:')); for (const session of stoppedSessions) { const name = session.name ? ` (${session.name})` : ''; console.log(` ${chalk.gray(session.id.slice(0, 8))} ${chalk.gray('stopped')}${name} ${session.workingDir}`); } } // Show active sessions from state (when web server manages them) const activeSessions = Object.values(stored).filter((s) => s.status !== 'stopped'); if (sessions.length === 0 && activeSessions.length > 0) { console.log(chalk.bold('\nActive Sessions (from web server):')); for (const session of activeSessions) { const status = session.status === 'idle' ? chalk.green('idle') : session.status === 'busy' ? chalk.yellow('busy') : chalk.red(session.status); const name = session.name ? ` (${session.name})` : ''; const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : ''; const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : ''; console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status}${name}${mode}${cost} ${session.workingDir}`); } } console.log(''); }); sessionCmd .command('logs ') .description('View session output') .option('-e, --errors', 'Show stderr instead of stdout') .action((id, options) => { const manager = getSessionManager(); const output = options.errors ? manager.getSessionError(id) : manager.getSessionOutput(id); if (output === null) { console.log(chalk.yellow(`Session ${id} not found or not active`)); return; } if (output === '') { console.log(chalk.gray('(no output)')); return; } console.log(output); }); // ============ Task Commands ============ const taskCmd = program.command('task').alias('t').description('Manage tasks'); taskCmd .command('add ') .description('Add a new task') .option('-d, --dir ', 'Working directory', process.cwd()) .option('-p, --priority ', 'Priority (higher = first)', '0') .option('-c, --completion ', 'Completion phrase to detect') .option('--timeout ', 'Timeout in milliseconds') .action((prompt, options) => { const queue = getTaskQueue(); const task = queue.addTask({ prompt, workingDir: options.dir, priority: parseInt(options.priority, 10), completionPhrase: options.completion, timeoutMs: options.timeout ? parseInt(options.timeout, 10) : undefined, }); console.log(chalk.green(`✓ Task added: ${task.id}`)); console.log(` Prompt: ${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}`); console.log(` Priority: ${task.priority}`); }); taskCmd .command('list') .alias('ls') .description('List all tasks') .option('-s, --status ', 'Filter by status (pending, running, completed, failed)') .action((options) => { const queue = getTaskQueue(); let tasks = queue.getAllTasks(); if (options.status) { tasks = tasks.filter((t) => t.status === options.status); } if (tasks.length === 0) { console.log(chalk.yellow('No tasks found')); return; } const statusColors = { pending: chalk.gray, running: chalk.yellow, completed: chalk.green, failed: chalk.red, }; console.log(chalk.bold('\nTasks:')); for (const task of tasks) { const color = statusColors[task.status]; const prompt = task.prompt.slice(0, 40) + (task.prompt.length > 40 ? '...' : ''); console.log(` ${chalk.cyan(task.id.slice(0, 8))} ${color(task.status.padEnd(10))} [${task.priority}] ${prompt}`); } const counts = queue.getCount(); console.log(chalk.bold('\nSummary:')); console.log( ` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}` ); console.log(''); }); taskCmd .command('status ') .description('Show task details') .action((id) => { const queue = getTaskQueue(); const task = queue.getTask(id); if (!task) { console.log(chalk.red(`Task ${id} not found`)); return; } console.log(chalk.bold('\nTask Details:')); console.log(` ID: ${task.id}`); console.log(` Status: ${task.status}`); console.log(` Priority: ${task.priority}`); console.log(` Prompt: ${task.prompt}`); console.log(` Working Dir: ${task.workingDir}`); if (task.assignedSessionId) { console.log(` Session: ${task.assignedSessionId}`); } if (task.error) { console.log(` Error: ${chalk.red(task.error)}`); } if (task.output) { console.log(chalk.bold('\nOutput:')); console.log(task.output.slice(0, 500) + (task.output.length > 500 ? '...' : '')); } console.log(''); }); taskCmd .command('remove ') .alias('rm') .description('Remove a task') .action((id) => { const queue = getTaskQueue(); if (queue.removeTask(id)) { console.log(chalk.green(`✓ Task removed: ${id}`)); } else { console.log(chalk.red(`Task ${id} not found`)); } }); taskCmd .command('clear') .description('Clear completed/failed tasks') .option('-a, --all', 'Clear all tasks') .option('-f, --failed', 'Clear only failed tasks') .action((options) => { const queue = getTaskQueue(); let count: number; if (options.all) { count = queue.clearAll(); console.log(chalk.green(`✓ Cleared ${count} tasks`)); } else if (options.failed) { count = queue.clearFailed(); console.log(chalk.green(`✓ Cleared ${count} failed tasks`)); } else { count = queue.clearCompleted(); console.log(chalk.green(`✓ Cleared ${count} completed tasks`)); } }); // ============ Ralph Loop Commands ============ const ralphCmd = program.command('ralph').alias('r').description('Control the Ralph autonomous loop'); ralphCmd .command('start') .description('Start the Ralph loop') .option('-m, --min-hours ', 'Minimum duration in hours') .option('--no-auto-generate', 'Disable auto-generating follow-up tasks') .action(async (options) => { const loop = getRalphLoop({ autoGenerateTasks: options.autoGenerate, }); if (options.minHours) { loop.setMinDuration(parseFloat(options.minHours)); } if (loop.isRunning()) { console.log(chalk.yellow('Ralph loop is already running')); return; } loop.on('taskAssigned', (taskId, sessionId) => { console.log(chalk.cyan(`→ Task ${taskId.slice(0, 8)} assigned to session ${sessionId.slice(0, 8)}`)); }); loop.on('taskCompleted', (taskId) => { console.log(chalk.green(`✓ Task ${taskId.slice(0, 8)} completed`)); }); loop.on('taskFailed', (taskId, error) => { console.log(chalk.red(`✗ Task ${taskId.slice(0, 8)} failed: ${error}`)); }); loop.on('stopped', () => { console.log(chalk.yellow('\nRalph loop stopped')); printStats(loop.getStats()); process.exit(0); }); await loop.start(); console.log(chalk.green('✓ Ralph loop started')); if (options.minHours) { console.log(` Minimum duration: ${options.minHours} hours`); } console.log(chalk.gray(' Press Ctrl+C to stop\n')); // Keep process running process.on('SIGINT', () => { console.log(chalk.yellow('\nStopping Ralph loop...')); loop.stop(); }); }); ralphCmd .command('stop') .description('Stop the Ralph loop') .action(() => { const loop = getRalphLoop(); if (!loop.isRunning()) { console.log(chalk.yellow('Ralph loop is not running')); return; } loop.stop(); console.log(chalk.green('✓ Ralph loop stopped')); }); ralphCmd .command('status') .description('Show Ralph loop status') .action(() => { const loop = getRalphLoop(); const stats = loop.getStats(); printStats(stats); }); function printStats(stats: ReturnType['getStats']>) { const statusColor = stats.status === 'running' ? chalk.green : stats.status === 'paused' ? chalk.yellow : chalk.gray; console.log(chalk.bold('\nRalph Loop Status:')); console.log(` Status: ${statusColor(stats.status)}`); console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`); if (stats.minDurationMs) { const minHours = stats.minDurationMs / (1000 * 60 * 60); console.log( ` Min Duration: ${minHours.toFixed(2)} hours (${stats.minDurationReached ? 'reached' : 'not reached'})` ); } console.log(chalk.bold('\nTasks:')); console.log(` Pending: ${stats.pending}`); console.log(` Running: ${stats.running}`); console.log(` Completed: ${stats.completed} (${stats.tasksCompleted} this session)`); console.log(` Failed: ${stats.failed}`); console.log(` Generated: ${stats.tasksGenerated}`); console.log(chalk.bold('\nSessions:')); console.log(` Active: ${stats.activeSessions}`); console.log(` Idle: ${stats.idleSessions}`); console.log(` Busy: ${stats.busySessions}`); console.log(''); } // ============ Utility Commands ============ program .command('status') .description('Show overall status') .action(() => { const manager = getSessionManager(); const queue = getTaskQueue(); const loop = getRalphLoop(); const sessions = manager.getAllSessions(); const stored = manager.getStoredSessions(); const storedValues = Object.values(stored); const taskCounts = queue.getCount(); const loopStatus = loop.status; // Use live sessions if available, otherwise fall back to stored state const activeCount = sessions.length || storedValues.filter((s) => s.status !== 'stopped').length; const idleCount = sessions.length ? sessions.filter((s) => s.isIdle()).length : storedValues.filter((s) => s.status === 'idle').length; const busyCount = sessions.length ? sessions.filter((s) => s.isBusy()).length : storedValues.filter((s) => s.status === 'busy').length; console.log(chalk.bold('\nCodeman Status')); console.log('─'.repeat(40)); console.log(chalk.bold('\nSessions:')); console.log(` Active: ${activeCount}`); console.log(` Idle: ${idleCount}`); console.log(` Busy: ${busyCount}`); console.log(chalk.bold('\nTasks:')); console.log(` Total: ${taskCounts.total}`); console.log(` Pending: ${taskCounts.pending}`); console.log(` Running: ${taskCounts.running}`); console.log(` Completed: ${taskCounts.completed}`); console.log(` Failed: ${taskCounts.failed}`); const statusColor = loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray; console.log(chalk.bold('\nRalph Loop:')); console.log(` Status: ${statusColor(loopStatus)}`); console.log(''); }); program .command('reset') .description('Reset all state') .option('-f, --force', 'Skip confirmation') .action(async (options) => { if (!options.force) { console.log(chalk.yellow('This will stop all sessions and clear all state.')); console.log(chalk.yellow('Use --force to confirm.')); return; } const manager = getSessionManager(); const store = getStore(); await manager.stopAllSessions(); store.reset(); console.log(chalk.green('✓ All state reset')); }); // Shorthand commands at root level program .command('start') .description('Start a new session (shorthand)') .option('-d, --dir ', 'Working directory', process.cwd()) .action(async (options) => { const manager = getSessionManager(); const session = await manager.createSession(options.dir); console.log(chalk.green(`✓ Session started: ${session.id}`)); }); program .command('list') .alias('ls') .description('List all sessions (shorthand)') .action(() => { const manager = getSessionManager(); const sessions = manager.getAllSessions(); const stored = manager.getStoredSessions(); if (sessions.length === 0 && Object.keys(stored).length === 0) { console.log(chalk.yellow('No sessions found')); return; } console.log(chalk.bold('\nActive Sessions:')); if (sessions.length === 0) { console.log(' (none)'); } else { for (const session of sessions) { const status = session.status === 'idle' ? chalk.green('idle') : session.status === 'busy' ? chalk.yellow('busy') : chalk.red(session.status); console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`); } } console.log(''); }); // Web interface command program .command('web') .description('Start the web interface') .option('-H, --host ', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1') .option('-p, --port ', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000') .option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)') .option('--title-hostname ', 'Override the hostname shown in the browser title') .option( '--allow-unauthenticated-network', 'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)' ) .action(async (options) => { const { startWebServer } = await import('./web/server.js'); const host = options.host; const port = parseInt(options.port, 10); const https = !!options.https; const titleHostname = options.titleHostname; const allowUnauthenticatedNetwork = !!options.allowUnauthenticatedNetwork; const protocol = https ? 'https' : 'http'; const displayHost = host === '0.0.0.0' ? 'localhost' : host; console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`)); try { const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork); console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`)); if (https) { console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit')); } console.log(chalk.gray(' Press Ctrl+C to stop\n')); // Graceful shutdown handler — flush state and clean up on SIGTERM/SIGINT let shuttingDown = false; const shutdown = async (signal: string) => { if (shuttingDown) return; shuttingDown = true; console.log(chalk.yellow(`\n${signal} received, shutting down gracefully...`)); try { await server.stop(); } catch (err) { console.error(chalk.red(`Error during shutdown: ${getErrorMessage(err)}`)); } process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGHUP', () => shutdown('SIGHUP')); } catch (err) { console.error(chalk.red(`✗ Failed to start web server: ${getErrorMessage(err)}`)); process.exit(1); } }); program .command('doctor') .alias('check-deps') .description('Check Codeman tool dependencies (Node, Claude CLI, tmux, LibreOffice, MS Office)') .option('--json', 'Output structured JSON instead of a table') .option('--category ', 'Only check one category (core|office|other)') .action(async (options) => { const { createRealHost, checkAll } = await import('./utils/dependency-checker.js'); const { renderTable, renderJson, computeExitCode } = await import('./utils/dependency-report.js'); const { DEPENDENCY_REGISTRY, TOOL_CATEGORIES } = await import('./config/dependency-registry.js'); if (options.category && !(TOOL_CATEGORIES as readonly string[]).includes(options.category)) { console.error(`Unknown category "${options.category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`); process.exit(2); } const host = createRealHost(); const registry = options.category ? DEPENDENCY_REGISTRY.filter((t) => t.category === options.category) : DEPENDENCY_REGISTRY; const results = checkAll(registry, host); if (options.json) { console.log(JSON.stringify(renderJson(results, host.environment), null, 2)); } else { console.log(renderTable(results, host.environment)); } process.exit(computeExitCode(results)); }); export { program };