#!/usr/bin/env bun /** * CLI entry point for hapi command * * Simple argument parsing without any CLI framework dependencies */ import chalk from 'chalk' import type { StartOptions } from '@/claude/runClaude' import { logger } from './ui/logger' import { authAndSetupMachineIfNeeded } from './ui/auth' import packageJson from '../package.json' import { isBunCompiled } from './projectPath' import { z } from 'zod' import { startDaemon } from './daemon/run' import { checkIfDaemonRunningAndCleanupStaleState, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './daemon/controlClient' import { getLatestDaemonLog } from './ui/logger' import { killRunawayHappyProcesses } from './daemon/doctor' import { install } from './daemon/install' import { uninstall } from './daemon/uninstall' import { runDoctorCommand } from './ui/doctor' import { listDaemonSessions, stopDaemonSession } from './daemon/controlClient' import { handleAuthCommand } from './commands/auth' import { handleConnectCommand } from './commands/connect' import { spawnHappyCLI } from './utils/spawnHappyCLI' import { execFileSync } from 'node:child_process' import { initializeToken } from './ui/tokenInit' import { ensureRuntimeAssets } from './runtime/assets' import { runHappyMcpStdioBridge } from './codex/happyMcpStdioBridge' import { withBunRuntimeEnv } from './utils/bunRuntime' import { getCliArgs } from './utils/cliArgs' (async () => { const args = getCliArgs() if (args.includes('-v') || args.includes('--version')) { console.log(`hapi version: ${packageJson.version}`) process.exit(0) } // Check if first argument is a subcommand const subcommand = args[0] if (isBunCompiled()) { process.env.DEV = 'false' } if (subcommand === 'mcp') { await runHappyMcpStdioBridge(args.slice(1)) return } if (subcommand === 'server') { try { await import('../../server/src/index') } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') if (process.env.DEBUG) { console.error(error) } process.exit(1) } return } if (subcommand === 'hook-forwarder') { const { runSessionHookForwarder } = await import('@/claude/utils/sessionHookForwarder') await runSessionHookForwarder(args.slice(1)) return } await ensureRuntimeAssets() logger.debug('Starting hapi CLI with args: ', process.argv) if (subcommand === 'doctor') { // Check for clean subcommand if (args[1] === 'clean') { const result = await killRunawayHappyProcesses() console.log(`Cleaned up ${result.killed} runaway processes`) if (result.errors.length > 0) { console.log('Errors:', result.errors) } process.exit(0) } await runDoctorCommand(); return; } else if (subcommand === 'auth') { // Handle auth subcommands try { await handleAuthCommand(args.slice(1)); } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') if (process.env.DEBUG) { console.error(error) } process.exit(1) } return; } else if (subcommand === 'connect') { // Handle connect subcommands try { await handleConnectCommand(args.slice(1)); } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') if (process.env.DEBUG) { console.error(error) } process.exit(1) } return; } else if (subcommand === 'codex') { // Handle codex command try { const { runCodex } = await import('@/codex/runCodex'); // Parse known arguments and collect unknown ones for passthrough const options: { startedBy?: 'daemon' | 'terminal'; codexArgs?: string[] } = {}; const unknownArgs: string[] = []; for (let i = 1; i < args.length; i++) { const arg = args[i]; if (arg === '--started-by') { options.startedBy = args[++i] as 'daemon' | 'terminal'; } else { unknownArgs.push(arg); } } if (unknownArgs.length > 0) { options.codexArgs = unknownArgs; } await initializeToken(); await authAndSetupMachineIfNeeded(); await runCodex(options); // Do not force exit here; allow instrumentation to show lingering handles } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') if (process.env.DEBUG) { console.error(error) } process.exit(1) } return; } else if (subcommand === 'gemini') { // Handle gemini command try { await import('./agent/runners/gemini'); const { runAgentSession } = await import('./agent/runners/runAgentSession'); let startedBy: 'daemon' | 'terminal' | undefined = undefined; for (let i = 1; i < args.length; i++) { if (args[i] === '--started-by') { startedBy = args[++i] as 'daemon' | 'terminal'; } } await initializeToken(); await authAndSetupMachineIfNeeded(); await runAgentSession({ agentType: 'gemini', startedBy }); } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') if (process.env.DEBUG) { console.error(error) } process.exit(1) } return; } else if (subcommand === 'logout') { // Keep for backward compatibility - redirect to auth logout console.log(chalk.yellow('Note: "hapi logout" is deprecated. Use "hapi auth logout" instead.\n')); try { await handleAuthCommand(['logout']); } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') if (process.env.DEBUG) { console.error(error) } process.exit(1) } return; } else if (subcommand === 'notify') { // Handle notification command console.error(chalk.red('The `hapi notify` command is not available in direct-connect mode.')) console.error(chalk.gray('Use Telegram notifications from hapi-server instead.')) process.exit(1) return; } else if (subcommand === 'daemon') { // Show daemon management help const daemonSubcommand = args[1] if (daemonSubcommand === 'list') { try { const sessions = await listDaemonSessions() if (sessions.length === 0) { console.log('No active sessions this daemon is aware of (they might have been started by a previous version of the daemon)') } else { console.log('Active sessions:') console.log(JSON.stringify(sessions, null, 2)) } } catch (error) { console.log('No daemon running') } return } else if (daemonSubcommand === 'stop-session') { const sessionId = args[2] if (!sessionId) { console.error('Session ID required') process.exit(1) } try { const success = await stopDaemonSession(sessionId) console.log(success ? 'Session stopped' : 'Failed to stop session') } catch (error) { console.log('No daemon running') } return } else if (daemonSubcommand === 'start') { // Spawn detached daemon process const child = spawnHappyCLI(['daemon', 'start-sync'], { detached: true, stdio: 'ignore', env: process.env }); child.unref(); // Wait for daemon to write state file (up to 5 seconds) let started = false; for (let i = 0; i < 50; i++) { if (await checkIfDaemonRunningAndCleanupStaleState()) { started = true; break; } await new Promise(resolve => setTimeout(resolve, 100)); } if (started) { console.log('Daemon started successfully'); } else { console.error('Failed to start daemon'); process.exit(1); } process.exit(0); } else if (daemonSubcommand === 'start-sync') { await initializeToken(); await startDaemon() process.exit(0) } else if (daemonSubcommand === 'stop') { await stopDaemon() process.exit(0) } else if (daemonSubcommand === 'status') { // Show daemon-specific doctor output await runDoctorCommand('daemon') process.exit(0) } else if (daemonSubcommand === 'logs') { // Simply print the path to the latest daemon log file const latest = await getLatestDaemonLog() if (!latest) { console.log('No daemon logs found') } else { console.log(latest.path) } process.exit(0) } else if (daemonSubcommand === 'install') { try { await install() } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') process.exit(1) } } else if (daemonSubcommand === 'uninstall') { try { await uninstall() } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') process.exit(1) } } else { console.log(` ${chalk.bold('hapi daemon')} - Daemon management ${chalk.bold('Usage:')} hapi daemon start Start the daemon (detached) hapi daemon stop Stop the daemon (sessions stay alive) hapi daemon status Show daemon status hapi daemon list List active sessions If you want to kill all hapi related processes run ${chalk.cyan('hapi doctor clean')} ${chalk.bold('Note:')} The daemon runs in the background and manages Claude sessions. ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('hapi doctor clean')} `) } return; } else { // If the first argument is claude, remove it if (args.length > 0 && args[0] === 'claude') { args.shift() } // Parse command line arguments for main command const options: StartOptions = {} let showHelp = false const unknownArgs: string[] = [] // Collect unknown args to pass through to claude for (let i = 0; i < args.length; i++) { const arg = args[i] if (arg === '-h' || arg === '--help') { showHelp = true // Also pass through to claude unknownArgs.push(arg) } else if (arg === '--hapi-starting-mode') { options.startingMode = z.enum(['local', 'remote']).parse(args[++i]) } else if (arg === '--yolo') { // Shortcut for --dangerously-skip-permissions unknownArgs.push('--dangerously-skip-permissions') } else if (arg === '--started-by') { options.startedBy = args[++i] as 'daemon' | 'terminal' } else { // Pass unknown arguments through to claude unknownArgs.push(arg) // Check if this arg expects a value (simplified check for common patterns) if (i + 1 < args.length && !args[i + 1].startsWith('-')) { unknownArgs.push(args[++i]) } } } // Add unknown args to claudeArgs if (unknownArgs.length > 0) { options.claudeArgs = [...(options.claudeArgs || []), ...unknownArgs] } // Show help if (showHelp) { console.log(` ${chalk.bold('hapi')} - Claude Code On the Go ${chalk.bold('Usage:')} hapi [options] Start Claude with Telegram control (direct-connect) hapi auth Manage authentication hapi codex Start Codex mode hapi gemini Start Gemini ACP mode hapi mcp Start MCP stdio bridge hapi connect (not available in direct-connect mode) hapi notify (not available in direct-connect mode) hapi server Start the API + web server hapi daemon Manage background service that allows to spawn new sessions away from your computer hapi doctor System diagnostics & troubleshooting ${chalk.bold('Examples:')} hapi Start session (will prompt for token if not set) hapi auth login Configure CLI_API_TOKEN interactively hapi --yolo Start with bypassing permissions hapi sugar for --dangerously-skip-permissions hapi auth status Show direct-connect status hapi doctor Run diagnostics ${chalk.bold('hapi supports ALL Claude options!')} Use any claude flag with hapi as you would with claude. Our favorite: hapi --resume ${chalk.gray('─'.repeat(60))} ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} `) // Run claude --help and display its output try { const claudeHelp = execFileSync( 'claude', ['--help'], { encoding: 'utf8', env: withBunRuntimeEnv(), shell: process.platform === 'win32' } ) console.log(claudeHelp) } catch (e) { console.log(chalk.yellow('Could not retrieve claude help. Make sure claude is installed.')) } process.exit(0) } // Normal flow - auth and machine setup await initializeToken(); await authAndSetupMachineIfNeeded(); // Always auto-start daemon for simplicity logger.debug('Ensuring hapi background service is running & matches our version...'); if (!(await isDaemonRunningCurrentlyInstalledHappyVersion())) { logger.debug('Starting hapi background service...'); // Use the built binary to spawn daemon const daemonProcess = spawnHappyCLI(['daemon', 'start-sync'], { detached: true, stdio: 'ignore', env: process.env }) daemonProcess.unref(); // Give daemon a moment to write PID & port file await new Promise(resolve => setTimeout(resolve, 200)); } // Start the CLI try { const { runClaude } = await import('@/claude/runClaude'); await runClaude(options); } catch (error) { // Categorize errors for better user experience const message = error instanceof Error ? error.message : 'Unknown error'; const messageLower = message.toLowerCase(); const axiosCode = (error as any)?.code; const httpStatus = (error as any)?.response?.status; if (axiosCode === 'ECONNREFUSED' || axiosCode === 'ETIMEDOUT' || axiosCode === 'ENOTFOUND' || messageLower.includes('econnrefused') || messageLower.includes('etimedout') || messageLower.includes('enotfound') || messageLower.includes('network error')) { const { configuration } = await import('@/configuration'); console.error(chalk.yellow('Unable to connect to HAPI server')); console.error(chalk.gray(` Server URL: ${configuration.serverUrl}`)); console.error(chalk.gray(' Please check your network connection or server status')); } else if (httpStatus === 401 || httpStatus === 403 || messageLower.includes('unauthorized') || messageLower.includes('forbidden')) { console.error(chalk.red('Authentication error:'), message); console.error(chalk.gray(' Run: hapi auth login')); } else { console.error(chalk.red('Error:'), message); } if (process.env.DEBUG) { console.error(error) } process.exit(1) } } })();