diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index f2a509db..660f9911 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -10,7 +10,8 @@ import type { DaemonState, Machine, MachineMetadata, Update, UpdateMachineBody } import { DaemonStateSchema, MachineMetadataSchema } from './types' import { backoff } from '@/utils/time' import { RpcHandlerManager } from './rpc/RpcHandlerManager' -import { registerCommonHandlers, type SpawnSessionOptions, type SpawnSessionResult } from '../modules/common/registerCommonHandlers' +import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' +import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' interface ServerToDaemonEvents { update: (data: Update) => void diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts index 4be315e2..3bc14eaf 100644 --- a/cli/src/commands/auth.ts +++ b/cli/src/commands/auth.ts @@ -4,6 +4,7 @@ import * as readline from 'node:readline/promises' import { stdin as input, stdout as output } from 'node:process' import { configuration } from '@/configuration' import { readSettings, clearMachineId, updateSettings } from '@/persistence' +import type { CommandDefinition } from './types' export async function handleAuthCommand(args: string[]): Promise { const subcommand = args[0] @@ -98,3 +99,19 @@ ${chalk.bold('Token priority (highest to lowest):')} 3. Interactive prompt (on first run) `) } + +export const authCommand: CommandDefinition = { + name: 'auth', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + await handleAuthCommand(commandArgs) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts new file mode 100644 index 00000000..97582ef5 --- /dev/null +++ b/cli/src/commands/claude.ts @@ -0,0 +1,203 @@ +import chalk from 'chalk' +import { execFileSync } from 'node:child_process' +import { z } from 'zod' +import type { StartOptions } from '@/claude/runClaude' +import { configuration } from '@/configuration' +import { isDaemonRunningCurrentlyInstalledHappyVersion } from '@/daemon/controlClient' +import { authAndSetupMachineIfNeeded } from '@/ui/auth' +import { logger } from '@/ui/logger' +import { initializeToken } from '@/ui/tokenInit' +import { spawnHappyCLI } from '@/utils/spawnHappyCLI' +import { maybeAutoStartServer } from '@/utils/autoStartServer' +import { withBunRuntimeEnv } from '@/utils/bunRuntime' +import type { CommandDefinition } from './types' + +function extractErrorInfo(error: unknown): { + message: string + messageLower: string + axiosCode?: string + httpStatus?: number + responseErrorText: string +} { + const message = error instanceof Error ? error.message : 'Unknown error' + const messageLower = message.toLowerCase() + + if (typeof error !== 'object' || error === null) { + return { message, messageLower, responseErrorText: '' } + } + + const record = error as Record + const axiosCode = typeof record.code === 'string' ? record.code : undefined + const response = typeof record.response === 'object' && record.response !== null + ? (record.response as Record) + : undefined + const httpStatus = typeof response?.status === 'number' ? response.status : undefined + const responseData = response?.data + const responseError = typeof responseData === 'object' && responseData !== null + ? (responseData as Record).error + : undefined + const responseErrorText = typeof responseError === 'string' ? responseError : '' + + return { + message, + messageLower, + axiosCode, + httpStatus, + responseErrorText + } +} + +export const claudeCommand: CommandDefinition = { + name: 'default', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + const args = [...commandArgs] + + if (args.length > 0 && args[0] === 'claude') { + args.shift() + } + + const options: StartOptions = {} + let showHelp = false + const unknownArgs: string[] = [] + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + + if (arg === '-h' || arg === '--help') { + showHelp = true + unknownArgs.push(arg) + } else if (arg === '--hapi-starting-mode') { + options.startingMode = z.enum(['local', 'remote']).parse(args[++i]) + } else if (arg === '--yolo') { + options.permissionMode = 'bypassPermissions' + unknownArgs.push('--dangerously-skip-permissions') + } else if (arg === '--dangerously-skip-permissions') { + options.permissionMode = 'bypassPermissions' + unknownArgs.push(arg) + } else if (arg === '--started-by') { + options.startedBy = args[++i] as 'daemon' | 'terminal' + } else { + unknownArgs.push(arg) + if (i + 1 < args.length && !args[i + 1].startsWith('-')) { + unknownArgs.push(args[++i]) + } + } + } + + if (unknownArgs.length > 0) { + options.claudeArgs = [...(options.claudeArgs || []), ...unknownArgs] + } + + 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`):')} +`) + + try { + const claudeHelp = execFileSync( + 'claude', + ['--help'], + { encoding: 'utf8', env: withBunRuntimeEnv(), shell: process.platform === 'win32' } + ) + console.log(claudeHelp) + } catch { + console.log(chalk.yellow('Could not retrieve claude help. Make sure claude is installed.')) + } + + process.exit(0) + } + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + + logger.debug('Ensuring hapi background service is running & matches our version...') + + if (!(await isDaemonRunningCurrentlyInstalledHappyVersion())) { + logger.debug('Starting hapi background service...') + + const daemonProcess = spawnHappyCLI(['daemon', 'start-sync'], { + detached: true, + stdio: 'ignore', + env: process.env + }) + daemonProcess.unref() + + await new Promise(resolve => setTimeout(resolve, 200)) + } + + try { + const { runClaude } = await import('@/claude/runClaude') + await runClaude(options) + } catch (error) { + const { message, messageLower, axiosCode, httpStatus, responseErrorText } = extractErrorInfo(error) + + if ( + axiosCode === 'ECONNREFUSED' || + axiosCode === 'ETIMEDOUT' || + axiosCode === 'ENOTFOUND' || + messageLower.includes('econnrefused') || + messageLower.includes('etimedout') || + messageLower.includes('enotfound') || + messageLower.includes('network error') + ) { + 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 === 403 && responseErrorText === 'Machine access denied') { + console.error(chalk.red('Machine access denied.')) + console.error(chalk.gray(' This machineId is already registered under a different namespace.')) + console.error(chalk.gray(' Fix: run `hapi auth logout`, or set a separate HAPI_HOME per namespace.')) + } else if (httpStatus === 403 && responseErrorText === 'Session access denied') { + console.error(chalk.red('Session access denied.')) + console.error(chalk.gray(' This session belongs to a different namespace.')) + console.error(chalk.gray(' Use the matching CLI_API_TOKEN or switch namespaces.')) + } 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) + } + } +} diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts new file mode 100644 index 00000000..b15a1642 --- /dev/null +++ b/cli/src/commands/codex.ts @@ -0,0 +1,48 @@ +import chalk from 'chalk' +import { authAndSetupMachineIfNeeded } from '@/ui/auth' +import { initializeToken } from '@/ui/tokenInit' +import { maybeAutoStartServer } from '@/utils/autoStartServer' +import type { CommandDefinition } from './types' + +export const codexCommand: CommandDefinition = { + name: 'codex', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + const { runCodex } = await import('@/codex/runCodex') + + const options: { + startedBy?: 'daemon' | 'terminal' + codexArgs?: string[] + permissionMode?: 'default' | 'read-only' | 'safe-yolo' | 'yolo' + } = {} + const unknownArgs: string[] = [] + + for (let i = 0; i < commandArgs.length; i++) { + const arg = commandArgs[i] + if (arg === '--started-by') { + options.startedBy = commandArgs[++i] as 'daemon' | 'terminal' + } else if (arg === '--yolo' || arg === '--dangerously-bypass-approvals-and-sandbox') { + options.permissionMode = 'yolo' + unknownArgs.push(arg) + } else { + unknownArgs.push(arg) + } + } + if (unknownArgs.length > 0) { + options.codexArgs = unknownArgs + } + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + await runCodex(options) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/connect.ts b/cli/src/commands/connect.ts index d0991cbe..8ed51750 100644 --- a/cli/src/commands/connect.ts +++ b/cli/src/commands/connect.ts @@ -1,7 +1,24 @@ import chalk from 'chalk' +import type { CommandDefinition } from './types' export async function handleConnectCommand(_args: string[]): Promise { console.error(chalk.red('The `hapi connect` command is not available in direct-connect mode.')) console.error(chalk.gray('Vendor token storage was part of the hosted server flow.')) process.exit(1) } + +export const connectCommand: CommandDefinition = { + name: 'connect', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + await handleConnectCommand(commandArgs) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/daemon.ts b/cli/src/commands/daemon.ts new file mode 100644 index 00000000..033d4cbf --- /dev/null +++ b/cli/src/commands/daemon.ts @@ -0,0 +1,144 @@ +import chalk from 'chalk' +import { startDaemon } from '@/daemon/run' +import { + checkIfDaemonRunningAndCleanupStaleState, + listDaemonSessions, + stopDaemon, + stopDaemonSession +} from '@/daemon/controlClient' +import { getLatestDaemonLog } from '@/ui/logger' +import { spawnHappyCLI } from '@/utils/spawnHappyCLI' +import { runDoctorCommand } from '@/ui/doctor' +import { install } from '@/daemon/install' +import { uninstall } from '@/daemon/uninstall' +import { initializeToken } from '@/ui/tokenInit' +import type { CommandDefinition } from './types' + +export const daemonCommand: CommandDefinition = { + name: 'daemon', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + const daemonSubcommand = commandArgs[0] + + 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 { + console.log('No daemon running') + } + return + } + + if (daemonSubcommand === 'stop-session') { + const sessionId = commandArgs[1] + 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 { + console.log('No daemon running') + } + return + } + + if (daemonSubcommand === 'start') { + const child = spawnHappyCLI(['daemon', 'start-sync'], { + detached: true, + stdio: 'ignore', + env: process.env + }) + child.unref() + + 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) + } + + if (daemonSubcommand === 'start-sync') { + await initializeToken() + await startDaemon() + process.exit(0) + } + + if (daemonSubcommand === 'stop') { + await stopDaemon() + process.exit(0) + } + + if (daemonSubcommand === 'status') { + await runDoctorCommand('daemon') + process.exit(0) + } + + if (daemonSubcommand === 'logs') { + const latest = await getLatestDaemonLog() + if (!latest) { + console.log('No daemon logs found') + } else { + console.log(latest.path) + } + process.exit(0) + } + + if (daemonSubcommand === 'install') { + try { + await install() + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + process.exit(1) + } + return + } + + if (daemonSubcommand === 'uninstall') { + try { + await uninstall() + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + process.exit(1) + } + return + } + + 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')} +`) + } +} diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts new file mode 100644 index 00000000..86a135f1 --- /dev/null +++ b/cli/src/commands/doctor.ts @@ -0,0 +1,19 @@ +import { killRunawayHappyProcesses } from '@/daemon/doctor' +import { runDoctorCommand } from '@/ui/doctor' +import type { CommandDefinition } from './types' + +export const doctorCommand: CommandDefinition = { + name: 'doctor', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + if (commandArgs[0] === '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() + } +} diff --git a/cli/src/commands/gemini.ts b/cli/src/commands/gemini.ts new file mode 100644 index 00000000..6501ecfd --- /dev/null +++ b/cli/src/commands/gemini.ts @@ -0,0 +1,39 @@ +import chalk from 'chalk' +import { authAndSetupMachineIfNeeded } from '@/ui/auth' +import { initializeToken } from '@/ui/tokenInit' +import { maybeAutoStartServer } from '@/utils/autoStartServer' +import type { CommandDefinition } from './types' + +export const geminiCommand: CommandDefinition = { + name: 'gemini', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + let startedBy: 'daemon' | 'terminal' | undefined + let yolo = false + + for (let i = 0; i < commandArgs.length; i++) { + if (commandArgs[i] === '--started-by') { + startedBy = commandArgs[++i] as 'daemon' | 'terminal' + } else if (commandArgs[i] === '--yolo') { + yolo = true + } + } + + const { registerGeminiAgent } = await import('@/agent/runners/gemini') + const { runAgentSession } = await import('@/agent/runners/runAgentSession') + registerGeminiAgent(yolo) + + await initializeToken() + await maybeAutoStartServer() + 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) + } + } +} diff --git a/cli/src/commands/hookForwarder.ts b/cli/src/commands/hookForwarder.ts new file mode 100644 index 00000000..3dd90a0f --- /dev/null +++ b/cli/src/commands/hookForwarder.ts @@ -0,0 +1,10 @@ +import type { CommandDefinition } from './types' + +export const hookForwarderCommand: CommandDefinition = { + name: 'hook-forwarder', + requiresRuntimeAssets: false, + run: async ({ commandArgs }) => { + const { runSessionHookForwarder } = await import('@/claude/utils/sessionHookForwarder') + await runSessionHookForwarder(commandArgs) + } +} diff --git a/cli/src/commands/logout.ts b/cli/src/commands/logout.ts new file mode 100644 index 00000000..9a4c673b --- /dev/null +++ b/cli/src/commands/logout.ts @@ -0,0 +1,20 @@ +import chalk from 'chalk' +import { handleAuthCommand } from './auth' +import type { CommandDefinition } from './types' + +export const logoutCommand: CommandDefinition = { + name: 'logout', + requiresRuntimeAssets: true, + run: async () => { + 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) + } + } +} diff --git a/cli/src/commands/mcp.ts b/cli/src/commands/mcp.ts new file mode 100644 index 00000000..a3e805e4 --- /dev/null +++ b/cli/src/commands/mcp.ts @@ -0,0 +1,10 @@ +import { runHappyMcpStdioBridge } from '@/codex/happyMcpStdioBridge' +import type { CommandDefinition } from './types' + +export const mcpCommand: CommandDefinition = { + name: 'mcp', + requiresRuntimeAssets: false, + run: async ({ commandArgs }) => { + await runHappyMcpStdioBridge(commandArgs) + } +} diff --git a/cli/src/commands/notify.ts b/cli/src/commands/notify.ts new file mode 100644 index 00000000..ee2480e7 --- /dev/null +++ b/cli/src/commands/notify.ts @@ -0,0 +1,12 @@ +import chalk from 'chalk' +import type { CommandDefinition } from './types' + +export const notifyCommand: CommandDefinition = { + name: 'notify', + requiresRuntimeAssets: true, + run: async () => { + 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) + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts new file mode 100644 index 00000000..cb1e6085 --- /dev/null +++ b/cli/src/commands/registry.ts @@ -0,0 +1,48 @@ +import { authCommand } from './auth' +import { claudeCommand } from './claude' +import { codexCommand } from './codex' +import { connectCommand } from './connect' +import { daemonCommand } from './daemon' +import { doctorCommand } from './doctor' +import { geminiCommand } from './gemini' +import { hookForwarderCommand } from './hookForwarder' +import { logoutCommand } from './logout' +import { mcpCommand } from './mcp' +import { notifyCommand } from './notify' +import { serverCommand } from './server' +import type { CommandContext, CommandDefinition } from './types' + +const COMMANDS: CommandDefinition[] = [ + authCommand, + connectCommand, + codexCommand, + geminiCommand, + mcpCommand, + serverCommand, + hookForwarderCommand, + doctorCommand, + daemonCommand, + logoutCommand, + notifyCommand +] + +const commandMap = new Map() +for (const command of COMMANDS) { + commandMap.set(command.name, command) +} + +export function resolveCommand(args: string[]): { command: CommandDefinition; context: CommandContext } { + const subcommand = args[0] + const command = subcommand ? commandMap.get(subcommand) : undefined + const resolvedCommand = command ?? claudeCommand + const commandArgs = command ? args.slice(1) : args + + return { + command: resolvedCommand, + context: { + args, + subcommand, + commandArgs + } + } +} diff --git a/cli/src/commands/runCli.ts b/cli/src/commands/runCli.ts new file mode 100644 index 00000000..acd82b1d --- /dev/null +++ b/cli/src/commands/runCli.ts @@ -0,0 +1,28 @@ +import packageJson from '../../package.json' +import { ensureRuntimeAssets } from '@/runtime/assets' +import { isBunCompiled } from '@/projectPath' +import { logger } from '@/ui/logger' +import { getCliArgs } from '@/utils/cliArgs' +import { resolveCommand } from './registry' + +export async function runCli(): Promise { + const args = getCliArgs() + + if (args.includes('-v') || args.includes('--version')) { + console.log(`hapi version: ${packageJson.version}`) + process.exit(0) + } + + if (isBunCompiled()) { + process.env.DEV = 'false' + } + + const { command, context } = resolveCommand(args) + + if (command.requiresRuntimeAssets) { + await ensureRuntimeAssets() + logger.debug('Starting hapi CLI with args: ', process.argv) + } + + await command.run(context) +} diff --git a/cli/src/commands/server.ts b/cli/src/commands/server.ts new file mode 100644 index 00000000..296c6d79 --- /dev/null +++ b/cli/src/commands/server.ts @@ -0,0 +1,18 @@ +import chalk from 'chalk' +import type { CommandDefinition } from './types' + +export const serverCommand: CommandDefinition = { + name: 'server', + requiresRuntimeAssets: false, + run: async () => { + 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) + } + } +} diff --git a/cli/src/commands/types.ts b/cli/src/commands/types.ts new file mode 100644 index 00000000..5436f962 --- /dev/null +++ b/cli/src/commands/types.ts @@ -0,0 +1,11 @@ +export type CommandContext = { + args: string[] + subcommand?: string + commandArgs: string[] +} + +export type CommandDefinition = { + name: string + requiresRuntimeAssets: boolean + run: (context: CommandContext) => Promise +} diff --git a/cli/src/daemon/controlServer.ts b/cli/src/daemon/controlServer.ts index bf36a3c0..91d5e43f 100644 --- a/cli/src/daemon/controlServer.ts +++ b/cli/src/daemon/controlServer.ts @@ -9,7 +9,7 @@ import { serializerCompiler, validatorCompiler, ZodTypeProvider } from 'fastify- import { logger } from '@/ui/logger'; import { Metadata } from '@/api/types'; import { TrackedSession } from './types'; -import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/registerCommonHandlers'; +import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/rpcTypes'; export function startDaemonControlServer({ getChildren, diff --git a/cli/src/daemon/run.ts b/cli/src/daemon/run.ts index 1056173c..b6d5c91f 100644 --- a/cli/src/daemon/run.ts +++ b/cli/src/daemon/run.ts @@ -4,7 +4,7 @@ import os from 'os'; import { ApiClient } from '@/api/api'; import { TrackedSession } from './types'; import { DaemonState, Metadata } from '@/api/types'; -import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/registerCommonHandlers'; +import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/rpcTypes'; import { logger } from '@/ui/logger'; import { authAndSetupMachineIfNeeded } from '@/ui/auth'; import packageJson from '../../package.json'; diff --git a/cli/src/index.ts b/cli/src/index.ts index 3cfadb01..44a1e6a7 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,477 +1,5 @@ #!/usr/bin/env bun -/** - * CLI entry point for hapi command - * - * Simple argument parsing without any CLI framework dependencies - */ +import { runCli } from './commands/runCli' - -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 { maybeAutoStartServer } from './utils/autoStartServer' -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[]; - permissionMode?: 'default' | 'read-only' | 'safe-yolo' | 'yolo'; - } = {}; - 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 if (arg === '--yolo' || arg === '--dangerously-bypass-approvals-and-sandbox') { - options.permissionMode = 'yolo'; - unknownArgs.push(arg); - } else { - unknownArgs.push(arg); - } - } - if (unknownArgs.length > 0) { - options.codexArgs = unknownArgs; - } - - await initializeToken(); - await maybeAutoStartServer(); - 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 { - let startedBy: 'daemon' | 'terminal' | undefined = undefined; - let yolo = false; - for (let i = 1; i < args.length; i++) { - if (args[i] === '--started-by') { - startedBy = args[++i] as 'daemon' | 'terminal'; - } else if (args[i] === '--yolo') { - yolo = true; - } - } - - const { registerGeminiAgent } = await import('./agent/runners/gemini'); - const { runAgentSession } = await import('./agent/runners/runAgentSession'); - registerGeminiAgent(yolo); - - await initializeToken(); - await maybeAutoStartServer(); - 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 - options.permissionMode = 'bypassPermissions' - unknownArgs.push('--dangerously-skip-permissions') - } else if (arg === '--dangerously-skip-permissions') { - options.permissionMode = 'bypassPermissions' - unknownArgs.push(arg) - } 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 maybeAutoStartServer(); - 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; - const responseError = (error as any)?.response?.data?.error; - const responseErrorText = typeof responseError === 'string' ? responseError : ''; - - 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 === 403 && responseErrorText === 'Machine access denied') { - console.error(chalk.red('Machine access denied.')); - console.error(chalk.gray(' This machineId is already registered under a different namespace.')); - console.error(chalk.gray(' Fix: run `hapi auth logout`, or set a separate HAPI_HOME per namespace.')); - } else if (httpStatus === 403 && responseErrorText === 'Session access denied') { - console.error(chalk.red('Session access denied.')); - console.error(chalk.gray(' This session belongs to a different namespace.')); - console.error(chalk.gray(' Use the matching CLI_API_TOKEN or switch namespaces.')); - } 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) - } - } -})(); +void runCli() diff --git a/cli/src/modules/common/gitHandlers.ts b/cli/src/modules/common/gitHandlers.ts index 0ebb244a..7ef5ce6d 100644 --- a/cli/src/modules/common/gitHandlers.ts +++ b/cli/src/modules/common/gitHandlers.ts @@ -1,135 +1 @@ -import { execFile, type ExecFileOptions } from 'child_process' -import { promisify } from 'util' -import { RpcHandlerManager } from '../../api/rpc/RpcHandlerManager' -import { validatePath } from './pathSecurity' - -const execFileAsync = promisify(execFile) - -interface GitStatusRequest { - cwd?: string - timeout?: number -} - -interface GitDiffNumstatRequest { - cwd?: string - staged?: boolean - timeout?: number -} - -interface GitDiffFileRequest { - cwd?: string - filePath: string - staged?: boolean - timeout?: number -} - -interface GitCommandResponse { - success: boolean - stdout?: string - stderr?: string - exitCode?: number - error?: string -} - -function resolveCwd(requestedCwd: string | undefined, workingDirectory: string): { cwd: string; error?: string } { - const cwd = requestedCwd ?? workingDirectory - const validation = validatePath(cwd, workingDirectory) - if (!validation.valid) { - return { cwd, error: validation.error ?? 'Invalid working directory' } - } - return { cwd } -} - -function validateFilePath(filePath: string, workingDirectory: string): string | null { - const validation = validatePath(filePath, workingDirectory) - if (!validation.valid) { - return validation.error ?? 'Invalid file path' - } - return null -} - -async function runGitCommand( - args: string[], - cwd: string, - timeout?: number -): Promise { - try { - const options: ExecFileOptions = { - cwd, - timeout: timeout ?? 10_000 - } - const { stdout, stderr } = await execFileAsync('git', args, options) - return { - success: true, - stdout: stdout ? stdout.toString() : '', - stderr: stderr ? stderr.toString() : '', - exitCode: 0 - } - } catch (error) { - const execError = error as NodeJS.ErrnoException & { - stdout?: string - stderr?: string - code?: number | string - killed?: boolean - } - - if (execError.code === 'ETIMEDOUT' || execError.killed) { - return { - success: false, - stdout: execError.stdout ? execError.stdout.toString() : '', - stderr: execError.stderr ? execError.stderr.toString() : '', - exitCode: typeof execError.code === 'number' ? execError.code : -1, - error: 'Command timed out' - } - } - - return { - success: false, - stdout: execError.stdout ? execError.stdout.toString() : '', - stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed', - exitCode: typeof execError.code === 'number' ? execError.code : 1, - error: execError.message || 'Command failed' - } - } -} - -export function registerGitHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { - rpcHandlerManager.registerHandler('git-status', async (data) => { - const resolved = resolveCwd(data.cwd, workingDirectory) - if (resolved.error) { - return { success: false, error: resolved.error } - } - return await runGitCommand( - ['status', '--porcelain=v2', '--branch', '--untracked-files=all'], - resolved.cwd, - data.timeout - ) - }) - - rpcHandlerManager.registerHandler('git-diff-numstat', async (data) => { - const resolved = resolveCwd(data.cwd, workingDirectory) - if (resolved.error) { - return { success: false, error: resolved.error } - } - const args = data.staged - ? ['diff', '--cached', '--numstat'] - : ['diff', '--numstat'] - return await runGitCommand(args, resolved.cwd, data.timeout) - }) - - rpcHandlerManager.registerHandler('git-diff-file', async (data) => { - const resolved = resolveCwd(data.cwd, workingDirectory) - if (resolved.error) { - return { success: false, error: resolved.error } - } - const fileError = validateFilePath(data.filePath, workingDirectory) - if (fileError) { - return { success: false, error: fileError } - } - - const args = data.staged - ? ['diff', '--cached', '--no-ext-diff', '--', data.filePath] - : ['diff', '--no-ext-diff', '--', data.filePath] - return await runGitCommand(args, resolved.cwd, data.timeout) - }) -} +export { registerGitHandlers } from './handlers/git' diff --git a/cli/src/modules/common/handlers/bash.ts b/cli/src/modules/common/handlers/bash.ts new file mode 100644 index 00000000..e5cffdd2 --- /dev/null +++ b/cli/src/modules/common/handlers/bash.ts @@ -0,0 +1,72 @@ +import { logger } from '@/ui/logger' +import { exec, type ExecOptions } from 'child_process' +import { promisify } from 'util' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { validatePath } from '../pathSecurity' +import { getErrorMessage, rpcError } from '../rpcResponses' + +const execAsync = promisify(exec) + +interface BashRequest { + command: string + cwd?: string + timeout?: number +} + +interface BashResponse { + success: boolean + stdout?: string + stderr?: string + exitCode?: number + error?: string +} + +export function registerBashHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + rpcHandlerManager.registerHandler('bash', async (data) => { + logger.debug('Shell command request:', data.command) + + if (data.cwd) { + const validation = validatePath(data.cwd, workingDirectory) + if (!validation.valid) { + return rpcError(validation.error ?? 'Invalid working directory') + } + } + + try { + const options: ExecOptions = { + cwd: data.cwd, + timeout: data.timeout || 30000 + } + + const { stdout, stderr } = await execAsync(data.command, options) + + return { + success: true, + stdout: stdout ? stdout.toString() : '', + stderr: stderr ? stderr.toString() : '', + exitCode: 0 + } + } catch (error) { + const execError = error as NodeJS.ErrnoException & { + stdout?: string + stderr?: string + code?: number | string + killed?: boolean + } + + if (execError.code === 'ETIMEDOUT' || execError.killed) { + return rpcError('Command timed out', { + stdout: execError.stdout ? execError.stdout.toString() : '', + stderr: execError.stderr ? execError.stderr.toString() : '', + exitCode: typeof execError.code === 'number' ? execError.code : -1 + }) + } + + return rpcError(getErrorMessage(execError, 'Command failed'), { + stdout: execError.stdout ? execError.stdout.toString() : '', + stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed', + exitCode: typeof execError.code === 'number' ? execError.code : 1 + }) + } + }) +} diff --git a/cli/src/modules/common/handlers/difftastic.ts b/cli/src/modules/common/handlers/difftastic.ts new file mode 100644 index 00000000..15de02f7 --- /dev/null +++ b/cli/src/modules/common/handlers/difftastic.ts @@ -0,0 +1,44 @@ +import { logger } from '@/ui/logger' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { run as runDifftastic } from '@/modules/difftastic/index' +import { validatePath } from '../pathSecurity' +import { getErrorMessage, rpcError } from '../rpcResponses' + +interface DifftasticRequest { + args: string[] + cwd?: string +} + +interface DifftasticResponse { + success: boolean + exitCode?: number + stdout?: string + stderr?: string + error?: string +} + +export function registerDifftasticHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + rpcHandlerManager.registerHandler('difftastic', async (data) => { + logger.debug('Difftastic request with args:', data.args, 'cwd:', data.cwd) + + if (data.cwd) { + const validation = validatePath(data.cwd, workingDirectory) + if (!validation.valid) { + return rpcError(validation.error ?? 'Invalid working directory') + } + } + + try { + const result = await runDifftastic(data.args, { cwd: data.cwd }) + return { + success: true, + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString() + } + } catch (error) { + logger.debug('Failed to run difftastic:', error) + return rpcError(getErrorMessage(error, 'Failed to run difftastic')) + } + }) +} diff --git a/cli/src/modules/common/handlers/directories.ts b/cli/src/modules/common/handlers/directories.ts new file mode 100644 index 00000000..cc9def2b --- /dev/null +++ b/cli/src/modules/common/handlers/directories.ts @@ -0,0 +1,173 @@ +import { logger } from '@/ui/logger' +import { readdir, stat } from 'fs/promises' +import { basename, join } from 'path' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { validatePath } from '../pathSecurity' +import { getErrorMessage, rpcError } from '../rpcResponses' + +interface ListDirectoryRequest { + path: string +} + +interface DirectoryEntry { + name: string + type: 'file' | 'directory' | 'other' + size?: number + modified?: number +} + +interface ListDirectoryResponse { + success: boolean + entries?: DirectoryEntry[] + error?: string +} + +interface GetDirectoryTreeRequest { + path: string + maxDepth: number +} + +interface TreeNode { + name: string + path: string + type: 'file' | 'directory' + size?: number + modified?: number + children?: TreeNode[] +} + +interface GetDirectoryTreeResponse { + success: boolean + tree?: TreeNode + error?: string +} + +export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + rpcHandlerManager.registerHandler('listDirectory', async (data) => { + logger.debug('List directory request:', data.path) + + const validation = validatePath(data.path, workingDirectory) + if (!validation.valid) { + return rpcError(validation.error ?? 'Invalid directory path') + } + + try { + const entries = await readdir(data.path, { withFileTypes: true }) + + const directoryEntries: DirectoryEntry[] = await Promise.all( + entries.map(async (entry) => { + const fullPath = join(data.path, entry.name) + let type: 'file' | 'directory' | 'other' = 'other' + let size: number | undefined + let modified: number | undefined + + if (entry.isDirectory()) { + type = 'directory' + } else if (entry.isFile()) { + type = 'file' + } + + try { + const stats = await stat(fullPath) + size = stats.size + modified = stats.mtime.getTime() + } catch (error) { + logger.debug(`Failed to stat ${fullPath}:`, error) + } + + return { + name: entry.name, + type, + size, + modified + } + }) + ) + + directoryEntries.sort((a, b) => { + if (a.type === 'directory' && b.type !== 'directory') return -1 + if (a.type !== 'directory' && b.type === 'directory') return 1 + return a.name.localeCompare(b.name) + }) + + return { success: true, entries: directoryEntries } + } catch (error) { + logger.debug('Failed to list directory:', error) + return rpcError(getErrorMessage(error, 'Failed to list directory')) + } + }) + + rpcHandlerManager.registerHandler('getDirectoryTree', async (data) => { + logger.debug('Get directory tree request:', data.path, 'maxDepth:', data.maxDepth) + + const validation = validatePath(data.path, workingDirectory) + if (!validation.valid) { + return rpcError(validation.error ?? 'Invalid directory path') + } + + async function buildTree(path: string, name: string, currentDepth: number): Promise { + try { + const stats = await stat(path) + + const node: TreeNode = { + name, + path, + type: stats.isDirectory() ? 'directory' : 'file', + size: stats.size, + modified: stats.mtime.getTime() + } + + if (stats.isDirectory() && currentDepth < data.maxDepth) { + const entries = await readdir(path, { withFileTypes: true }) + const children: TreeNode[] = [] + + await Promise.all( + entries.map(async (entry) => { + if (entry.isSymbolicLink()) { + logger.debug(`Skipping symlink: ${join(path, entry.name)}`) + return + } + + const childPath = join(path, entry.name) + const childNode = await buildTree(childPath, entry.name, currentDepth + 1) + if (childNode) { + children.push(childNode) + } + }) + ) + + children.sort((a, b) => { + if (a.type === 'directory' && b.type !== 'directory') return -1 + if (a.type !== 'directory' && b.type === 'directory') return 1 + return a.name.localeCompare(b.name) + }) + + node.children = children + } + + return node + } catch (error) { + logger.debug(`Failed to process ${path}:`, error instanceof Error ? error.message : String(error)) + return null + } + } + + try { + if (data.maxDepth < 0) { + return rpcError('maxDepth must be non-negative') + } + + const baseName = data.path === '/' ? '/' : basename(data.path) || data.path + const tree = await buildTree(data.path, baseName, 0) + + if (!tree) { + return rpcError('Failed to access the specified path') + } + + return { success: true, tree } + } catch (error) { + logger.debug('Failed to get directory tree:', error) + return rpcError(getErrorMessage(error, 'Failed to get directory tree')) + } + }) +} diff --git a/cli/src/modules/common/handlers/files.ts b/cli/src/modules/common/handlers/files.ts new file mode 100644 index 00000000..486d1acb --- /dev/null +++ b/cli/src/modules/common/handlers/files.ts @@ -0,0 +1,98 @@ +import { logger } from '@/ui/logger' +import { readFile, stat, writeFile } from 'fs/promises' +import { createHash } from 'crypto' +import { resolve } from 'path' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { validatePath } from '../pathSecurity' +import { getErrorMessage, rpcError } from '../rpcResponses' + +interface ReadFileRequest { + path: string +} + +interface ReadFileResponse { + success: boolean + content?: string + error?: string +} + +interface WriteFileRequest { + path: string + content: string + expectedHash?: string | null +} + +interface WriteFileResponse { + success: boolean + hash?: string + error?: string +} + +export function registerFileHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + rpcHandlerManager.registerHandler('readFile', async (data) => { + logger.debug('Read file request:', data.path) + + const validation = validatePath(data.path, workingDirectory) + if (!validation.valid) { + return rpcError(validation.error ?? 'Invalid file path') + } + + try { + const resolvedPath = resolve(workingDirectory, data.path) + const buffer = await readFile(resolvedPath) + const content = buffer.toString('base64') + return { success: true, content } + } catch (error) { + logger.debug('Failed to read file:', error) + return rpcError(getErrorMessage(error, 'Failed to read file')) + } + }) + + rpcHandlerManager.registerHandler('writeFile', async (data) => { + logger.debug('Write file request:', data.path) + + const validation = validatePath(data.path, workingDirectory) + if (!validation.valid) { + return rpcError(validation.error ?? 'Invalid file path') + } + + try { + if (data.expectedHash !== null && data.expectedHash !== undefined) { + try { + const existingBuffer = await readFile(data.path) + const existingHash = createHash('sha256').update(existingBuffer).digest('hex') + + if (existingHash !== data.expectedHash) { + return rpcError(`File hash mismatch. Expected: ${data.expectedHash}, Actual: ${existingHash}`) + } + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw error + } + return rpcError('File does not exist but hash was provided') + } + } else { + try { + await stat(data.path) + return rpcError('File already exists but was expected to be new') + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw error + } + } + } + + const buffer = Buffer.from(data.content, 'base64') + await writeFile(data.path, buffer) + + const hash = createHash('sha256').update(buffer).digest('hex') + + return { success: true, hash } + } catch (error) { + logger.debug('Failed to write file:', error) + return rpcError(getErrorMessage(error, 'Failed to write file')) + } + }) +} diff --git a/cli/src/modules/common/handlers/git.ts b/cli/src/modules/common/handlers/git.ts new file mode 100644 index 00000000..d8583708 --- /dev/null +++ b/cli/src/modules/common/handlers/git.ts @@ -0,0 +1,132 @@ +import { execFile, type ExecFileOptions } from 'child_process' +import { promisify } from 'util' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { validatePath } from '../pathSecurity' +import { rpcError } from '../rpcResponses' + +const execFileAsync = promisify(execFile) + +interface GitStatusRequest { + cwd?: string + timeout?: number +} + +interface GitDiffNumstatRequest { + cwd?: string + staged?: boolean + timeout?: number +} + +interface GitDiffFileRequest { + cwd?: string + filePath: string + staged?: boolean + timeout?: number +} + +interface GitCommandResponse { + success: boolean + stdout?: string + stderr?: string + exitCode?: number + error?: string +} + +function resolveCwd(requestedCwd: string | undefined, workingDirectory: string): { cwd: string; error?: string } { + const cwd = requestedCwd ?? workingDirectory + const validation = validatePath(cwd, workingDirectory) + if (!validation.valid) { + return { cwd, error: validation.error ?? 'Invalid working directory' } + } + return { cwd } +} + +function validateFilePath(filePath: string, workingDirectory: string): string | null { + const validation = validatePath(filePath, workingDirectory) + if (!validation.valid) { + return validation.error ?? 'Invalid file path' + } + return null +} + +async function runGitCommand( + args: string[], + cwd: string, + timeout?: number +): Promise { + try { + const options: ExecFileOptions = { + cwd, + timeout: timeout ?? 10_000 + } + const { stdout, stderr } = await execFileAsync('git', args, options) + return { + success: true, + stdout: stdout ? stdout.toString() : '', + stderr: stderr ? stderr.toString() : '', + exitCode: 0 + } + } catch (error) { + const execError = error as NodeJS.ErrnoException & { + stdout?: string + stderr?: string + code?: number | string + killed?: boolean + } + + if (execError.code === 'ETIMEDOUT' || execError.killed) { + return rpcError('Command timed out', { + stdout: execError.stdout ? execError.stdout.toString() : '', + stderr: execError.stderr ? execError.stderr.toString() : '', + exitCode: typeof execError.code === 'number' ? execError.code : -1 + }) + } + + return rpcError(execError.message || 'Command failed', { + stdout: execError.stdout ? execError.stdout.toString() : '', + stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed', + exitCode: typeof execError.code === 'number' ? execError.code : 1 + }) + } +} + +export function registerGitHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + rpcHandlerManager.registerHandler('git-status', async (data) => { + const resolved = resolveCwd(data.cwd, workingDirectory) + if (resolved.error) { + return rpcError(resolved.error) + } + return await runGitCommand( + ['status', '--porcelain=v2', '--branch', '--untracked-files=all'], + resolved.cwd, + data.timeout + ) + }) + + rpcHandlerManager.registerHandler('git-diff-numstat', async (data) => { + const resolved = resolveCwd(data.cwd, workingDirectory) + if (resolved.error) { + return rpcError(resolved.error) + } + const args = data.staged + ? ['diff', '--cached', '--numstat'] + : ['diff', '--numstat'] + return await runGitCommand(args, resolved.cwd, data.timeout) + }) + + rpcHandlerManager.registerHandler('git-diff-file', async (data) => { + const resolved = resolveCwd(data.cwd, workingDirectory) + if (resolved.error) { + return rpcError(resolved.error) + } + const fileError = validateFilePath(data.filePath, workingDirectory) + if (fileError) { + return rpcError(fileError) + } + + const args = data.staged + ? ['diff', '--cached', '--no-ext-diff', '--', data.filePath] + : ['diff', '--no-ext-diff', '--', data.filePath] + return await runGitCommand(args, resolved.cwd, data.timeout) + }) +} diff --git a/cli/src/modules/common/handlers/ripgrep.ts b/cli/src/modules/common/handlers/ripgrep.ts new file mode 100644 index 00000000..54ec38b3 --- /dev/null +++ b/cli/src/modules/common/handlers/ripgrep.ts @@ -0,0 +1,44 @@ +import { logger } from '@/ui/logger' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { run as runRipgrep } from '@/modules/ripgrep/index' +import { validatePath } from '../pathSecurity' +import { getErrorMessage, rpcError } from '../rpcResponses' + +interface RipgrepRequest { + args: string[] + cwd?: string +} + +interface RipgrepResponse { + success: boolean + exitCode?: number + stdout?: string + stderr?: string + error?: string +} + +export function registerRipgrepHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + rpcHandlerManager.registerHandler('ripgrep', async (data) => { + logger.debug('Ripgrep request with args:', data.args, 'cwd:', data.cwd) + + if (data.cwd) { + const validation = validatePath(data.cwd, workingDirectory) + if (!validation.valid) { + return rpcError(validation.error ?? 'Invalid working directory') + } + } + + try { + const result = await runRipgrep(data.args, { cwd: data.cwd }) + return { + success: true, + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString() + } + } catch (error) { + logger.debug('Failed to run ripgrep:', error) + return rpcError(getErrorMessage(error, 'Failed to run ripgrep')) + } + }) +} diff --git a/cli/src/modules/common/handlers/slashCommands.ts b/cli/src/modules/common/handlers/slashCommands.ts new file mode 100644 index 00000000..f9a6be7b --- /dev/null +++ b/cli/src/modules/common/handlers/slashCommands.ts @@ -0,0 +1,18 @@ +import { logger } from '@/ui/logger' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { listSlashCommands, type ListSlashCommandsRequest, type ListSlashCommandsResponse } from '../slashCommands' +import { getErrorMessage, rpcError } from '../rpcResponses' + +export function registerSlashCommandHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler('listSlashCommands', async (data) => { + logger.debug('List slash commands request for agent:', data.agent) + + try { + const commands = await listSlashCommands(data.agent) + return { success: true, commands } + } catch (error) { + logger.debug('Failed to list slash commands:', error) + return rpcError(getErrorMessage(error, 'Failed to list slash commands')) + } + }) +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index 85c563de..91fd5303 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -1,507 +1,18 @@ -import { logger } from '@/ui/logger'; -import { exec, ExecOptions } from 'child_process'; -import { promisify } from 'util'; -import { readFile, writeFile, readdir, stat } from 'fs/promises'; -import { createHash } from 'crypto'; -import { basename, join, resolve } from 'path'; -import { run as runRipgrep } from '@/modules/ripgrep/index'; -import { run as runDifftastic } from '@/modules/difftastic/index'; -import { RpcHandlerManager } from '../../api/rpc/RpcHandlerManager'; -import { registerGitHandlers } from './gitHandlers'; -import { validatePath } from './pathSecurity'; -import { listSlashCommands, type ListSlashCommandsRequest, type ListSlashCommandsResponse } from './slashCommands'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { registerBashHandlers } from './handlers/bash' +import { registerDirectoryHandlers } from './handlers/directories' +import { registerDifftasticHandlers } from './handlers/difftastic' +import { registerFileHandlers } from './handlers/files' +import { registerGitHandlers } from './gitHandlers' +import { registerRipgrepHandlers } from './handlers/ripgrep' +import { registerSlashCommandHandlers } from './handlers/slashCommands' -const execAsync = promisify(exec); - -interface BashRequest { - command: string; - cwd?: string; - timeout?: number; // timeout in milliseconds -} - -interface BashResponse { - success: boolean; - stdout?: string; - stderr?: string; - exitCode?: number; - error?: string; -} - -interface ReadFileRequest { - path: string; -} - -interface ReadFileResponse { - success: boolean; - content?: string; // base64 encoded - error?: string; -} - -interface WriteFileRequest { - path: string; - content: string; // base64 encoded - expectedHash?: string | null; // null for new files, hash for existing files -} - -interface WriteFileResponse { - success: boolean; - hash?: string; // hash of written file - error?: string; -} - -interface ListDirectoryRequest { - path: string; -} - -interface DirectoryEntry { - name: string; - type: 'file' | 'directory' | 'other'; - size?: number; - modified?: number; // timestamp -} - -interface ListDirectoryResponse { - success: boolean; - entries?: DirectoryEntry[]; - error?: string; -} - -interface GetDirectoryTreeRequest { - path: string; - maxDepth: number; -} - -interface TreeNode { - name: string; - path: string; - type: 'file' | 'directory'; - size?: number; - modified?: number; - children?: TreeNode[]; // Only present for directories -} - -interface GetDirectoryTreeResponse { - success: boolean; - tree?: TreeNode; - error?: string; -} - -interface RipgrepRequest { - args: string[]; - cwd?: string; -} - -interface RipgrepResponse { - success: boolean; - exitCode?: number; - stdout?: string; - stderr?: string; - error?: string; -} - -interface DifftasticRequest { - args: string[]; - cwd?: string; -} - -interface DifftasticResponse { - success: boolean; - exitCode?: number; - stdout?: string; - stderr?: string; - error?: string; -} - -/* - * Spawn Session Options and Result - * This rpc type is used by the daemon, all other RPCs here are for sessions -*/ - -export interface SpawnSessionOptions { - machineId?: string; - directory: string; - sessionId?: string; - approvedNewDirectoryCreation?: boolean; - agent?: 'claude' | 'codex' | 'gemini'; - yolo?: boolean; - token?: string; - sessionType?: 'simple' | 'worktree'; - worktreeName?: string; -} - -export type SpawnSessionResult = - | { type: 'success'; sessionId: string } - | { type: 'requestToApproveDirectoryCreation'; directory: string } - | { type: 'error'; errorMessage: string }; - -/** - * Register all RPC handlers with the session - */ -export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string) { - - // Shell command handler - executes commands in the default shell - rpcHandlerManager.registerHandler('bash', async (data) => { - logger.debug('Shell command request:', data.command); - - // Validate cwd if provided - if (data.cwd) { - const validation = validatePath(data.cwd, workingDirectory); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - } - - try { - // Build options with shell enabled by default - // Note: ExecOptions doesn't support boolean for shell, but exec() uses the default shell when shell is undefined - const options: ExecOptions = { - cwd: data.cwd, - timeout: data.timeout || 30000, // Default 30 seconds timeout - }; - - const { stdout, stderr } = await execAsync(data.command, options); - - return { - success: true, - stdout: stdout ? stdout.toString() : '', - stderr: stderr ? stderr.toString() : '', - exitCode: 0 - }; - } catch (error) { - const execError = error as NodeJS.ErrnoException & { - stdout?: string; - stderr?: string; - code?: number | string; - killed?: boolean; - }; - - // Check if the error was due to timeout - if (execError.code === 'ETIMEDOUT' || execError.killed) { - return { - success: false, - stdout: execError.stdout || '', - stderr: execError.stderr || '', - exitCode: typeof execError.code === 'number' ? execError.code : -1, - error: 'Command timed out' - }; - } - - // If exec fails, it includes stdout/stderr in the error - return { - success: false, - stdout: execError.stdout ? execError.stdout.toString() : '', - stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed', - exitCode: typeof execError.code === 'number' ? execError.code : 1, - error: execError.message || 'Command failed' - }; - } - }); - - // Read file handler - returns base64 encoded content - rpcHandlerManager.registerHandler('readFile', async (data) => { - logger.debug('Read file request:', data.path); - - // Validate path is within working directory - const validation = validatePath(data.path, workingDirectory); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - - try { - const resolvedPath = resolve(workingDirectory, data.path); - const buffer = await readFile(resolvedPath); - const content = buffer.toString('base64'); - return { success: true, content }; - } catch (error) { - logger.debug('Failed to read file:', error); - return { success: false, error: error instanceof Error ? error.message : 'Failed to read file' }; - } - }); - - // Write file handler - with hash verification - rpcHandlerManager.registerHandler('writeFile', async (data) => { - logger.debug('Write file request:', data.path); - - // Validate path is within working directory - const validation = validatePath(data.path, workingDirectory); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - - try { - // If expectedHash is provided (not null), verify existing file - if (data.expectedHash !== null && data.expectedHash !== undefined) { - try { - const existingBuffer = await readFile(data.path); - const existingHash = createHash('sha256').update(existingBuffer).digest('hex'); - - if (existingHash !== data.expectedHash) { - return { - success: false, - error: `File hash mismatch. Expected: ${data.expectedHash}, Actual: ${existingHash}` - }; - } - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code !== 'ENOENT') { - throw error; - } - // File doesn't exist but hash was provided - return { - success: false, - error: 'File does not exist but hash was provided' - }; - } - } else { - // expectedHash is null - expecting new file - try { - await stat(data.path); - // File exists but we expected it to be new - return { - success: false, - error: 'File already exists but was expected to be new' - }; - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code !== 'ENOENT') { - throw error; - } - // File doesn't exist - this is expected - } - } - - // Write the file - const buffer = Buffer.from(data.content, 'base64'); - await writeFile(data.path, buffer); - - // Calculate and return hash of written file - const hash = createHash('sha256').update(buffer).digest('hex'); - - return { success: true, hash }; - } catch (error) { - logger.debug('Failed to write file:', error); - return { success: false, error: error instanceof Error ? error.message : 'Failed to write file' }; - } - }); - - // List directory handler - rpcHandlerManager.registerHandler('listDirectory', async (data) => { - logger.debug('List directory request:', data.path); - - // Validate path is within working directory - const validation = validatePath(data.path, workingDirectory); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - - try { - const entries = await readdir(data.path, { withFileTypes: true }); - - const directoryEntries: DirectoryEntry[] = await Promise.all( - entries.map(async (entry) => { - const fullPath = join(data.path, entry.name); - let type: 'file' | 'directory' | 'other' = 'other'; - let size: number | undefined; - let modified: number | undefined; - - if (entry.isDirectory()) { - type = 'directory'; - } else if (entry.isFile()) { - type = 'file'; - } - - try { - const stats = await stat(fullPath); - size = stats.size; - modified = stats.mtime.getTime(); - } catch (error) { - // Ignore stat errors for individual files - logger.debug(`Failed to stat ${fullPath}:`, error); - } - - return { - name: entry.name, - type, - size, - modified - }; - }) - ); - - // Sort entries: directories first, then files, alphabetically - directoryEntries.sort((a, b) => { - if (a.type === 'directory' && b.type !== 'directory') return -1; - if (a.type !== 'directory' && b.type === 'directory') return 1; - return a.name.localeCompare(b.name); - }); - - return { success: true, entries: directoryEntries }; - } catch (error) { - logger.debug('Failed to list directory:', error); - return { success: false, error: error instanceof Error ? error.message : 'Failed to list directory' }; - } - }); - - // Get directory tree handler - recursive with depth control - rpcHandlerManager.registerHandler('getDirectoryTree', async (data) => { - logger.debug('Get directory tree request:', data.path, 'maxDepth:', data.maxDepth); - - // Validate path is within working directory - const validation = validatePath(data.path, workingDirectory); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - - // Helper function to build tree recursively - async function buildTree(path: string, name: string, currentDepth: number): Promise { - try { - const stats = await stat(path); - - // Base node information - const node: TreeNode = { - name, - path, - type: stats.isDirectory() ? 'directory' : 'file', - size: stats.size, - modified: stats.mtime.getTime() - }; - - // If it's a directory and we haven't reached max depth, get children - if (stats.isDirectory() && currentDepth < data.maxDepth) { - const entries = await readdir(path, { withFileTypes: true }); - const children: TreeNode[] = []; - - // Process entries in parallel, filtering out symlinks - await Promise.all( - entries.map(async (entry) => { - // Skip symbolic links completely - if (entry.isSymbolicLink()) { - logger.debug(`Skipping symlink: ${join(path, entry.name)}`); - return; - } - - const childPath = join(path, entry.name); - const childNode = await buildTree(childPath, entry.name, currentDepth + 1); - if (childNode) { - children.push(childNode); - } - }) - ); - - // Sort children: directories first, then files, alphabetically - children.sort((a, b) => { - if (a.type === 'directory' && b.type !== 'directory') return -1; - if (a.type !== 'directory' && b.type === 'directory') return 1; - return a.name.localeCompare(b.name); - }); - - node.children = children; - } - - return node; - } catch (error) { - // Log error but continue traversal - logger.debug(`Failed to process ${path}:`, error instanceof Error ? error.message : String(error)); - return null; - } - } - - try { - // Validate maxDepth - if (data.maxDepth < 0) { - return { success: false, error: 'maxDepth must be non-negative' }; - } - - // Get the base name for the root node (cross-platform) - const baseName = data.path === '/' ? '/' : basename(data.path) || data.path; - - // Build the tree starting from the requested path - const tree = await buildTree(data.path, baseName, 0); - - if (!tree) { - return { success: false, error: 'Failed to access the specified path' }; - } - - return { success: true, tree }; - } catch (error) { - logger.debug('Failed to get directory tree:', error); - return { success: false, error: error instanceof Error ? error.message : 'Failed to get directory tree' }; - } - }); - - // Ripgrep handler - raw interface to ripgrep - rpcHandlerManager.registerHandler('ripgrep', async (data) => { - logger.debug('Ripgrep request with args:', data.args, 'cwd:', data.cwd); - - // Validate cwd if provided - if (data.cwd) { - const validation = validatePath(data.cwd, workingDirectory); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - } - - try { - const result = await runRipgrep(data.args, { cwd: data.cwd }); - return { - success: true, - exitCode: result.exitCode, - stdout: result.stdout.toString(), - stderr: result.stderr.toString() - }; - } catch (error) { - logger.debug('Failed to run ripgrep:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to run ripgrep' - }; - } - }); - - // Difftastic handler - raw interface to difftastic - rpcHandlerManager.registerHandler('difftastic', async (data) => { - logger.debug('Difftastic request with args:', data.args, 'cwd:', data.cwd); - - // Validate cwd if provided - if (data.cwd) { - const validation = validatePath(data.cwd, workingDirectory); - if (!validation.valid) { - return { success: false, error: validation.error }; - } - } - - try { - const result = await runDifftastic(data.args, { cwd: data.cwd }); - return { - success: true, - exitCode: result.exitCode, - stdout: result.stdout.toString(), - stderr: result.stderr.toString() - }; - } catch (error) { - logger.debug('Failed to run difftastic:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to run difftastic' - }; - } - }); - - // Slash commands handler - lists available slash commands for an agent - rpcHandlerManager.registerHandler('listSlashCommands', async (data) => { - logger.debug('List slash commands request for agent:', data.agent); - - try { - const commands = await listSlashCommands(data.agent); - return { success: true, commands }; - } catch (error) { - logger.debug('Failed to list slash commands:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to list slash commands' - }; - } - }); - - registerGitHandlers(rpcHandlerManager, workingDirectory); +export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { + registerBashHandlers(rpcHandlerManager, workingDirectory) + registerFileHandlers(rpcHandlerManager, workingDirectory) + registerDirectoryHandlers(rpcHandlerManager, workingDirectory) + registerRipgrepHandlers(rpcHandlerManager, workingDirectory) + registerDifftasticHandlers(rpcHandlerManager, workingDirectory) + registerSlashCommandHandlers(rpcHandlerManager) + registerGitHandlers(rpcHandlerManager, workingDirectory) } diff --git a/cli/src/modules/common/rpcResponses.ts b/cli/src/modules/common/rpcResponses.ts new file mode 100644 index 00000000..771eacf8 --- /dev/null +++ b/cli/src/modules/common/rpcResponses.ts @@ -0,0 +1,23 @@ +export type RpcErrorResponse = { success: false; error: string } + +export type RpcSuccessResponse = { success: true } & T + +export function rpcError = Record>( + message: string, + extras?: T +): RpcErrorResponse & T { + const payload = { + success: false, + error: message, + ...(extras ?? {}) + } + + return payload as RpcErrorResponse & T +} + +export function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) { + return error.message + } + return fallback +} diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts new file mode 100644 index 00000000..e7e7fd14 --- /dev/null +++ b/cli/src/modules/common/rpcTypes.ts @@ -0,0 +1,16 @@ +export interface SpawnSessionOptions { + machineId?: string + directory: string + sessionId?: string + approvedNewDirectoryCreation?: boolean + agent?: 'claude' | 'codex' | 'gemini' + yolo?: boolean + token?: string + sessionType?: 'simple' | 'worktree' + worktreeName?: string +} + +export type SpawnSessionResult = + | { type: 'success'; sessionId: string } + | { type: 'requestToApproveDirectoryCreation'; directory: string } + | { type: 'error'; errorMessage: string }