import { TunnelCliError, EXIT_CODE } from './cli-errors.js'; const DEFAULT_PORT = 3000; const DEFAULT_TAIL_LINES = 200; function levenshteinDistance(a, b) { const m = a.length; const n = b.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); } } return dp[m][n]; } function findClosestMatch(input, candidates, maxDistance = 3) { if (typeof input !== 'string' || input.length === 0 || !Array.isArray(candidates)) { return null; } const normalized = input.toLowerCase(); let bestCandidate = null; let bestDistance = maxDistance + 1; for (const candidate of candidates) { const distance = levenshteinDistance(normalized, candidate.toLowerCase()); if (distance < bestDistance) { bestDistance = distance; bestCandidate = candidate; } } return bestDistance <= maxDistance ? bestCandidate : null; } function splitOptionToken(arg) { if (!arg.startsWith('-')) return null; if (arg.startsWith('--')) { const eqIndex = arg.indexOf('='); return { name: eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2), inlineValue: eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined, long: true, }; } return { name: arg.slice(1), inlineValue: undefined, long: false, }; } function parseArgs(argv = process.argv.slice(2)) { const args = Array.isArray(argv) ? [...argv] : []; const options = { port: DEFAULT_PORT, host: undefined, uiPassword: process.env.OPENCHAMBER_UI_PASSWORD || undefined, json: false, all: false, follow: true, lines: DEFAULT_TAIL_LINES, limit: undefined, provider: undefined, mode: undefined, profile: undefined, name: undefined, title: undefined, configPath: undefined, token: undefined, tokenFile: undefined, tokenStdin: false, hostname: undefined, server: undefined, connectTtl: undefined, sessionTtl: undefined, qr: false, explicitQr: false, force: false, showSecrets: false, dryRun: false, plain: false, quiet: false, explicitPort: false, explicitUiPassword: false, envSnapshot: true, foreground: false, lan: false, apiOnly: false, project: undefined, task: undefined, session: undefined, message: undefined, prompt: undefined, model: undefined, daily: undefined, weekly: undefined, once: undefined, time: undefined, cron: undefined, timezone: undefined, agent: undefined, variant: undefined, disabled: false, goal: false, goalTokenBudget: undefined, directory: undefined, role: undefined, last: false, wait: false, timeout: undefined, lastAssistant: false, withStatus: false, }; const removedFlagErrors = []; const positional = []; let helpRequested = false; let versionRequested = false; const consumeValue = (index, inlineValue) => { if (typeof inlineValue === 'string' && inlineValue.length > 0) { return { value: inlineValue, nextIndex: index }; } const candidate = args[index + 1]; if (typeof candidate === 'string' && !candidate.startsWith('-')) { return { value: candidate, nextIndex: index + 1 }; } return { value: undefined, nextIndex: index }; }; for (let i = 0; i < args.length; i++) { const arg = args[i]; const parsedToken = splitOptionToken(arg); if (!parsedToken) { positional.push(arg); continue; } const { name, inlineValue, long } = parsedToken; switch (name) { case 'port': case 'p': { const { value: consumedValue, nextIndex: consumedIndex } = consumeValue(i, inlineValue); let value = consumedValue; let nextIndex = consumedIndex; // Support explicit negative numeric values like `-p -1` so we can report // a clear range validation error instead of "Unknown option". if (value === undefined && typeof inlineValue !== 'string') { const candidate = args[i + 1]; if (typeof candidate === 'string' && /^-\d+$/.test(candidate)) { value = candidate; nextIndex = i + 1; } } i = nextIndex; if (typeof value !== 'string' || value.trim().length === 0) { throw new TunnelCliError('Missing value for --port.', EXIT_CODE.USAGE_ERROR); } if (!/^-?\d+$/.test(value.trim())) { throw new TunnelCliError(`Invalid port value: ${value}`, EXIT_CODE.USAGE_ERROR); } const parsed = parseInt(value, 10); if (parsed < 1 || parsed > 65535) { throw new TunnelCliError(`Invalid port value: ${parsed}`, EXIT_CODE.USAGE_ERROR); } options.port = parsed; options.explicitPort = true; break; } case 'host': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; if (typeof value !== 'string' || value.trim().length === 0) { throw new TunnelCliError('Missing value for --host.', EXIT_CODE.USAGE_ERROR); } options.host = value.trim(); break; } case 'lan': options.lan = true; break; case 'ui-password': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.uiPassword = typeof value === 'string' ? value : ''; options.explicitUiPassword = true; break; } case 'provider': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.provider = typeof value === 'string' ? value : options.provider; break; } case 'mode': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.mode = typeof value === 'string' ? value : options.mode; break; } case 'profile': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.profile = typeof value === 'string' ? value : options.profile; break; } case 'name': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.name = typeof value === 'string' ? value : options.name; break; } case 'title': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.title = typeof value === 'string' ? value : options.title; break; } case 'worktree': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.worktree = typeof value === 'string' ? value : options.worktree; break; } case 'branch': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.branch = typeof value === 'string' ? value : options.branch; break; } case 'start-ref': case 'base': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.startRef = typeof value === 'string' ? value : options.startRef; break; } case 'upstream': options.setUpstream = true; break; case 'no-upstream': options.setUpstream = false; break; case 'project': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.project = typeof value === 'string' ? value : options.project; break; } case 'dir': case 'directory': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.directory = typeof value === 'string' ? value : options.directory; break; } case 'task': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.task = typeof value === 'string' ? value : options.task; break; } case 'session': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.session = typeof value === 'string' ? value : options.session; break; } case 'message': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.message = typeof value === 'string' ? value : options.message; break; } case 'prompt': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.prompt = typeof value === 'string' ? value : options.prompt; break; } case 'model': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.model = typeof value === 'string' ? value : options.model; break; } case 'daily': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.daily = typeof value === 'string' ? value : options.daily; break; } case 'weekly': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.weekly = typeof value === 'string' ? value : options.weekly; break; } case 'once': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.once = typeof value === 'string' ? value : options.once; break; } case 'time': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.time = typeof value === 'string' ? value : options.time; break; } case 'cron': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.cron = typeof value === 'string' ? value : options.cron; break; } case 'timezone': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.timezone = typeof value === 'string' ? value : options.timezone; break; } case 'agent': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.agent = typeof value === 'string' ? value : options.agent; break; } case 'variant': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.variant = typeof value === 'string' ? value : options.variant; break; } case 'disabled': options.disabled = true; break; case 'goal': options.goal = true; break; case 'goal-token-budget': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.goalTokenBudget = value; break; } case 'config': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.configPath = typeof value === 'string' ? value : null; break; } case 'token': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.token = typeof value === 'string' ? value : options.token; break; } case 'token-file': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.tokenFile = typeof value === 'string' ? value : options.tokenFile; break; } case 'token-stdin': options.tokenStdin = true; break; case 'hostname': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.hostname = typeof value === 'string' ? value : options.hostname; break; } case 'server': case 'server-url': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; if (typeof value !== 'string' || value.trim().length === 0) { throw new TunnelCliError('Missing value for --server.', EXIT_CODE.USAGE_ERROR); } options.server = value.trim(); break; } case 'connect-ttl': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.connectTtl = typeof value === 'string' ? value : options.connectTtl; break; } case 'session-ttl': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.sessionTtl = typeof value === 'string' ? value : options.sessionTtl; break; } case 'json': options.json = true; break; case 'all': options.all = true; break; case 'last': options.last = true; break; case 'last-assistant': options.lastAssistant = true; break; case 'wait': options.wait = true; break; case 'timeout': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.timeout = typeof value === 'string' ? value : options.timeout; break; } case 'with-status': options.withStatus = true; break; case 'role': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.role = typeof value === 'string' ? value : options.role; break; } case 'no-follow': options.follow = false; break; case 'no-env-snapshot': options.envSnapshot = false; break; case 'lines': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; const parsed = parseInt(value ?? '', 10); if (Number.isFinite(parsed) && parsed > 0) { options.lines = parsed; } break; } case 'limit': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; const parsed = parseInt(value ?? '', 10); if (!Number.isFinite(parsed) || parsed < 1) { throw new TunnelCliError('Invalid limit value. Provide a positive integer.', EXIT_CODE.USAGE_ERROR); } options.limit = parsed; break; } case 'relay': options.relay = true; break; case 'qr': options.qr = true; options.explicitQr = true; break; case 'no-qr': options.qr = false; options.explicitQr = true; break; case 'force': options.force = true; break; case 'show-secrets': options.showSecrets = true; break; case 'dry-run': options.dryRun = true; break; case 'plain': options.plain = true; break; case 'quiet': case 'q': options.quiet = true; break; case 'help': case 'h': helpRequested = true; break; case 'version': case 'v': versionRequested = true; break; case 'foreground': case 'no-daemon': options.foreground = true; break; case 'api-only': options.apiOnly = true; break; case 'daemon': case 'd': // Legacy no-op: daemon mode is already the default, but older clients // may still pass this when starting a remote server. break; case 'try-cf-tunnel': removedFlagErrors.push('`--try-cf-tunnel` was removed. Use: openchamber tunnel start --provider cloudflare --mode quick'); break; case 'tunnel-qr': removedFlagErrors.push('`--tunnel-qr` was removed. Use: openchamber tunnel start ... --qr'); break; case 'tunnel-password-url': removedFlagErrors.push('`--tunnel-password-url` was removed. Use UI password auth directly after tunnel start.'); break; case 'tunnel-provider': case 'tunnel-mode': case 'tunnel-config': case 'tunnel-token': case 'tunnel-hostname': case 'tunnel': removedFlagErrors.push(`\`--${name}\` was removed from top-level serve flow. Use: openchamber tunnel start ...`); break; default: if (!long && name.length === 1) { removedFlagErrors.push(`Unknown option: -${name}`); } else { removedFlagErrors.push(`Unknown option: --${name}`); } break; } } const command = positional[0] || 'serve'; const subcommand = command === 'tunnel' ? (positional[1] || 'help') : null; const tunnelAction = command === 'tunnel' ? (positional[2] || null) : null; const startupAction = command === 'startup' ? (positional[1] || 'status') : null; const scheduleAction = command === 'schedule' ? (positional[1] || 'help') : null; const sessionAction = command === 'session' ? (positional[1] || 'help') : null; const controlAction = command === 'control' ? (positional[1] || 'help') : null; if (options.lan && typeof options.host !== 'string') { options.host = '0.0.0.0'; } if (command !== 'tunnel' && typeof options.hostname === 'string' && typeof options.host !== 'string') { options.host = options.hostname; } return { command, subcommand, tunnelAction, startupAction, scheduleAction, sessionAction, controlAction, options, removedFlagErrors, helpRequested, versionRequested, }; } function showHelp() { console.log(` OpenChamber - Web interface for the OpenCode AI coding agent USAGE: openchamber [COMMAND] [OPTIONS] COMMANDS: serve Start the web server (daemon default) stop Stop running instance(s) restart Stop and start the server status Show server status schedule Manage scheduled tasks session Create, inspect, and read OpenChamber sessions models Show default and favorite models projects Show configured projects and IDs control Show OpenChamber control-plane commands tunnel Tunnel lifecycle commands startup Manage launch at system startup logs Tail OpenChamber logs connect-url Generate URL/QR for connecting another client update Check for and install updates OPTIONS: -p, --port Web server port (default: ${DEFAULT_PORT}) --host Bind address (default: 127.0.0.1) --hostname Alias for --host outside tunnel commands --lan Bind to 0.0.0.0 for LAN access --server Public/server URL for connect-url links --relay connect-url: also include the end-to-end-encrypted relay transport --ui-password [password] Protect browser UI with a password (generates one when omitted) --api-only Start API routes only, without serving browser UI assets --foreground Run server in foreground (use with systemd/process managers) --no-daemon Alias for --foreground -h, --help Show help -v, --version Show version ENVIRONMENT: OPENCHAMBER_HOST Bind address (e.g. 0.0.0.0 for all interfaces) OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag OPENCHAMBER_API_ONLY Set to true/1 to start API routes only OPENCHAMBER_DATA_DIR Override OpenChamber data directory OPENCODE_HOST External OpenCode server base URL, e.g. http://hostname:4096 OPENCODE_PORT Port of external OpenCode server to connect to OPENCODE_SKIP_START Skip starting OpenCode, use external server OPENCHAMBER_OPENCODE_HOSTNAME Bind hostname for managed OpenCode server (default: 127.0.0.1) EXAMPLES: openchamber # Start in daemon mode on default port 3000 (or free port) openchamber --port 8080 # Start on port 8080 (daemon) openchamber --lan --port 3002 # Start on LAN at 0.0.0.0:3002 openchamber serve --foreground # Start in foreground (for systemd Type=simple) openchamber connect-url --port 3000 --qr openchamber connect-url --server https://openchamber.example.com openchamber control # Show control-plane commands for agents/scripts openchamber startup enable # Start OpenChamber at user login openchamber tunnel help # Show tunnel lifecycle help openchamber logs # Follow logs for latest running instance `); } function showControlHelp() { console.log(` OpenChamber Control Commands USAGE: openchamber [OPTIONS] COMMANDS: status Show running OpenChamber runtimes session Create, inspect, and read sessions models Show default and favorite models projects Show configured projects and IDs schedule Manage scheduled tasks tunnel Inspect tunnel status/readiness logs Tail logs for CLI-managed runtimes DETAILED HELP: openchamber session --help Show session creation, status, and message options openchamber models --help Show model defaults and favorites help openchamber projects --help Show project list help openchamber schedule --help Show scheduled task actions and schedule options openchamber tunnel help Show tunnel lifecycle/status commands openchamber status --help Show runtime status options COMMON OPTIONS: --json Output machine-readable JSON -q, --quiet Print minimal output -p, --port Target a specific OpenChamber runtime --ui-password Authenticate to a password-protected runtime EXAMPLES: openchamber status openchamber models openchamber projects openchamber session --help openchamber schedule --help `); } function showStartupHelp() { console.log(` OpenChamber Startup Commands USAGE: openchamber startup [OPTIONS] SUBCOMMANDS: status Show startup integration status enable Install and start native user startup integration disable Stop and remove native user startup integration OPTIONS: -p, --port Web server port used by startup service --host Bind address used by startup service --ui-password Protect browser UI with single password --api-only Start API routes only, without serving browser UI assets --no-env-snapshot Do not save current environment for startup service --json Output machine-readable JSON -q, --quiet Suppress non-essential output EXAMPLES: openchamber startup enable openchamber startup enable --port 3000 openchamber startup enable --port 3000 --api-only --host 0.0.0.0 openchamber startup status --json `); } function showConnectUrlHelp() { console.log(` OpenChamber Connect URL USAGE: openchamber connect-url [OPTIONS] DESCRIPTION: Generate an openchamber:// connection link for adding this server to another OpenChamber app. If no server is running on the selected port, it starts one. OPTIONS: -p, --port Server port to use or start (default: ${DEFAULT_PORT}) --host
Bind address when starting the server --hostname
Alias for --host --lan Bind to 0.0.0.0 for LAN access when starting --server Public URL saved into the connection link --server-url Alias for --server --relay Also include the end-to-end-encrypted relay transport so the link works away from the local network. The device prefers the direct connection when reachable; the instance brings the relay up on its own. Set OPENCHAMBER_RELAY_URL to use a self-hosted relay. --name