2026-01-21 08:37:35 +01:00
/**
2026-02-26 16:44:34 +01:00
* @fileoverview Codeman CLI command definitions
2026-01-21 08:37:35 +01:00
*
* Defines all CLI commands and subcommands for managing Claude sessions,
* tasks, Ralph loops, and the web server.
*
* @module cli
*/
2026-01-18 04:50:46 +01:00
import { Command } from 'commander' ;
2026-02-18 07:06:34 +01:00
import { createRequire } from 'module' ;
2026-06-10 20:42:17 -04:00
import http from 'node:http' ;
import https from 'node:https' ;
2026-08-09 03:30:15 +02:00
import { existsSync , readFileSync } from 'node:fs' ;
import { isAbsolute , join } from 'node:path' ;
import { homedir } from 'node:os' ;
2026-06-11 10:27:05 +02:00
import { dataPath } from './config/instance.js' ;
2026-10-08 14:37:46 +02:00
import { readCodemanCredentials } from './codeman-credentials.js' ;
2026-10-29 21:45:26 +08:00
import { runNotify } from './cli-notify.js' ;
2026-09-01 11:32:03 +02:00
import { casePath } from './config/cases-dir.js' ;
2026-09-04 12:10:20 -04:00
import { assertValidBasePath } from './config/base-path.js' ;
2026-10-11 07:10:41 +02:00
import {
agentSkillDir ,
agentSkillDirFor ,
CLAUDE_AGENT_SKILL_DIR ,
installAgentSkillInto ,
removeAgentSkillFrom ,
type AgentSkillApplyResult ,
} from './hooks-config.js' ;
import type { AgentSkillDir } from './config/cli-registry/types.js' ;
import { getCli , listClis } from './config/cli-registry/registry.js' ;
2026-10-08 15:00:29 +02:00
import { registerAgentCommands } from './cli-agent.js' ;
2026-10-24 20:43:17 -04:00
import { registerHistoryCommands } from './cli-history.js' ;
2026-10-13 13:23:56 +02:00
import { resolveMcpConfig , runMcpStdio } from './mcp-server.js' ;
2026-01-18 04:50:46 +01:00
import { getSessionManager } from './session-manager.js' ;
import { getTaskQueue } from './task-queue.js' ;
import { getRalphLoop } from './ralph-loop.js' ;
import { getStore } from './state-store.js' ;
2026-01-21 14:07:04 +01:00
import { getErrorMessage } from './types.js' ;
2026-09-27 07:55:29 -04:00
import { DOCUMENT_ATTACHMENT_EXTENSIONS , isSupportedAttachmentExtension } from './attachment-registry.js' ;
2026-08-09 01:34:55 +02:00
import { daemonStatus , startDaemon , stopDaemon , type WebLaunchOptions } from './daemon-control.js' ;
import { installService , serviceStatus , uninstallService } from './service-installer.js' ;
import { isLoopbackBindHost , isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js' ;
2026-08-16 18:23:44 +02:00
import { confirm , heading , isInteractive , kv , palette , rule , tint , withSpinner , type Tone } from './cli-style.js' ;
import type { ToolResult } from './utils/dependency-checker.js' ;
import type { ReportStyle } from './utils/dependency-report.js' ;
2026-01-18 04:50:46 +01:00
2026-02-18 07:06:34 +01:00
const require = createRequire ( import . meta . url );
const pkg = require ( '../package.json' ) as { version : string };
2026-01-18 04:50:46 +01:00
const program = new Command ();
2026-02-27 13:16:23 +01:00
program . name ( 'codeman' ). description ( 'Claude Code session manager with autonomous Ralph Loop' ). version ( pkg . version );
2026-01-18 04:50:46 +01:00
2026-06-10 20:42:17 -04:00
function makeAttachmentMagicLink ( filePath : string ) : string {
return `codeman://attach?path= ${ encodeURIComponent ( filePath ) } ` ;
}
async function postAttachment ( apiUrl : string , sessionId : string , filePath : string ) : Promise < boolean > {
2026-10-08 14:37:46 +02:00
const { username , password } = readCodemanCredentials ();
2026-06-10 20:42:17 -04:00
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 < string , string | number > = {
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 ();
});
}
2026-10-29 21:24:56 +08:00
program
. command ( 'notify <message>' )
. description (
"Post a message to this tab's Discord thread (needs the user: a physical step, a blocker, a timed-out wait)"
)
. option ( '--urgent' , 'Ping the user' )
. option ( '--file <path>' , 'Send a file from the session folder with the message' )
. option ( '-s, --session <id>' , 'Codeman session ID (defaults to CODEMAN_SESSION_ID)' )
. option ( '--url <url>' , 'Codeman API URL (defaults to CODEMAN_API_URL)' )
. action ( async ( message : string , options ) => {
2026-10-29 21:45:26 +08:00
const { code , out , err } = await runNotify ({ message , ... options }, process . env , process . cwd ());
if ( out ) console . log ( code === 0 && out . startsWith ( 'Posted' ) ? palette . ok ( `✓ ${ out } ` ) : out );
if ( err ) console . error ( palette . err ( `✗ ${ err } ` ));
process . exit ( code );
2026-10-29 21:24:56 +08:00
});
2026-06-10 20:42:17 -04:00
program
. command ( 'attach <path>' )
. description ( 'Show an attachment card for a local file' )
. option ( '-s, --session <id>' , 'Codeman session ID (defaults to CODEMAN_SESSION_ID)' )
. option ( '--url <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 )) {
2026-09-27 07:55:29 -04:00
console . error (
palette . err (
`✗ attach requires an absolute path to an image (png, jpg, gif, webp), document ( ${ DOCUMENT_ATTACHMENT_EXTENSIONS . join ( ', ' ) } ), audio, video, md, txt or other text file`
)
);
2026-06-10 20:42:17 -04:00
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 ))) {
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( '✓ Attachment card requested' ));
2026-06-10 20:42:17 -04:00
return ;
}
console . log ( makeAttachmentMagicLink ( filePath ));
});
2026-08-09 03:30:15 +02:00
// ============ Skill Commands ============
/** Same registry the server resolves case names through (mirrors `case-routes.ts`). */
const LINKED_CASES_FILE = dataPath ( 'linked-cases.json' );
2026-09-24 01:35:27 +02:00
/** Graceful shutdown budget before the process force-exits (see the SIGTERM handler). */
const SHUTDOWN_FORCE_EXIT_MS = 10 _000 ;
2026-08-09 03:30:15 +02:00
/**
* Case name to directory, checking `linked-cases.json` FIRST and falling back to the
* shared single-user cases dir. Mirrors `resolveCasePath()` in `case-routes.ts`, which
* is what the web UI and `quick-start` use. Without the linked-cases lookup this
* command rejected every case linked in from outside `~/codeman-cases` with
* "Case not found", even though the server resolved the same name fine.
*
* Sync and tolerant on purpose: a missing or malformed registry means "no linked
* cases", never a crash.
*/
2026-08-09 11:49:30 +02:00
export function resolveCliCasePath ( name : string ) : string {
2026-08-09 03:30:15 +02:00
try {
const linked = JSON . parse ( readFileSync ( LINKED_CASES_FILE , 'utf-8' )) as Record < string , string >;
const target = linked ? .[ name ];
if ( typeof target === 'string' && target ) return target ;
} catch {
// no registry yet, or unreadable/invalid JSON: fall through to the cases dir
}
2026-09-01 11:32:03 +02:00
// Same resolver the server uses, so CODEMAN_CASES_PATH (Docker Compose) moves
// the CLI's idea of a case with it instead of leaving it on the home default.
return casePath ( name );
2026-08-09 03:30:15 +02:00
}
/**
* Resolve where `skill install` / `skill uninstall` operate. Global is
* `~/.claude/skills/codeman` (Claude Code's user-scope skill dir, read by every new
2026-10-11 07:10:41 +02:00
* session); `--case <name>` targets `<case>/<skillDir>/codeman` for the given skills
* directory (default: Claude Code's, `<case>/.claude/skills/codeman`), resolved through
2026-08-09 03:30:15 +02:00
* `resolveCliCasePath()` above. The web server's automatic per-case injection
* (`agentSkillEnabled`) covers multi-user spaces; this CLI is a local operator tool
* and stays single-user.
2026-08-09 11:49:30 +02:00
*
* A missing case is REPORTED, not exited on: the exit lives in the wrapper below so
* this resolution (including the linked-cases lookup, which shipped unguarded) can be
* unit-tested without `process.exit(1)` taking the test runner down with it.
2026-08-09 03:30:15 +02:00
*/
2026-08-09 11:49:30 +02:00
export function resolveSkillTargetPath ( options : {
case ?: string ;
2026-10-11 07:10:41 +02:00
skillDir? : AgentSkillDir ;
2026-08-09 11:49:30 +02:00
}) : { target : string ; missingCase? : undefined } | { target? : undefined ; missingCase : string } {
2026-08-09 03:30:15 +02:00
if ( options . case ) {
const casePath = resolveCliCasePath ( options . case );
2026-08-09 11:49:30 +02:00
if ( ! existsSync ( casePath )) return { missingCase : casePath };
2026-10-11 07:10:41 +02:00
return { target : agentSkillDirFor ( casePath , options . skillDir ?? CLAUDE_AGENT_SKILL_DIR ) };
2026-08-09 03:30:15 +02:00
}
2026-08-09 11:49:30 +02:00
return { target : join ( homedir (), '.claude' , 'skills' , 'codeman' ) };
}
/** Exit-owning wrapper around `resolveSkillTargetPath()` for the two commands below. */
2026-10-11 07:10:41 +02:00
function resolveSkillTarget ( options : { case ?: string ; skillDir? : AgentSkillDir }) : string {
2026-08-09 11:49:30 +02:00
const resolved = resolveSkillTargetPath ( options );
if ( resolved . missingCase !== undefined ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ Case not found: ${ resolved . missingCase } ` ));
2026-08-09 11:49:30 +02:00
process . exit ( 1 );
}
return resolved . target ;
2026-08-09 03:30:15 +02:00
}
2026-10-11 07:10:41 +02:00
/**
* The skills directory `skill install --mode <mode>` writes into, or why it cannot: a
* mode the registry gives no skill, or a user-level install for a CLI other than Claude
* Code (each CLI keeps its user-level skills somewhere else, and `npx skills add`
* already knows every one of those places).
*/
export function resolveSkillInstallDir ( options : {
mode? : string ;
case ?: string ;
}) : { skillDir : AgentSkillDir ; error? : undefined } | { skillDir? : undefined ; error : string } {
const mode = options . mode ?? 'claude' ;
const cli = getCli ( mode );
if ( ! cli ) return { error : `Unknown mode: ${ mode } ` };
const skillDir = agentSkillDir ( cli . capabilities );
if ( ! skillDir ) return { error : `Mode ${ mode } takes no Codeman agent skill` };
if ( ! options . case && skillDir !== CLAUDE_AGENT_SKILL_DIR ) {
return {
error : `A user-level install for ${ mode } goes through the skills installer: npx skills add Ark0N/Codeman --skill codeman -g (or pass --case <name>)` ,
};
}
return { skillDir };
}
/**
* Every distinct skills directory the registry can inject into, so
* `skill uninstall --case` removes whatever any CLI's session left there.
*/
export function registryAgentSkillDirs () : AgentSkillDir [] {
const dirs = new Set < AgentSkillDir >();
for ( const cli of listClis ()) {
const dir = agentSkillDir ( cli . capabilities );
if ( dir ) dirs . add ( dir );
}
return [... dirs ];
}
/** An AgentSkillApplyResult in words, and whether it counts as success. */
function describeSkillResult ( result : AgentSkillApplyResult , target : string ) : { ok : boolean ; text : string } {
2026-08-09 03:30:15 +02:00
const messages : Record < AgentSkillApplyResult , { ok : boolean ; text : string }> = {
installed : { ok : true , text : `Agent skill installed: ${ target } ` },
refreshed : { ok : true , text : `Agent skill refreshed (was stale): ${ target } ` },
unchanged : { ok : true , text : `Agent skill already up to date: ${ target } ` },
removed : { ok : true , text : `Agent skill removed: ${ target } ` },
absent : { ok : true , text : `Nothing to remove at ${ target } ` },
foreign : {
ok : false ,
text : ` ${ target } exists but is not Codeman-managed (no marker), refusing to touch it. Remove it yourself if you want the packaged skill there.` ,
},
symlink : {
ok : false ,
2026-11-08 08:33:02 +01:00
text : ` ${ target } , its parent or its reference folder is a symlink, refusing to write through it.` ,
2026-08-09 03:30:15 +02:00
},
};
2026-10-11 07:10:41 +02:00
return messages [ result ];
}
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */
function reportSkillResult ( result : AgentSkillApplyResult , target : string ) : void {
const message = describeSkillResult ( result , target );
2026-08-09 03:30:15 +02:00
if ( message . ok ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ ${ message . text } ` ));
2026-08-09 03:30:15 +02:00
} else {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ ${ message . text } ` ));
2026-08-09 03:30:15 +02:00
process . exit ( 1 );
}
}
const skillCmd = program
. command ( 'skill' )
. description ( 'Manage the Codeman agent skill (lets an agent inside a session drive the API)' );
skillCmd
. command ( 'install' )
. description ( 'Install the agent skill globally (~/.claude/skills/codeman) or into one case' )
. option ( '-g, --global' , 'Install into ~/.claude/skills/codeman, picked up by every new session (the default)' )
. option ( '-c, --case <name>' , 'Install into <case>/.claude/skills/codeman instead (linked cases resolve too)' )
2026-10-11 07:10:41 +02:00
. option (
'-m, --mode <mode>' ,
'With --case: install where that run mode looks for skills (codex: <case>/.agents/skills/codeman)' ,
'claude'
)
. action ( async ( options : { global ?: boolean ; case ?: string ; mode? : string }) => {
2026-08-09 03:30:15 +02:00
try {
2026-10-11 07:10:41 +02:00
const resolved = resolveSkillInstallDir ( options );
if ( resolved . error !== undefined ) {
console . error ( palette . err ( `✗ ${ resolved . error } ` ));
process . exit ( 1 );
}
const target = resolveSkillTarget ({ case : options . case , skillDir : resolved.skillDir });
2026-08-09 03:30:15 +02:00
reportSkillResult ( await installAgentSkillInto ( target ), target );
} catch ( err ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ Failed to install agent skill: ${ getErrorMessage ( err ) } ` ));
2026-08-09 03:30:15 +02:00
process . exit ( 1 );
}
});
skillCmd
. command ( 'uninstall' )
2026-10-11 07:10:41 +02:00
. description ( 'Remove Codeman-managed agent skill copies (never touches a user-authored one)' )
2026-08-09 03:30:15 +02:00
. option ( '-g, --global' , 'Remove from ~/.claude/skills/codeman (the default)' )
2026-10-11 07:10:41 +02:00
. option (
'-c, --case <name>' ,
"Remove every Codeman-managed copy from that case instead, whichever CLI's directory it is in (linked cases resolve too)"
)
2026-08-09 03:30:15 +02:00
. action ( async ( options : { global ?: boolean ; case ?: string }) => {
try {
2026-10-11 07:10:41 +02:00
if ( ! options . case ) {
const target = resolveSkillTarget ( options );
reportSkillResult ( await removeAgentSkillFrom ( target ), target );
return ;
}
let failed = false ;
let removedAny = false ;
for ( const skillDir of registryAgentSkillDirs ()) {
const target = resolveSkillTarget ({ case : options . case , skillDir });
const result = await removeAgentSkillFrom ( target );
if ( result === 'absent' ) continue ;
2026-10-13 17:44:07 +02:00
if ( result === 'symlink' ) {
// A shared-skills layout (`.agents/skills -> ../.claude/skills`) is deliberate:
// skip it rather than fail the whole sweep.
2026-11-08 08:33:02 +01:00
console . log (
palette . warn (
`- Skipped ${ target } : it, its parent or its reference folder is a symlink, so it was not touched`
)
);
2026-10-13 17:44:07 +02:00
continue ;
}
2026-10-11 07:10:41 +02:00
removedAny = true ;
const message = describeSkillResult ( result , target );
if ( message . ok ) console . log ( palette . ok ( `✓ ${ message . text } ` ));
else {
console . error ( palette . err ( `✗ ${ message . text } ` ));
failed = true ;
}
}
if ( ! removedAny ) console . log ( palette . ok ( `✓ No Codeman-managed agent skill in case ${ options . case } ` ));
if ( failed ) process . exit ( 1 );
2026-08-09 03:30:15 +02:00
} catch ( err ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ Failed to remove agent skill: ${ getErrorMessage ( err ) } ` ));
2026-08-09 03:30:15 +02:00
process . exit ( 1 );
}
});
2026-10-08 15:00:29 +02:00
// ============ Agent Commands (session-to-session, any CLI mode) ============
registerAgentCommands ( program );
2026-10-24 20:43:17 -04:00
registerHistoryCommands ( program );
2026-10-08 15:00:29 +02:00
2026-10-13 13:23:56 +02:00
// ============ MCP server (any MCP client drives Codeman) ============
program
. command ( 'mcp' )
. description (
'Run a Model Context Protocol server on stdio, so MCP clients (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start, prompt, wait on, read and delete Codeman sessions'
)
. option ( '--url <url>' , 'Codeman server URL (default: $CODEMAN_API_URL; never guessed)' )
. option (
'--allow-any-session' ,
'Let the tools prompt, interrupt and delete sessions this server did not start (default: only its own)'
)
. action ( async ( options : { url? : string ; allowAnySession? : boolean }) => {
await runMcpStdio (
resolveMcpConfig ({ url : options.url , allowAnySession : options.allowAnySession , version : pkg.version })
);
process . exit ( 0 );
});
2026-01-18 04:50:46 +01:00
// ============ Session Commands ============
2026-02-27 13:16:23 +01:00
const sessionCmd = program . command ( 'session' ). alias ( 's' ). description ( 'Manage Claude sessions' );
2026-01-18 04:50:46 +01:00
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 );
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Session started: ${ session . id } ` ));
2026-01-18 04:50:46 +01:00
console . log ( ` Working directory: ${ session . workingDir } ` );
console . log ( ` PID: ${ session . pid } ` );
} catch ( err ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ Failed to start session: ${ getErrorMessage ( err ) } ` ));
2026-01-18 04:50:46 +01:00
process . exit ( 1 );
}
});
sessionCmd
. command ( 'stop <id>' )
. description ( 'Stop a session' )
. action ( async ( id ) => {
try {
const manager = getSessionManager ();
await manager . stopSession ( id );
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Session stopped: ${ id } ` ));
2026-01-18 04:50:46 +01:00
} catch ( err ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ Failed to stop session: ${ getErrorMessage ( err ) } ` ));
2026-01-18 04:50:46 +01:00
process . exit ( 1 );
}
});
2026-08-16 18:23:44 +02:00
/** Session status in the shared vocabulary: idle is fine, busy is working, anything else is a problem. */
function sessionStatusLabel ( status : string ) : string {
if ( status === 'idle' ) return palette . ok ( 'idle' );
if ( status === 'busy' ) return palette . warn ( 'busy' );
return palette . err ( status );
}
/**
* The one session listing. `codeman list` used to be a copy of this that had
* drifted (it lost the stopped and web-server sections), so it now calls the
* same renderer and only opts out of those two sections.
*/
function printSessionList ( options : { includeStored : boolean }) : void {
const manager = getSessionManager ();
const sessions = manager . getAllSessions ();
const stored = manager . getStoredSessions ();
if ( sessions . length === 0 && Object . keys ( stored ). length === 0 ) {
console . log ( palette . warn ( 'No sessions found' ));
return ;
}
console . log ( heading ( 'Active Sessions:' ));
if ( sessions . length === 0 ) {
console . log ( ' (none)' );
} else {
for ( const session of sessions ) {
console . log (
` ${ palette . info ( session . id . slice ( 0 , 8 )) } ${ sessionStatusLabel ( session . status ) } ${ session . workingDir } `
);
}
}
if ( options . includeStored ) {
const stoppedSessions = Object . values ( stored ). filter (( s ) => s . status === 'stopped' );
if ( stoppedSessions . length > 0 ) {
console . log ( heading ( 'Stopped Sessions:' ));
for ( const session of stoppedSessions ) {
const name = session . name ? ` ( ${ session . name } )` : '' ;
console . log (
` ${ palette . muted ( session . id . slice ( 0 , 8 )) } ${ palette . muted ( 'stopped' ) }${ name } ${ session . workingDir } `
);
}
}
// Sessions the web server owns: this process has no PTY for them, so they
// only exist in the shared state file.
const activeSessions = Object . values ( stored ). filter (( s ) => s . status !== 'stopped' );
if ( sessions . length === 0 && activeSessions . length > 0 ) {
console . log ( heading ( 'Active Sessions (from web server):' ));
for ( const session of activeSessions ) {
const name = session . name ? ` ( ${ session . name } )` : '' ;
const mode = session . mode === 'shell' ? palette . muted ( ' [shell]' ) : '' ;
const cost = session . totalCost ? palette . muted ( ` $ ${ session . totalCost . toFixed ( 4 ) } ` ) : '' ;
console . log (
` ${ palette . info ( session . id . slice ( 0 , 8 )) } ${ sessionStatusLabel ( session . status ) }${ name }${ mode }${ cost } ${ session . workingDir } `
);
}
}
}
console . log ( '' );
}
2026-01-18 04:50:46 +01:00
sessionCmd
. command ( 'list' )
. alias ( 'ls' )
. description ( 'List all sessions' )
2026-08-16 18:23:44 +02:00
. action (() => printSessionList ({ includeStored : true }));
2026-01-18 04:50:46 +01:00
sessionCmd
. command ( 'logs <id>' )
. description ( 'View session output' )
. option ( '-e, --errors' , 'Show stderr instead of stdout' )
. action (( id , options ) => {
const manager = getSessionManager ();
2026-02-27 13:16:23 +01:00
const output = options . errors ? manager . getSessionError ( id ) : manager . getSessionOutput ( id );
2026-01-18 04:50:46 +01:00
if ( output === null ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( `Session ${ id } not found or not active` ));
2026-01-18 04:50:46 +01:00
return ;
}
if ( output === '' ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . muted ( '(no output)' ));
2026-01-18 04:50:46 +01:00
return ;
}
console . log ( output );
});
// ============ Task Commands ============
2026-02-27 13:16:23 +01:00
const taskCmd = program . command ( 'task' ). alias ( 't' ). description ( 'Manage tasks' );
2026-01-18 04:50:46 +01:00
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 ,
});
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Task added: ${ task . id } ` ));
2026-01-18 04:50:46 +01:00
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 ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( 'No tasks found' ));
2026-01-18 04:50:46 +01:00
return ;
}
const statusColors = {
2026-08-16 18:23:44 +02:00
pending : palette.muted ,
running : palette.warn ,
completed : palette.ok ,
failed : palette.err ,
2026-01-18 04:50:46 +01:00
};
2026-08-16 18:23:44 +02:00
console . log ( palette . emph ( '\nTasks:' ));
2026-01-18 04:50:46 +01:00
for ( const task of tasks ) {
const color = statusColors [ task . status ];
const prompt = task . prompt . slice ( 0 , 40 ) + ( task . prompt . length > 40 ? '...' : '' );
2026-08-16 18:23:44 +02:00
console . log (
` ${ palette . info ( task . id . slice ( 0 , 8 )) } ${ color ( task . status . padEnd ( 10 )) } [ ${ task . priority } ] ${ prompt } `
);
2026-01-18 04:50:46 +01:00
}
const counts = queue . getCount ();
2026-08-16 18:23:44 +02:00
console . log ( palette . emph ( '\nSummary:' ));
2026-02-27 13:16:23 +01:00
console . log (
` Pending: ${ counts . pending } , Running: ${ counts . running } , Completed: ${ counts . completed } , Failed: ${ counts . failed } `
);
2026-01-18 04:50:46 +01:00
console . log ( '' );
});
taskCmd
. command ( 'status <id>' )
. description ( 'Show task details' )
. action (( id ) => {
const queue = getTaskQueue ();
const task = queue . getTask ( id );
if ( ! task ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . err ( `Task ${ id } not found` ));
2026-01-18 04:50:46 +01:00
return ;
}
2026-08-16 18:23:44 +02:00
console . log ( palette . emph ( '\nTask Details:' ));
2026-01-18 04:50:46 +01:00
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 ) {
2026-08-16 18:23:44 +02:00
console . log ( ` Error: ${ palette . err ( task . error ) } ` );
2026-01-18 04:50:46 +01:00
}
if ( task . output ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . emph ( '\nOutput:' ));
2026-01-18 04:50:46 +01:00
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 )) {
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Task removed: ${ id } ` ));
2026-01-18 04:50:46 +01:00
} else {
2026-08-16 18:23:44 +02:00
console . log ( palette . err ( `Task ${ id } not found` ));
2026-01-18 04:50:46 +01:00
}
});
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 ();
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Cleared ${ count } tasks` ));
2026-01-18 04:50:46 +01:00
} else if ( options . failed ) {
count = queue . clearFailed ();
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Cleared ${ count } failed tasks` ));
2026-01-18 04:50:46 +01:00
} else {
count = queue . clearCompleted ();
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Cleared ${ count } completed tasks` ));
2026-01-18 04:50:46 +01:00
}
});
// ============ Ralph Loop Commands ============
2026-02-27 13:16:23 +01:00
const ralphCmd = program . command ( 'ralph' ). alias ( 'r' ). description ( 'Control the Ralph autonomous loop' );
2026-01-18 04:50:46 +01:00
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 ()) {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( 'Ralph loop is already running' ));
2026-01-18 04:50:46 +01:00
return ;
}
loop . on ( 'taskAssigned' , ( taskId , sessionId ) => {
2026-08-16 18:23:44 +02:00
console . log ( palette . info ( `→ Task ${ taskId . slice ( 0 , 8 ) } assigned to session ${ sessionId . slice ( 0 , 8 ) } ` ));
2026-01-18 04:50:46 +01:00
});
loop . on ( 'taskCompleted' , ( taskId ) => {
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Task ${ taskId . slice ( 0 , 8 ) } completed` ));
2026-01-18 04:50:46 +01:00
});
loop . on ( 'taskFailed' , ( taskId , error ) => {
2026-08-16 18:23:44 +02:00
console . log ( palette . err ( `✗ Task ${ taskId . slice ( 0 , 8 ) } failed: ${ error } ` ));
2026-01-18 04:50:46 +01:00
});
loop . on ( 'stopped' , () => {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( '\nRalph loop stopped' ));
2026-01-18 04:50:46 +01:00
printStats ( loop . getStats ());
process . exit ( 0 );
});
await loop . start ();
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( '✓ Ralph loop started' ));
2026-01-18 04:50:46 +01:00
if ( options . minHours ) {
console . log ( ` Minimum duration: ${ options . minHours } hours` );
}
2026-08-16 18:23:44 +02:00
console . log ( palette . muted ( ' Press Ctrl+C to stop\n' ));
2026-01-18 04:50:46 +01:00
// Keep process running
process . on ( 'SIGINT' , () => {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( '\nStopping Ralph loop...' ));
2026-01-18 04:50:46 +01:00
loop . stop ();
});
});
ralphCmd
. command ( 'stop' )
. description ( 'Stop the Ralph loop' )
. action (() => {
const loop = getRalphLoop ();
if ( ! loop . isRunning ()) {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( 'Ralph loop is not running' ));
2026-01-18 04:50:46 +01:00
return ;
}
loop . stop ();
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( '✓ Ralph loop stopped' ));
2026-01-18 04:50:46 +01:00
});
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' ] >) {
2026-08-16 18:23:44 +02:00
const statusColor =
stats . status === 'running' ? palette.ok : stats.status === 'paused' ? palette.warn : palette.muted ;
2026-01-18 04:50:46 +01:00
2026-08-16 18:23:44 +02:00
console . log ( palette . emph ( '\nRalph Loop Status:' ));
2026-01-18 04:50:46 +01:00
console . log ( ` Status: ${ statusColor ( stats . status ) } ` );
console . log ( ` Elapsed: ${ stats . elapsedHours . toFixed ( 2 ) } hours` );
if ( stats . minDurationMs ) {
const minHours = stats . minDurationMs / ( 1000 * 60 * 60 );
2026-02-27 13:16:23 +01:00
console . log (
` Min Duration: ${ minHours . toFixed ( 2 ) } hours ( ${ stats . minDurationReached ? 'reached' : 'not reached' } )`
);
2026-01-18 04:50:46 +01:00
}
2026-08-16 18:23:44 +02:00
console . log ( palette . emph ( '\nTasks:' ));
2026-01-18 04:50:46 +01:00
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 } ` );
2026-08-16 18:23:44 +02:00
console . log ( palette . emph ( '\nSessions:' ));
2026-01-18 04:50:46 +01:00
console . log ( ` Active: ${ stats . activeSessions } ` );
console . log ( ` Idle: ${ stats . idleSessions } ` );
console . log ( ` Busy: ${ stats . busySessions } ` );
console . log ( '' );
}
// ============ Utility Commands ============
2026-08-09 04:06:24 +02:00
/** What probing the web server found. */
interface WebServerProbe {
reachable : boolean ;
/** The URL that answered, or the first candidate when nothing did. */
url : string ;
statusCode? : number ;
version? : string ;
authRequired? : boolean ;
/** Live session states from `/api/status`, when the probe could read them. */
sessions? : Array < { status? : string } > ;
}
/**
* GET `<base>/api/status` with a short timeout, tolerating the self-signed cert an
* `--https` install uses. ANY HTTP answer proves the server is up: a 401 just
* means it wants credentials (sent when available, same env → data-dir `.env`
* fallback as `codeman attach`).
*/
function probeWebServerAt ( base : string ) : Promise < WebServerProbe | null > {
let url : URL ;
try {
url = new URL ( '/api/status' , base );
} catch {
return Promise . resolve ( null );
}
2026-10-08 14:37:46 +02:00
const { username , password } = readCodemanCredentials ();
2026-08-09 04:06:24 +02:00
const transport = url . protocol === 'https:' ? https : http ;
const headers : Record < string , string > = { Accept : 'application/json' };
if ( password ) {
headers . Authorization = `Basic ${ Buffer . from ( ` ${ username } : ${ password } ` ). toString ( 'base64' ) } ` ;
}
return new Promise (( resolve ) => {
const req = transport . request (
{
protocol : url.protocol ,
hostname : url.hostname ,
port : url.port ,
method : 'GET' ,
path : url.pathname ,
rejectUnauthorized : false ,
headers ,
timeout : 3000 ,
},
( res ) => {
const chunks : Buffer [] = [];
let received = 0 ;
res . on ( 'data' , ( chunk : Buffer ) => {
received += chunk . length ;
if ( received <= 1024 * 1024 ) chunks . push ( chunk );
});
res . on ( 'end' , () => {
const statusCode = res . statusCode ?? 0 ;
if ( statusCode === 401 ) {
resolve ({ reachable : true , url : base , statusCode , authRequired : true });
return ;
}
let version : string | undefined ;
let sessions : Array < { status? : string } > | undefined ;
try {
const parsed = JSON . parse ( Buffer . concat ( chunks ). toString ( 'utf-8' )) as {
data ?: { version? : unknown ; sessions? : unknown };
};
const data = parsed ? . data ?? ( parsed as { version? : unknown ; sessions? : unknown });
if ( typeof data ? . version === 'string' ) version = data . version ;
if ( Array . isArray ( data ? . sessions )) sessions = data . sessions as Array < { status? : string } > ;
} catch {
// Not JSON, but still an answer, so still running.
}
resolve ({ reachable : true , url : base , statusCode , version , sessions });
});
}
);
req . on ( 'timeout' , () => req . destroy ( new Error ( 'timeout' )));
req . on ( 'error' , () => resolve ( null ));
req . end ();
});
}
2026-01-18 04:50:46 +01:00
program
. command ( 'status' )
2026-08-09 04:06:24 +02:00
. description ( 'Show whether the Codeman web server is running, plus session/task state' )
. option ( '--url <url>' , 'Server URL to probe (defaults to CODEMAN_API_URL, then local port)' )
. action ( async ( options : { url? : string }) => {
// Issue #230: this command runs in its own fresh process, and the old output
// reported THAT process's (always-stopped) Ralph loop under a bare "Status:",
// reading as "the server is down" while the web service ran fine. Probe the
// real server first; the Ralph loop has its own `codeman ralph status`.
const port = process . env . CODEMAN_PORT || '3000' ;
const candidates = options . url
? [ options . url ]
: process . env . CODEMAN_API_URL
? [ process . env . CODEMAN_API_URL ]
: [ `https://127.0.0.1: ${ port } ` , `http://127.0.0.1: ${ port } ` ];
let probe : WebServerProbe = { reachable : false , url : candidates [ 0 ] };
for ( const candidate of candidates ) {
const answer = await probeWebServerAt ( candidate );
if ( answer ) {
probe = answer ;
break ;
}
}
2026-01-22 20:36:06 +01:00
2026-08-16 18:23:44 +02:00
console . log ( heading ( 'Codeman Status' ));
console . log ( rule ( 40 ));
2026-01-18 04:50:46 +01:00
2026-08-16 18:23:44 +02:00
console . log ( heading ( 'Web Server:' ));
2026-08-09 04:06:24 +02:00
if ( probe . reachable ) {
const version = probe . version ? ` (v ${ probe . version } )` : '' ;
2026-08-16 18:23:44 +02:00
console . log ( kv ( 'Status' , ` ${ palette . ok ( 'running' ) }${ version } at ${ probe . url } ` ));
2026-08-09 04:06:24 +02:00
if ( probe . authRequired ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . muted ( ' (answers 401: set CODEMAN_PASSWORD/CODEMAN_USERNAME to see session details)' ));
2026-08-09 04:06:24 +02:00
}
} else {
2026-08-16 18:23:44 +02:00
console . log ( kv ( 'Status' , ` ${ palette . err ( 'not reachable' ) } at ${ candidates . join ( ' or ' ) } ` ));
2026-08-09 04:06:24 +02:00
console . log (
2026-08-16 18:23:44 +02:00
palette . muted ( ' (start it with `codeman web`, or check your service: systemctl --user status codeman-web)' )
2026-08-09 04:06:24 +02:00
);
}
2026-01-18 04:50:46 +01:00
2026-08-09 04:06:24 +02:00
// Prefer the server's live view; fall back to the shared saved state, labeled
// as such, so the numbers are never silently a different thing.
if ( probe . sessions ) {
const live = probe . sessions ;
2026-08-16 18:23:44 +02:00
console . log ( heading ( 'Sessions (live, from the server):' ));
console . log ( kv ( 'Total' , String ( live . length )));
console . log ( kv ( 'Idle' , String ( live . filter (( s ) => s . status === 'idle' ). length )));
console . log ( kv ( 'Busy' , String ( live . filter (( s ) => s . status === 'busy' ). length )));
2026-08-09 04:06:24 +02:00
} else {
const manager = getSessionManager ();
const storedValues = Object . values ( manager . getStoredSessions ());
2026-08-16 18:23:44 +02:00
console . log ( heading ( 'Sessions (from saved state):' ));
console . log ( kv ( 'Active' , String ( storedValues . filter (( s ) => s . status !== 'stopped' ). length )));
console . log ( kv ( 'Idle' , String ( storedValues . filter (( s ) => s . status === 'idle' ). length )));
console . log ( kv ( 'Busy' , String ( storedValues . filter (( s ) => s . status === 'busy' ). length )));
2026-08-09 04:06:24 +02:00
}
const taskCounts = getTaskQueue (). getCount ();
2026-08-16 18:23:44 +02:00
console . log ( heading ( 'Tasks:' ));
console . log ( kv ( 'Total' , String ( taskCounts . total )));
console . log ( kv ( 'Pending' , String ( taskCounts . pending )));
console . log ( kv ( 'Running' , String ( taskCounts . running )));
console . log ( kv ( 'Completed' , String ( taskCounts . completed )));
console . log ( kv ( 'Failed' , String ( taskCounts . failed )));
2026-01-18 04:50:46 +01:00
console . log ( '' );
});
program
. command ( 'reset' )
. description ( 'Reset all state' )
. option ( '-f, --force' , 'Skip confirmation' )
. action ( async ( options ) => {
if ( ! options . force ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( 'This will stop all sessions and clear all state.' ));
// Non-interactive callers keep the old refusal: a script piping into the
// CLI must never be able to reset state by hanging on an unseen question.
if ( ! isInteractive ()) {
console . log ( palette . warn ( 'Use --force to confirm.' ));
return ;
}
if ( ! ( await confirm ( 'Reset all Codeman state?' ))) {
console . log ( palette . muted ( '○ Cancelled, nothing was changed' ));
return ;
}
2026-01-18 04:50:46 +01:00
}
const manager = getSessionManager ();
const store = getStore ();
await manager . stopAllSessions ();
store . reset ();
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( '✓ All state reset' ));
2026-01-18 04:50:46 +01:00
});
// 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 );
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Session started: ${ session . id } ` ));
2026-01-18 04:50:46 +01:00
});
program
. command ( 'list' )
. alias ( 'ls' )
2026-08-16 18:23:44 +02:00
. description ( 'List active sessions (shorthand; `codeman session list` also shows stopped ones)' )
. action (() => printSessionList ({ includeStored : false }));
2026-01-18 04:50:46 +01:00
2026-08-16 20:01:17 +02:00
// ============ TUI ============
program
. command ( 'tui' )
. argument ( '[n]' , 'attach straight to the nth session of `codeman tui --list`' )
. description ( 'Terminal dashboard for your sessions (the web UI remains the primary surface)' )
. option ( '-l, --list' , 'Print the numbered session list and exit, instead of opening the dashboard' )
. action ( async ( position : string | undefined , options : { list? : boolean }) => {
// Imported here, not at the top: the dashboard pulls in the whole TUI core,
// and every other command would pay for it at startup.
const { runTui , runTuiAttach , runTuiList } = await import ( './tui/tui-app.js' );
if ( options . list ) {
process . exitCode = await runTuiList ();
return ;
}
if ( position !== undefined ) {
const n = Number . parseInt ( position , 10 );
if ( ! Number . isSafeInteger ( n ) || n < 1 ) {
console . error ( palette . err ( `" ${ position } " is not a session number.` ));
console . error ( `Run ${ palette . info ( 'codeman tui --list' ) } to see them.` );
process . exitCode = 1 ;
return ;
}
process . exitCode = await runTuiAttach ( n );
return ;
}
// The dashboard owns the terminal until it quits; exiting explicitly keeps a
// stray handle (a socket mid-close) from stranding the user's shell.
process . exit ( await runTui ());
});
2026-08-09 01:34:55 +02:00
// ============ Web / daemon / service Commands ============
/** Shared option set for the commands that can launch a web server. */
function addWebLaunchOptions ( cmd : Command ) : Command {
return cmd
. option ( '-H, --host <host>' , 'Host to bind to' , process . env . CODEMAN_HOST || '127.0.0.1' )
. option ( '-p, --port <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)' )
2026-09-04 12:10:20 -04:00
. option (
'--base-url <path>' ,
'Sub-path Codeman is mounted under behind a reverse proxy, e.g. /codeman (env: CODEMAN_BASE_URL)' ,
process . env . CODEMAN_BASE_URL || '/'
)
2026-08-09 01:34:55 +02:00
. option ( '--title-hostname <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)'
)
2026-10-26 02:11:16 -04:00
. option (
'--allow-unauthenticated-tunnel' ,
'Allow starting the Cloudflare tunnel without CODEMAN_PASSWORD (dangerous; publishes terminal control to a public URL; env: CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL)'
)
2026-08-09 01:34:55 +02:00
. option (
'--multiuser' ,
'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)'
);
}
/** Normalize commander's strings into the shape daemon-control/service-installer take. */
function toWebLaunchOptions ( options : {
host : string ;
port : string ;
https? : boolean ;
2026-09-04 12:10:20 -04:00
baseUrl? : string ;
2026-08-09 01:34:55 +02:00
titleHostname? : string ;
allowUnauthenticatedNetwork? : boolean ;
2026-10-26 02:11:16 -04:00
allowUnauthenticatedTunnel? : boolean ;
2026-08-09 01:34:55 +02:00
multiuser? : boolean ;
}) : WebLaunchOptions {
const port = parseInt ( options . port , 10 );
if ( ! Number . isInteger ( port ) || port <= 0 || port > 65535 ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ Invalid port: ${ options . port } ` ));
2026-08-09 01:34:55 +02:00
process . exit ( 1 );
}
2026-09-04 12:10:20 -04:00
let basePath : string ;
try {
basePath = assertValidBasePath ( options . baseUrl );
} catch ( err ) {
console . error ( palette . err ( `✗ ${ err instanceof Error ? err.message : String ( err ) } ` ));
process . exit ( 1 );
}
2026-08-09 01:34:55 +02:00
return {
host : options.host ,
port ,
https : !! options . https ,
2026-09-04 12:10:20 -04:00
basePath ,
2026-08-09 01:34:55 +02:00
titleHostname : options.titleHostname ,
allowUnauthenticatedNetwork : !! options . allowUnauthenticatedNetwork ,
2026-10-26 02:11:16 -04:00
allowUnauthenticatedTunnel : !! options . allowUnauthenticatedTunnel ,
2026-08-09 01:34:55 +02:00
multiuser : !! options . multiuser ,
};
}
/**
* The server prints this itself, but into a log file nobody reads when it is
* detached or supervised. Repeat it where the operator is actually looking.
*/
function warnIfUnauthenticatedNetwork ( launch : WebLaunchOptions ) : void {
if ( isLoopbackBindHost ( launch . host )) return ;
if ( isUnauthenticatedNetworkAcknowledged ( launch . allowUnauthenticatedNetwork )) return ;
console . log (
2026-08-16 18:23:44 +02:00
palette . warn (
2026-08-09 01:34:55 +02:00
`⚠ Binding ${ launch . host } without CODEMAN_PASSWORD: anyone who can reach this port gets terminal control.`
)
);
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( ' Set CODEMAN_PASSWORD, or bind 127.0.0.1 and front it with tailscale serve.' ));
2026-08-09 01:34:55 +02:00
}
2026-01-18 05:14:34 +01:00
// Web interface command
2026-08-09 01:34:55 +02:00
const webCmd = addWebLaunchOptions ( program . command ( 'web' ). description ( 'Start the web interface' ))
. option ( '-d, --daemon' , 'Run detached in the background; survives the shell, logs to <data dir>/web.log' )
. option ( '--stop' , 'Stop a server started with --daemon' )
. option ( '--status' , 'Report whether a detached server is running' );
2026-01-18 05:14:34 +01:00
2026-08-09 01:34:55 +02:00
webCmd . action ( async ( options ) => {
// The flag is surfaced to the rest of the process via the env var so
// isMultiUserMode() has a single source of truth (see config/multiuser.ts).
if ( options . multiuser ) process . env . CODEMAN_MULTIUSER = '1' ;
const launch = toWebLaunchOptions ( options );
2026-01-18 05:14:34 +01:00
2026-08-09 01:34:55 +02:00
if ( options . stop ) {
2026-08-16 18:23:44 +02:00
// stopDaemon waits for the process to actually exit (up to 15s).
const result = await withSpinner ( 'Stopping Codeman...' , () => stopDaemon ( launch ));
2026-08-09 01:34:55 +02:00
if ( result . ok && result . reason === 'not-running' ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . muted ( `○ ${ result . message } ` ));
2026-08-09 01:34:55 +02:00
return ;
}
if ( result . ok ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ ${ result . message ?? `Stopped Codeman (pid ${ result . pid } )` } ` ));
console . log ( palette . muted ( ' Your agents keep running in tmux.' ));
2026-08-09 01:34:55 +02:00
return ;
}
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ ${ result . message ?? 'Could not stop the server' } ` ));
2026-08-09 01:34:55 +02:00
process . exit ( 1 );
}
if ( options . status ) {
const status = await daemonStatus ( launch );
if ( status . responding ) {
const version = status . version ? ` (v ${ status . version } )` : '' ;
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Responding at ${ status . url }${ version } ` ));
2026-08-09 01:34:55 +02:00
} else {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( `○ Nothing answering at ${ status . url } ` ));
2026-08-09 01:34:55 +02:00
}
2026-08-16 18:23:44 +02:00
console . log ( kv ( 'Daemon pid' , status . running ? palette . ok ( String ( status . pid )) : palette . muted ( 'not running' ), 11 ));
console . log ( palette . muted ( kv ( 'Pidfile' , status . pidFile , 11 )));
console . log ( palette . muted ( kv ( 'Log' , status . logPath , 11 )));
2026-08-09 01:34:55 +02:00
if ( ! status . running && status . responding ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . muted ( ' (running, but not started with --daemon: probably a service or a foreground run)' ));
2026-08-09 01:34:55 +02:00
}
return ;
}
if ( options . daemon ) {
warnIfUnauthenticatedNetwork ( launch );
2026-08-16 18:23:44 +02:00
// The start polls /api/status for up to 30s; without this the shell just sits there.
const result = await withSpinner ( 'Starting Codeman in the background, waiting for it to answer...' , () =>
startDaemon ( launch )
);
2026-08-09 01:34:55 +02:00
if ( result . ok ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( ` \ n✓ Codeman is running at ${ result . url } (pid ${ result . pid } )` ));
console . log ( palette . muted ( ` Logs: ${ result . logPath } ` ));
console . log ( palette . muted ( ' Stop it with: codeman web --stop' ));
console . log ( palette . muted ( ' Want it back after a reboot? codeman service install' ));
2026-08-09 01:34:55 +02:00
return ;
}
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( ` \ n✗ ${ result . message ?? 'Failed to start' } ` ));
2026-08-09 01:34:55 +02:00
process . exit ( 1 );
}
const { startWebServer } = await import ( './web/server.js' );
const host = launch . host ;
const port = launch . port ;
const https = launch . https ;
const titleHostname = options . titleHostname ;
const allowUnauthenticatedNetwork = launch . allowUnauthenticatedNetwork ?? false ;
2026-10-26 02:11:16 -04:00
const allowUnauthenticatedTunnel = launch . allowUnauthenticatedTunnel ?? false ;
2026-09-04 12:10:20 -04:00
const basePath = launch . basePath ?? '' ;
// Single source of truth for subsystems that read it directly (e.g. renderers).
if ( basePath ) process . env . CODEMAN_BASE_URL = basePath ;
2026-08-09 01:34:55 +02:00
const displayHost = host === '0.0.0.0' ? 'localhost' : host ;
2026-09-04 12:10:20 -04:00
console . log (
palette . info (
`Starting Codeman web interface on ${ displayHost } : ${ port }${ basePath ? basePath + '/' : '' }${ https ? ' (HTTPS)' : '' } ...`
)
);
2026-08-09 01:34:55 +02:00
try {
2026-08-16 18:23:44 +02:00
// The server prints its own "running at" line (it also covers the daemon and
// service launch paths), so this one used to be a duplicate of it.
2026-10-26 02:11:16 -04:00
const server = await startWebServer (
port ,
https ,
false ,
host ,
titleHostname ,
allowUnauthenticatedNetwork ,
basePath ,
allowUnauthenticatedTunnel
);
2026-08-09 01:34:55 +02:00
if ( https ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( ' Note: Accept the self-signed certificate in your browser on first visit' ));
2026-08-09 01:34:55 +02:00
}
2026-08-16 18:23:44 +02:00
console . log ( palette . muted ( ' Press Ctrl+C to stop\n' ));
2026-08-09 01:34:55 +02:00
// Graceful shutdown handler — flush state and clean up on SIGTERM/SIGINT
let shuttingDown = false ;
const shutdown = async ( signal : string ) => {
if ( shuttingDown ) return ;
shuttingDown = true ;
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( ` \ n ${ signal } received, shutting down gracefully...` ));
2026-09-24 01:35:27 +02:00
// A hung stop() must not keep the process alive: the listener is already
// closed by then, and a KeepAlive LaunchDaemon only respawns the server once
// it EXITS (systemd would SIGKILL after TimeoutStopSec; launchd does not).
// Seen after a self-update on macOS: port closed, process alive, service down.
setTimeout (() => {
console . error ( palette . err ( `Shutdown did not finish in ${ SHUTDOWN_FORCE_EXIT_MS / 1000 } s, forcing exit` ));
process . exit ( 1 );
}, SHUTDOWN_FORCE_EXIT_MS ). unref ();
2026-08-09 01:34:55 +02:00
try {
await server . stop ();
} catch ( err ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `Error during shutdown: ${ getErrorMessage ( err ) } ` ));
2026-01-24 02:12:22 +01:00
}
2026-08-09 01:34:55 +02:00
process . exit ( 0 );
};
process . on ( 'SIGTERM' , () => shutdown ( 'SIGTERM' ));
process . on ( 'SIGINT' , () => shutdown ( 'SIGINT' ));
process . on ( 'SIGHUP' , () => shutdown ( 'SIGHUP' ));
} catch ( err ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ Failed to start web server: ${ getErrorMessage ( err ) } ` ));
2026-08-09 01:34:55 +02:00
process . exit ( 1 );
}
});
2026-02-12 18:26:16 +01:00
2026-08-09 01:34:55 +02:00
// Supervised service: the "still there after a reboot" answer, where `web -d` is
// the "still there after I close this shell" one (issue #231).
const serviceCmd = program
. command ( 'service' )
. description ( 'Manage the background service (systemd user unit on Linux, LaunchAgent on macOS)' );
addWebLaunchOptions (
serviceCmd . command ( 'install' ). description ( 'Install and start the service, then verify it answers' )
). action ( async ( options ) => {
const launch = toWebLaunchOptions ( options );
warnIfUnauthenticatedNetwork ( launch );
2026-08-16 18:23:44 +02:00
// Install polls the new unit's /api/status for up to 30s before it can honestly
// report success, so the wait needs a visible heartbeat.
const result = await withSpinner ( 'Installing the Codeman service, waiting for it to answer...' , () =>
installService ( launch )
);
for ( const warning of result . warnings ?? []) console . log ( palette . warn ( `⚠ ${ warning } ` ));
2026-08-09 01:34:55 +02:00
if ( ! result . ok ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ ${ result . message } ` ));
2026-08-09 01:34:55 +02:00
process . exit ( 1 );
}
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ ${ result . message } ` ));
console . log ( palette . muted ( ` Unit: ${ result . unitPath } ` ));
2026-08-09 01:34:55 +02:00
if ( process . env . CODEMAN_PASSWORD ) {
console . log (
2026-08-16 18:23:44 +02:00
palette . warn (
2026-08-09 01:34:55 +02:00
' Note: CODEMAN_PASSWORD was NOT copied into the unit file. Add it there yourself if the service needs auth.'
)
);
}
});
serviceCmd
. command ( 'uninstall' )
. description ( 'Stop the service and remove its unit file' )
. action (() => {
const result = uninstallService ();
if ( ! result . ok ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ ${ result . message } ` ));
2026-01-18 05:14:34 +01:00
process . exit ( 1 );
}
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ ${ result . message } ` ));
2026-01-18 05:14:34 +01:00
});
2026-08-09 01:34:55 +02:00
addWebLaunchOptions (
serviceCmd . command ( 'status' ). description ( 'Show whether the service is installed and running' )
). action ( async ( options ) => {
const status = await serviceStatus ( toWebLaunchOptions ( options ));
if ( ! status . kind ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( `No supported supervisor on ${ process . platform } . Use \`codeman web -d\` instead.` ));
2026-08-09 01:34:55 +02:00
return ;
}
console . log ( ` Supervisor: ${ status . kind } ( ${ status . name } )` );
2026-08-16 18:23:44 +02:00
console . log ( ` Unit file: ${ status . installed ? palette . ok ( status . unitPath ) : palette . muted ( 'not installed' ) } ` );
console . log ( ` Loaded: ${ status . loaded ? palette . ok ( 'yes' ) : palette . muted ( 'no' ) } ` );
2026-08-09 01:34:55 +02:00
const version = status . version ? ` (v ${ status . version } )` : '' ;
console . log (
2026-08-16 18:23:44 +02:00
` Responding: ${ status . responding ? palette . ok ( `yes at ${ status . url }${ version } ` ) : palette . muted ( `no at ${ status . url } ` ) } `
2026-08-09 01:34:55 +02:00
);
});
2026-07-20 02:57:48 +02:00
// ============ Multi-user Commands ============
//
// Operate directly on ~/.codeman/users.json (via user-store) with NO running
// server, honoring CODEMAN_INSTANCE. This is the headless bootstrap path and the
// recovery answer to "locked out: last admin forgot password".
/** Read a password from stdin without echoing. Falls back to plain read on non-TTY. */
function promptHiddenPassword ( question : string ) : Promise < string > {
const stdin = process . stdin ;
if ( ! stdin . isTTY || typeof stdin . setRawMode !== 'function' ) {
// Non-interactive: read a single line from stdin.
return new Promise (( resolve ) => {
let buf = '' ;
stdin . setEncoding ( 'utf8' );
stdin . on ( 'data' , ( d ) => ( buf += d ));
stdin . on ( 'end' , () => resolve ( buf . replace ( /\r?\n$/ , '' )));
});
}
return new Promise (( resolve ) => {
process . stdout . write ( question );
let input = '' ;
stdin . setRawMode ( true );
stdin . resume ();
stdin . setEncoding ( 'utf8' );
const onData = ( chunk : string ) => {
for ( const c of chunk ) {
if ( c === '\n' || c === '\r' || c === '\u0004' ) {
stdin . setRawMode ! ( false );
stdin . pause ();
stdin . removeListener ( 'data' , onData );
process . stdout . write ( '\n' );
resolve ( input );
return ;
} else if ( c === '\u0003' ) {
process . stdout . write ( '\n' );
process . exit ( 1 );
} else if ( c === '\u007f' || c === '\b' ) {
input = input . slice ( 0 , - 1 );
} else {
input += c ;
}
}
};
stdin . on ( 'data' , onData );
});
}
function readAllStdin () : Promise < string > {
return new Promise (( resolve ) => {
let buf = '' ;
process . stdin . setEncoding ( 'utf8' );
process . stdin . on ( 'data' , ( d ) => ( buf += d ));
process . stdin . on ( 'end' , () => resolve ( buf . replace ( /\r?\n$/ , '' )));
});
}
const usersCmd = program . command ( 'users' ). description ( 'Manage multi-user accounts (~/.codeman/users.json)' );
usersCmd
. command ( 'add <name>' )
. description ( 'Create a user (prompts for password; use --password-stdin for scripts)' )
. option ( '--admin' , 'Create as an admin' )
. option ( '--password-stdin' , 'Read the password from stdin instead of prompting' )
. action ( async ( name , options ) => {
const { createUser , isValidUsername } = await import ( './user-store.js' );
if ( ! isValidUsername ( name )) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( '✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])' ));
2026-07-20 02:57:48 +02:00
process . exit ( 1 );
}
try {
let password : string ;
if ( options . passwordStdin ) {
password = await readAllStdin ();
} else {
password = await promptHiddenPassword ( 'New password: ' );
const confirm = await promptHiddenPassword ( 'Confirm password: ' );
if ( password !== confirm ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( '✗ Passwords do not match' ));
2026-07-20 02:57:48 +02:00
process . exit ( 1 );
}
}
if ( ! password || password . length < 8 ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( '✗ Password must be at least 8 characters' ));
2026-07-20 02:57:48 +02:00
process . exit ( 1 );
}
const user = await createUser ({ username : name , role : options.admin ? 'admin' : 'user' , password });
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Created ${ user . role } " ${ user . username } "` ));
2026-07-20 02:57:48 +02:00
} catch ( err ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ ${ getErrorMessage ( err ) } ` ));
2026-07-20 02:57:48 +02:00
process . exit ( 1 );
}
});
usersCmd
. command ( 'passwd <name>' )
. description ( 'Reset a user password' )
. option ( '--password-stdin' , 'Read the new password from stdin instead of prompting' )
. action ( async ( name , options ) => {
const { setPassword } = await import ( './user-store.js' );
try {
let password : string ;
if ( options . passwordStdin ) {
password = await readAllStdin ();
} else {
password = await promptHiddenPassword ( 'New password: ' );
const confirm = await promptHiddenPassword ( 'Confirm password: ' );
if ( password !== confirm ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( '✗ Passwords do not match' ));
2026-07-20 02:57:48 +02:00
process . exit ( 1 );
}
}
await setPassword ( name , password , { mustChangePassword : false });
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Password updated for " ${ name } "` ));
2026-07-20 02:57:48 +02:00
} catch ( err ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ ${ getErrorMessage ( err ) } ` ));
2026-07-20 02:57:48 +02:00
process . exit ( 1 );
}
});
usersCmd
. command ( 'list' )
. alias ( 'ls' )
. description ( 'List all users' )
. action ( async () => {
const { readUsers } = await import ( './user-store.js' );
const users = await readUsers ( true );
if ( users . length === 0 ) {
2026-08-16 18:23:44 +02:00
console . log ( palette . warn ( 'No users defined (run: codeman users add <name> --admin)' ));
2026-07-20 02:57:48 +02:00
return ;
}
2026-08-16 18:23:44 +02:00
console . log ( palette . emph ( '\nUsers:' ));
2026-07-20 02:57:48 +02:00
for ( const u of users ) {
2026-08-16 18:23:44 +02:00
const role = u . role === 'admin' ? palette . accent ( 'admin' ) : palette . info ( 'user ' );
const state = u . disabled ? palette . err ( 'disabled' ) : palette . ok ( 'enabled ' );
2026-07-20 02:57:48 +02:00
const flags = [ u . mustChangePassword ? 'must-change-pw' : '' , u . canBypassPermissions ? 'can-bypass' : '' ]
. filter ( Boolean )
. join ( ' ' );
2026-08-16 18:23:44 +02:00
console . log ( ` ${ role } ${ state } ${ u . username }${ flags ? palette . muted ( ` [ ${ flags } ]` ) : '' } ` );
2026-07-20 02:57:48 +02:00
}
console . log ( '' );
});
usersCmd
. command ( 'rm <name>' )
. description ( 'Delete a user' )
. option ( '--delete-space' , "Also delete the user's ~/codeman-users/<name> space" )
. action ( async ( name , options ) => {
const { deleteUser , deleteUserSpace } = await import ( './user-store.js' );
try {
await deleteUser ( name );
if ( options . deleteSpace ) {
await deleteUserSpace ( name );
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Deleted user " ${ name } " and their space` ));
2026-07-20 02:57:48 +02:00
} else {
2026-08-16 18:23:44 +02:00
console . log ( palette . ok ( `✓ Deleted user " ${ name } " (space left on disk)` ));
2026-07-20 02:57:48 +02:00
}
} catch ( err ) {
2026-08-16 18:23:44 +02:00
console . error ( palette . err ( `✗ ${ getErrorMessage ( err ) } ` ));
2026-07-20 02:57:48 +02:00
process . exit ( 1 );
}
});
2026-08-16 18:23:44 +02:00
/**
* Missing REQUIRED tools are failures; a missing optional one or a skipped check
* is just absence, so it stays muted rather than shouting red at everyone
* without LibreOffice installed.
*/
function dependencyTone ( result : ToolResult ) : Tone {
if ( result . status === 'ok' ) return 'ok' ;
if ( result . status === 'skipped' ) return 'idle' ;
return result . required ? 'err' : 'idle' ;
}
/**
* The colorize hook `dependency-report.ts` was written for. Versions stay in the
* default color (they are data, not a verdict); everything that IS a verdict is
* painted, and the supporting detail is muted so the glyph column reads first.
*/
const DOCTOR_STYLE : ReportStyle = {
title : ( text ) => palette . emph ( text ),
heading : ( text ) => palette . emph ( palette . info ( text )),
glyph : ( result , glyph ) => tint ( dependencyTone ( result ), glyph ),
label : ( text ) => text ,
status : ( result , text ) => ( result . status === 'ok' ? text : tint ( dependencyTone ( result ), text )),
path : ( text ) => palette . muted ( text ),
meta : ( text ) => palette . muted ( text ),
summary : ( text ) => palette . emph ( text ),
};
2026-06-14 12:25:49 -04:00
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' )
2026-06-14 22:24:46 +02:00
. option ( '--category <name>' , 'Only check one category (core|office|other)' )
2026-06-14 12:25:49 -04:00
. action ( async ( options ) => {
const { createRealHost , checkAll } = await import ( './utils/dependency-checker.js' );
const { renderTable , renderJson , computeExitCode } = await import ( './utils/dependency-report.js' );
2026-09-02 08:26:45 +08:00
const { dependencyRegistry , TOOL_CATEGORIES } = await import ( './config/dependency-registry.js' );
2026-06-14 22:24:46 +02:00
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 );
}
2026-06-14 12:25:49 -04:00
const host = createRealHost ();
2026-09-02 08:26:45 +08:00
const allTools = dependencyRegistry ();
const registry = options . category ? allTools . filter (( t ) => t . category === options . category ) : allTools ;
2026-06-14 12:25:49 -04:00
const results = checkAll ( registry , host );
if ( options . json ) {
2026-08-16 18:23:44 +02:00
// Raw JSON, never styled: this output is parsed, not read.
2026-06-14 12:25:49 -04:00
console . log ( JSON . stringify ( renderJson ( results , host . environment ), null , 2 ));
} else {
2026-08-16 18:23:44 +02:00
console . log ( renderTable ( results , host . environment , DOCTOR_STYLE ));
2026-06-14 12:25:49 -04:00
}
process . exit ( computeExitCode ( results ));
});
2026-01-18 04:50:46 +01:00
export { program };