diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts index bdced925..38234a1b 100644 --- a/cli/src/commands/auth.ts +++ b/cli/src/commands/auth.ts @@ -1,7 +1,9 @@ import chalk from 'chalk' import os from 'node:os' +import * as readline from 'node:readline/promises' +import { stdin as input, stdout as output } from 'node:process' import { configuration } from '@/configuration' -import { readSettings, clearMachineId } from '@/persistence' +import { readSettings, clearMachineId, updateSettings } from '@/persistence' export async function handleAuthCommand(args: string[]): Promise { const subcommand = args[0] @@ -13,24 +15,56 @@ export async function handleAuthCommand(args: string[]): Promise { if (subcommand === 'status') { const settings = await readSettings() + const envToken = process.env.CLI_API_TOKEN + const settingsToken = settings.cliApiToken + const hasToken = Boolean(envToken || settingsToken) + const tokenSource = envToken ? 'environment' : (settingsToken ? 'settings file' : 'none') console.log(chalk.bold('\nDirect Connect Status\n')) console.log(chalk.gray(` HAPPY_BOT_URL: ${configuration.serverUrl}`)) - console.log(chalk.gray(` CLI_API_TOKEN: ${configuration.cliApiToken ? 'set' : 'missing'}`)) + console.log(chalk.gray(` CLI_API_TOKEN: ${hasToken ? 'set' : 'missing'}`)) + console.log(chalk.gray(` Token Source: ${tokenSource}`)) console.log(chalk.gray(` Machine ID: ${settings.machineId ?? 'not set'}`)) console.log(chalk.gray(` Host: ${os.hostname()}`)) return } if (subcommand === 'login') { - console.log(chalk.yellow('No login flow in direct-connect mode.')) - console.log(chalk.gray('Set `HAPPY_BOT_URL` and `CLI_API_TOKEN` in your environment.')) + if (!process.stdin.isTTY) { + console.error(chalk.red('Cannot prompt for token in non-TTY environment.')) + console.error(chalk.gray('Set CLI_API_TOKEN environment variable instead.')) + process.exit(1) + } + + const rl = readline.createInterface({ input, output }) + + try { + const token = await rl.question(chalk.cyan('Enter CLI_API_TOKEN: ')) + + if (!token.trim()) { + console.error(chalk.red('Token cannot be empty')) + process.exit(1) + } + + await updateSettings(current => ({ + ...current, + cliApiToken: token.trim() + })) + configuration._setCliApiToken(token.trim()) + console.log(chalk.green('\nToken saved to ~/.config/hapi/settings.json')) + } finally { + rl.close() + } return } if (subcommand === 'logout') { + await updateSettings(current => ({ + ...current, + cliApiToken: undefined + })) await clearMachineId() - console.log(chalk.green('Cleared local machineId.')) - console.log(chalk.gray('Unset `CLI_API_TOKEN` in your environment to fully revoke access.')) + console.log(chalk.green('Cleared local credentials (token and machineId).')) + console.log(chalk.gray('Note: If CLI_API_TOKEN is set via environment variable, it will still be used.')) return } @@ -41,15 +75,16 @@ export async function handleAuthCommand(args: string[]): Promise { function showHelp(): void { console.log(` -${chalk.bold('hapi auth')} - Direct-connect configuration +${chalk.bold('hapi auth')} - Authentication management ${chalk.bold('Usage:')} hapi auth status Show current configuration - hapi auth login Print configuration help - hapi auth logout Clear local machineId + hapi auth login Enter and save CLI_API_TOKEN + hapi auth logout Clear saved credentials -${chalk.bold('Required env vars:')} - HAPPY_BOT_URL= - CLI_API_TOKEN= +${chalk.bold('Token priority (highest to lowest):')} + 1. CLI_API_TOKEN environment variable + 2. ~/.config/hapi/settings.json + 3. Interactive prompt (on first run) `) } diff --git a/cli/src/configuration.ts b/cli/src/configuration.ts index 53247bb1..4da11b07 100644 --- a/cli/src/configuration.ts +++ b/cli/src/configuration.ts @@ -12,7 +12,7 @@ import packageJson from '../package.json' class Configuration { public readonly serverUrl: string - public readonly cliApiToken: string + private _cliApiToken: string public readonly isDaemonProcess: boolean // Directories and paths (from persistence) @@ -30,19 +30,19 @@ class Configuration { constructor() { // Bot server configuration this.serverUrl = process.env.HAPPY_BOT_URL || 'http://localhost:3006' - this.cliApiToken = process.env.CLI_API_TOKEN || '' + this._cliApiToken = process.env.CLI_API_TOKEN || '' // Check if we're running as daemon based on process args const args = process.argv.slice(2) this.isDaemonProcess = args.length >= 2 && args[0] === 'daemon' && (args[1] === 'start-sync') - // Directory configuration - Priority: HAPPY_HOME_DIR env > default home dir - if (process.env.HAPPY_HOME_DIR) { + // Directory configuration - Priority: HAPI_HOME_DIR env > default home dir + if (process.env.HAPI_HOME_DIR) { // Expand ~ to home directory if present - const expandedPath = process.env.HAPPY_HOME_DIR.replace(/^~/, homedir()) + const expandedPath = process.env.HAPI_HOME_DIR.replace(/^~/, homedir()) this.happyHomeDir = expandedPath } else { - this.happyHomeDir = join(homedir(), '.happy') + this.happyHomeDir = join(homedir(), '.config', 'hapi') } this.logsDir = join(this.happyHomeDir, 'logs') @@ -64,6 +64,14 @@ class Configuration { mkdirSync(this.logsDir, { recursive: true }) } } + + get cliApiToken(): string { + return this._cliApiToken + } + + _setCliApiToken(token: string): void { + this._cliApiToken = token + } } export const configuration: Configuration = new Configuration() diff --git a/cli/src/index.ts b/cli/src/index.ts index aa8abe1c..6d504f90 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -26,6 +26,7 @@ import { handleConnectCommand } from './commands/connect' import { spawnHappyCLI } from './utils/spawnHappyCLI' import { claudeCliPath } from './claude/claudeLocal' import { execFileSync } from 'node:child_process' +import { initializeToken } from './ui/tokenInit' (async () => { @@ -87,7 +88,8 @@ import { execFileSync } from 'node:child_process' startedBy = args[++i] as 'daemon' | 'terminal'; } } - + + await initializeToken(); await authAndSetupMachineIfNeeded(); await runCodex({ startedBy }); // Do not force exit here; allow instrumentation to show lingering handles @@ -179,6 +181,7 @@ import { execFileSync } from 'node:child_process' } process.exit(0); } else if (daemonSubcommand === 'start-sync') { + await initializeToken(); await startDaemon() process.exit(0) } else if (daemonSubcommand === 'stop') { @@ -283,7 +286,7 @@ ${chalk.bold('hapi')} - Claude Code On the Go ${chalk.bold('Usage:')} hapi [options] Start Claude with Telegram control (direct-connect) - hapi auth Show direct-connect configuration + hapi auth Manage authentication hapi codex Start Codex mode hapi connect (not available in direct-connect mode) hapi notify (not available in direct-connect mode) @@ -292,9 +295,9 @@ ${chalk.bold('Usage:')} hapi doctor System diagnostics & troubleshooting ${chalk.bold('Examples:')} - HAPPY_BOT_URL=http://localhost:3006 CLI_API_TOKEN=... hapi - Start session (direct-connect) - hapi --yolo Start with bypassing permissions + 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 @@ -327,6 +330,7 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} } // Normal flow - auth and machine setup + await initializeToken(); await authAndSetupMachineIfNeeded(); // Always auto-start daemon for simplicity diff --git a/cli/src/persistence.ts b/cli/src/persistence.ts index d3f4e527..17e91908 100644 --- a/cli/src/persistence.ts +++ b/cli/src/persistence.ts @@ -19,6 +19,7 @@ interface Settings { machineId?: string machineIdConfirmedByServer?: boolean daemonAutoStartWhenRunningHappy?: boolean + cliApiToken?: string } const defaultSettings: Settings = { diff --git a/cli/src/ui/doctor.ts b/cli/src/ui/doctor.ts index 31a802eb..4ee7dfa9 100644 --- a/cli/src/ui/doctor.ts +++ b/cli/src/ui/doctor.ts @@ -23,7 +23,7 @@ import packageJson from '../../package.json' export function getEnvironmentInfo(): Record { return { PWD: process.env.PWD, - HAPPY_HOME_DIR: process.env.HAPPY_HOME_DIR, + HAPI_HOME_DIR: process.env.HAPI_HOME_DIR, HAPPY_BOT_URL: process.env.HAPPY_BOT_URL, HAPPY_PROJECT_ROOT: process.env.HAPPY_PROJECT_ROOT, CLI_API_TOKEN_SET: Boolean(process.env.CLI_API_TOKEN), @@ -111,7 +111,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise // Environment console.log(chalk.bold('\nšŸŒ Environment Variables')); const env = getEnvironmentInfo(); - console.log(`HAPPY_HOME_DIR: ${env.HAPPY_HOME_DIR ? chalk.green(env.HAPPY_HOME_DIR) : chalk.gray('not set')}`); + console.log(`HAPI_HOME_DIR: ${env.HAPI_HOME_DIR ? chalk.green(env.HAPI_HOME_DIR) : chalk.gray('not set')}`); console.log(`HAPPY_BOT_URL: ${env.HAPPY_BOT_URL ? chalk.green(env.HAPPY_BOT_URL) : chalk.gray('not set')}`); console.log(`CLI_API_TOKEN: ${env.CLI_API_TOKEN_SET ? chalk.green('set') : chalk.gray('not set')}`); console.log(`DANGEROUSLY_LOG_TO_SERVER: ${env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING ? chalk.yellow('ENABLED') : chalk.gray('not set')}`); @@ -119,21 +119,30 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise console.log(`NODE_ENV: ${env.NODE_ENV ? chalk.green(env.NODE_ENV) : chalk.gray('not set')}`); // Settings + let settings; try { - const settings = await readSettings(); + settings = await readSettings(); console.log(chalk.bold('\nšŸ“„ Settings (settings.json):')); - console.log(chalk.gray(JSON.stringify(settings, null, 2))); + // Hide cliApiToken in output for security + const displaySettings = { ...settings, cliApiToken: settings.cliApiToken ? '***' : undefined }; + console.log(chalk.gray(JSON.stringify(displaySettings, null, 2))); } catch (error) { console.log(chalk.bold('\nšŸ“„ Settings:')); console.log(chalk.red('āŒ Failed to read settings')); + settings = {}; } // Authentication status (direct-connect) console.log(chalk.bold('\nšŸ” Direct Connect Auth')); - if (configuration.cliApiToken) { - console.log(chalk.green('āœ“ CLI_API_TOKEN is set')); + const envToken = process.env.CLI_API_TOKEN; + const settingsToken = settings.cliApiToken; + const hasToken = Boolean(envToken || settingsToken); + const tokenSource = envToken ? 'environment variable' : (settingsToken ? 'settings file' : 'none'); + if (hasToken) { + console.log(chalk.green(`āœ“ CLI_API_TOKEN is set (from ${tokenSource})`)); } else { console.log(chalk.red('āŒ CLI_API_TOKEN is not set')); + console.log(chalk.gray(' Run `hapi auth login` to configure or set CLI_API_TOKEN env var')); } // Legacy credentials (unused in direct-connect mode) diff --git a/cli/src/ui/tokenInit.ts b/cli/src/ui/tokenInit.ts new file mode 100644 index 00000000..2ce65456 --- /dev/null +++ b/cli/src/ui/tokenInit.ts @@ -0,0 +1,65 @@ +/** + * Token initialization module + * + * Handles CLI_API_TOKEN initialization with priority: + * 1. Environment variable (highest - allows temporary override) + * 2. Settings file (~/.config/hapi/settings.json) + * 3. Interactive prompt (only when both above are missing) + */ + +import * as readline from 'node:readline/promises' +import { stdin as input, stdout as output } from 'node:process' +import chalk from 'chalk' +import { configuration } from '@/configuration' +import { readSettings, updateSettings } from '@/persistence' + +/** + * Initialize CLI API token + * Must be called before any API operations + */ +export async function initializeToken(): Promise { + // 1. Environment variable has highest priority (allows temporary override) + if (configuration.cliApiToken) { + return + } + + // 2. Read from settings file + const settings = await readSettings() + if (settings.cliApiToken) { + configuration._setCliApiToken(settings.cliApiToken) + return + } + + // 3. Non-TTY environment cannot prompt, fail with clear error + if (!process.stdin.isTTY) { + throw new Error('CLI_API_TOKEN is required. Set it via environment variable or run `hapi auth login`.') + } + + // 4. Interactive prompt + const token = await promptForToken() + + // 5. Save and update configuration + await updateSettings(current => ({ + ...current, + cliApiToken: token + })) + configuration._setCliApiToken(token) +} + +async function promptForToken(): Promise { + const rl = readline.createInterface({ input, output }) + + console.log(chalk.yellow('\nNo CLI_API_TOKEN found.')) + console.log(chalk.gray('You can set it via environment variable or enter it now.\n')) + + try { + const token = await rl.question(chalk.cyan('Enter CLI_API_TOKEN: ')) + if (!token.trim()) { + throw new Error('Token cannot be empty') + } + console.log(chalk.green('\nToken saved to ~/.config/hapi/settings.json')) + return token.trim() + } finally { + rl.close() + } +}