Files
Codeman/src/cli.ts
T
aakhterandClaude Opus 4.6 b7e94e7068 feat: hostname-aware window title (#82)
Set the browser tab title to codeman:${hostname} instead of the bare
"Codeman" literal. Useful for users running multiple Codeman instances
across hosts (laptop, dev box, NAS) — the OS hostname disambiguates
which tab points at which backend.

Implementation:

- src/cli.ts: new --title-hostname <hostname> flag overrides the
  detected hostname (handy for cosmetic naming or when os.hostname()
  returns something noisy).
- src/web/server.ts: WebServer now accepts an optional titleHostname
  constructor arg (defaults to os.hostname()), composes
  windowTitle = codeman:${titleHostname}, and serves / and
  /index.html by templating that title into the cached index.html
  template (with HTML escaping of the title text).
- src/web/public/notification-manager.js: title-flash logic now uses
  this.originalTitle instead of the hardcoded "Codeman" literal, so
  the tab flash respects the per-host title.
- scripts/browser-comparison.mjs + test/file-link-click.test.ts:
  expectations updated from === "Codeman" to a startsWith("codeman:")
  predicate so they pass regardless of host.

The new index.html templating is intentionally narrow — it only
substitutes the <title> tag and continues to serve everything else
from the static template. No JS-side title injection, so it works
without JavaScript and shows the correct title from the very first
paint.

Note: test/file-link-click.test.ts shows ~49 prettier-reformat lines
that are not part of the feature — they are pre-existing prettier
debt that the pre-commit hook required me to clear. The single
behavioral change is the browserAvailable line.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:11:33 +02:00

529 lines
17 KiB
TypeScript

/**
* @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 { 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';
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);
// ============ 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 <path>', '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 <id>')
.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 <id>')
.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 <prompt>')
.description('Add a new task')
.option('-d, --dir <path>', 'Working directory', process.cwd())
.option('-p, --priority <n>', 'Priority (higher = first)', '0')
.option('-c, --completion <phrase>', 'Completion phrase to detect')
.option('--timeout <ms>', '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 <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 <id>')
.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 <id>')
.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 <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<ReturnType<typeof getRalphLoop>['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 <path>', '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('-p, --port <port>', 'Port to listen on', '3000')
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
.action(async (options) => {
const { startWebServer } = await import('./web/server.js');
const port = parseInt(options.port, 10);
const https = !!options.https;
const titleHostname = options.titleHostname;
const protocol = https ? 'https' : 'http';
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
try {
const server = await startWebServer(port, https, false, titleHostname);
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${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);
}
});
export { program };