From fc2fdfc25b175ede295fccf1ae71b173c0c4ae58 Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 12 Sep 2026 12:02:28 +0800 Subject: [PATCH] feat(cli): add agent picker and remove Claude default Separate top-level help and version flags from agent arguments. Require explicit agents in scripts and preserve command argument boundaries. Validation: bun typecheck, bun run test, targeted runner integration tests, and PTY/source/compiled argv smoke checks. --- README.md | 6 +- cli/README.md | 30 +++++- cli/src/agent/agentAvailability.test.ts | 22 +++- cli/src/agent/agentAvailability.ts | 10 +- cli/src/commands/claude.test.ts | 19 +++- cli/src/commands/claude.ts | 73 +------------ cli/src/commands/help.ts | 41 ++++++++ cli/src/commands/registry.test.ts | 25 +++++ cli/src/commands/registry.ts | 13 ++- cli/src/commands/runCli.test.ts | 120 ++++++++++++++++++++++ cli/src/commands/runCli.ts | 50 +++++++-- cli/src/runner/run.ts | 4 +- cli/src/runner/runner.integration.test.ts | 2 + cli/src/ui/ink/AgentPicker.test.ts | 96 +++++++++++++++++ cli/src/ui/ink/AgentPicker.tsx | 55 ++++++++++ cli/src/ui/selectAgent.test.ts | 83 +++++++++++++++ cli/src/ui/selectAgent.ts | 49 +++++++++ cli/src/utils/cliArgs.test.ts | 56 ++++++++++ cli/src/utils/cliArgs.ts | 64 ++++-------- docs/guide/agents.md | 10 +- docs/guide/how-it-works.md | 4 +- docs/guide/installation.md | 2 +- 22 files changed, 687 insertions(+), 147 deletions(-) create mode 100644 cli/src/commands/help.ts create mode 100644 cli/src/commands/registry.test.ts create mode 100644 cli/src/commands/runCli.test.ts create mode 100644 cli/src/ui/ink/AgentPicker.test.ts create mode 100644 cli/src/ui/ink/AgentPicker.tsx create mode 100644 cli/src/ui/selectAgent.test.ts create mode 100644 cli/src/ui/selectAgent.ts create mode 100644 cli/src/utils/cliArgs.test.ts diff --git a/README.md b/README.md index 40d548ff..92723506 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,15 @@ https://github.com/user-attachments/assets/38230353-94c6-4dbe-9c29-b2a2cc457546 ```bash npx @twsxtd/hapi hub --relay # start hub with E2E encrypted relay -npx @twsxtd/hapi # run claude code +npx @twsxtd/hapi # choose an agent and start a session ``` `hapi server` remains supported as an alias. +Use `hapi [options]` to start an agent directly, for example `hapi claude` +or `hapi codex`. Scripts must specify the agent explicitly. `hapi --help` shows +HAPI's commands and supported agents. + The terminal will display a URL and QR code. Scan the QR code with your phone or open the URL to access. > The relay uses WireGuard + TLS for end-to-end encryption. Your data is encrypted from your device to your machine. diff --git a/cli/README.md b/cli/README.md index c7bc05ef..44aaac4e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,6 +1,6 @@ # hapi CLI -Run Claude Code, Codex, Cursor Agent, Grok Build, OpenCode, or DeepSeek Harness sessions from your terminal and control them remotely through the hapi hub. +Choose a supported coding agent from your terminal and control its sessions remotely through the hapi hub. See the [supported agents](../docs/guide/agents.md). ## What it does @@ -18,20 +18,25 @@ Run Claude Code, Codex, Cursor Agent, Grok Build, OpenCode, or DeepSeek Harness 1. Start the hub and set env vars (see ../hub/README.md). 2. Set the same CLI_API_TOKEN on this machine or run `hapi auth login`. -3. Run `hapi` to start a session. +3. Run `hapi` to choose an agent, or `hapi ` to start one directly. 4. Use the web app or Telegram Mini App to monitor and control. ## Commands ### Session commands -- `hapi` - Start a Claude Code session (passes through Claude CLI flags). See `src/index.ts`. +- `hapi` - Choose an agent interactively. Unavailable agents are shown with a reason and cannot be selected. +- `hapi claude` - Start a Claude Code session (passes through Claude CLI flags). - `hapi codex` - Start Codex mode. See `src/codex/runCodex.ts`. - `hapi codex resume ` - Resume existing Codex session. - `hapi cursor` - Start Cursor Agent mode. See `src/cursor/runCursor.ts`. Supports `hapi cursor resume `, `hapi cursor --continue`, `--mode plan|ask`, `--yolo`, `--model`. Local and remote modes supported; remote uses `agent -p` with stream-json. - `hapi grok` - Start Grok Build mode. See `src/grok/runGrok.ts`. +- `hapi copilot` - Start GitHub Copilot mode. +- `hapi kimi` - Start Kimi mode. +- `hapi agy` - Start Antigravity mode (remote-only). +- `hapi pi` - Start Pi mode (remote-only). - `hapi opencode` - Start OpenCode mode via ACP. See `src/opencode/runOpencode.ts`. Note: OpenCode supports local and remote modes; local mode streams via OpenCode plugins. - `hapi dsh` - Start DeepSeek Harness through ACP. See `src/dsh/runDsh.ts`. @@ -40,6 +45,21 @@ Run Claude Code, Codex, Cursor Agent, Grok Build, OpenCode, or DeepSeek Harness - `hapi ping-peer ` - Resume (if needed) and message another session. Prefer this or MCP `ping_peer` / `list_peers` over reinventing JWT+curl. Also `--message-file` / `--list`. - `hapi inspect-peer ` - Read-only peer metadata + recent message text (no resume). Prefer this or MCP `inspect_peer` when a user cites `[title](/sessions/)` or Copy-reference `See session "…" (/sessions/) for context`. `/sessions/` is a hub path, not a local file. Optional `--limit`. +The picker lists agents alphabetically by command name. Use Up/Down and Enter +to choose; Esc or Ctrl-C cancels. It appears on every bare invocation, even +when only one agent is available. No default agent or selection is saved. + +Scripts and non-interactive shells must specify an agent. Old implicit-Claude +commands such as `hapi --yolo`, `hapi --resume`, and `hapi "prompt"` now report +an error; use `hapi claude --yolo`, `hapi claude --resume`, or +`hapi claude "prompt"` instead. + +`hapi --help`, `hapi -h`, and `hapi help` show only HAPI's help, without starting +a session or requiring an installed agent. `hapi --version` / `hapi -v` show +HAPI's version. Flags after an agent name are handled by that agent's existing +integration; supported flags vary by agent. HAPI does not translate or append +agent help text. + ### Resume a remote session locally ```bash @@ -60,7 +80,7 @@ for queue semantics, environment isolation, recovery, supported flags and tests. ### Answer local Claude prompts from HAPI -In a local Claude session started by `hapi`, main-session `AskUserQuestion` +In a local Claude session started by `hapi claude`, main-session `AskUserQuestion` questions and tool permission prompts can also be answered from the web app. The terminal dialog stays usable; answering does not restart Claude or switch the session to remote mode. Claude arbitrates terminal/web races, and HAPI @@ -216,7 +236,7 @@ Data is stored in `~/.hapi/` (or `$HAPI_HOME`): ## Requirements -- Claude CLI installed and logged in (`claude` on PATH). +- Install and authenticate the agent you want to use. Claude CLI (`claude` on PATH) is required only for `hapi claude`. - Cursor Agent CLI installed (`agent` on PATH) for `hapi cursor`. Install: `curl https://cursor.com/install -fsS | bash` (macOS/Linux), `irm 'https://cursor.com/install?win32=true' | iex` (Windows). - Grok Build CLI installed (`grok` on PATH) for `hapi grok`. Authenticate with `grok login --device-auth` on headless runner machines, or set `XAI_API_KEY`. - OpenCode CLI installed (`opencode` on PATH). diff --git a/cli/src/agent/agentAvailability.test.ts b/cli/src/agent/agentAvailability.test.ts index 29d4c750..8d3e1ac0 100644 --- a/cli/src/agent/agentAvailability.test.ts +++ b/cli/src/agent/agentAvailability.test.ts @@ -1,7 +1,8 @@ import { chmod, mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import * as codexExecutable from '@/codex/utils/codexExecutable' import { executableCandidates, getAgentLaunchCommand, resolveExecutable } from './agentLaunchCommand' import { getAgentAvailability } from './agentAvailability' @@ -69,6 +70,25 @@ describe('agent executable resolution', () => { })).toEqual({ agent: 'codex', available: true }) }) + it('checks the terminal Codex executable independently of the runner override', async () => { + const directory = await mkdtemp(join(tmpdir(), 'hapi-agent-path-')) + const codex = await makeExecutable(directory, 'codex') + const resolveSpy = vi.spyOn(codexExecutable, 'resolveCodexCommand') + try { + resolveSpy.mockReturnValue({ command: codex, args: [] }) + expect(getAgentAvailability('codex', { + PATH: '', HAPI_CODEX_APP_SERVER_BIN: '/missing/app-server' + }, 'terminal')).toEqual({ agent: 'codex', available: true }) + + resolveSpy.mockReturnValue({ command: join(directory, 'missing-codex'), args: [] }) + expect(getAgentAvailability('codex', { + PATH: '', HAPI_CODEX_APP_SERVER_BIN: codex + }, 'terminal')).toEqual({ agent: 'codex', available: false, reason: 'not_found' }) + } finally { + resolveSpy.mockRestore() + } + }) + it('rejects malformed or missing DSH static configuration', async () => { const directory = await mkdtemp(join(tmpdir(), 'hapi-agent-path-')) await makeExecutable(directory, 'dsh-acp-demo') diff --git a/cli/src/agent/agentAvailability.ts b/cli/src/agent/agentAvailability.ts index da8ed9b0..70f12280 100644 --- a/cli/src/agent/agentAvailability.ts +++ b/cli/src/agent/agentAvailability.ts @@ -12,13 +12,14 @@ import { resolveDshAcpCommand } from '@/dsh/utils/dshBackend' import { getAgentLaunchCommand, resolveExecutable } from './agentLaunchCommand' type LaunchEnvironment = Record +type LaunchContext = 'runner' | 'terminal' type AgentLaunchSpec = { command: string args: string[] } -function resolveLaunchSpec(agent: AgentFlavor, env: LaunchEnvironment): AgentLaunchSpec { +function resolveLaunchSpec(agent: AgentFlavor, env: LaunchEnvironment, context: LaunchContext): AgentLaunchSpec { if (agent === 'claude') { return { command: getDefaultClaudeCodePath(env), args: [] } } @@ -26,7 +27,7 @@ function resolveLaunchSpec(agent: AgentFlavor, env: LaunchEnvironment): AgentLau // Remote Codex sessions launch app-server through this explicit // override. Validate the same command the session will actually use, // rather than falling back to an unrelated PATH/Desktop install. - if (env.HAPI_CODEX_APP_SERVER_BIN) { + if (context === 'runner' && env.HAPI_CODEX_APP_SERVER_BIN) { return { command: env.HAPI_CODEX_APP_SERVER_BIN.trim(), args: [] } } return resolveCodexCommand(env) @@ -70,6 +71,7 @@ function hasResolvableCommand(spec: AgentLaunchSpec, env: LaunchEnvironment): bo export function getAgentAvailability( agent: AgentFlavor, env: LaunchEnvironment = process.env, + context: LaunchContext = 'runner', ): AgentAvailabilityEntry { if (agent === 'gemini') { return { agent, available: false, reason: 'not_found' } @@ -77,7 +79,7 @@ export function getAgentAvailability( let spec: AgentLaunchSpec try { - spec = resolveLaunchSpec(agent, env) + spec = resolveLaunchSpec(agent, env, context) if (agent === 'dsh' && !hasValidDshConfiguration(env)) { return { agent, available: false, reason: 'invalid_configuration' } } @@ -97,7 +99,7 @@ export function getAgentAvailability( return { agent, available: false, - reason: agent === 'codex' && Boolean(env.HAPI_CODEX_APP_SERVER_BIN) + reason: context === 'runner' && agent === 'codex' && Boolean(env.HAPI_CODEX_APP_SERVER_BIN) ? 'invalid_configuration' : 'not_found', } diff --git a/cli/src/commands/claude.test.ts b/cli/src/commands/claude.test.ts index d9c979bd..6716ee4d 100644 --- a/cli/src/commands/claude.test.ts +++ b/cli/src/commands/claude.test.ts @@ -41,7 +41,7 @@ function createCommandContext(commandArgs: string[]) { } } -describe('claudeCommand model arguments', () => { +describe('claudeCommand arguments', () => { beforeEach(() => { initializeTokenMock.mockClear() maybeAutoStartServerMock.mockClear() @@ -60,4 +60,21 @@ describe('claudeCommand model arguments', () => { expect(runClaudeMock).toHaveBeenCalledWith({ model: 'claude-opus-4-1' }) }) + + it.each(['--help', '-h', '--version', '-v', 'claude'])('forwards %s without special handling', async (arg) => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + await claudeCommand.run(createCommandContext([arg])) + expect(runClaudeMock).toHaveBeenCalledWith({ claudeArgs: [arg] }) + expect(logSpy).not.toHaveBeenCalled() + } finally { + logSpy.mockRestore() + } + }) + + it('keeps arguments after -- opaque', async () => { + const args = ['--', '--model', 'literal', '--help'] + await claudeCommand.run(createCommandContext(args)) + expect(runClaudeMock).toHaveBeenCalledWith({ claudeArgs: args }) + }) }) diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index 3d7305a2..0d755132 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -1,5 +1,4 @@ import chalk from 'chalk' -import { execFileSync } from 'node:child_process' import { z } from 'zod' import { PROTOCOL_VERSION } from '@hapi/protocol' import type { StartOptions } from '@/claude/runClaude' @@ -11,31 +10,25 @@ 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 { extractErrorInfo } from '@/utils/errorUtils' import type { CommandDefinition } from './types' export const claudeCommand: CommandDefinition = { - name: 'default', + name: 'claude', 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[] = [] let hasExplicitPermissionMode = false for (let i = 0; i < args.length; i++) { const arg = args[i] - if (arg === '-h' || arg === '--help') { - showHelp = true - unknownArgs.push(arg) + if (arg === '--') { + unknownArgs.push(...args.slice(i)) + break } else if (arg === '--hapi-starting-mode') { options.startingMode = z.enum(['local', 'remote']).parse(args[++i]) } else if (arg === '--permission-mode') { @@ -90,64 +83,6 @@ export const claudeCommand: CommandDefinition = { 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 cursor Start Cursor Agent mode - hapi opencode Start OpenCode ACP mode - hapi dsh Start DeepSeek Harness ACP mode - hapi resume [id] Resume an existing HAPI session locally - hapi mcp Start MCP stdio bridge - hapi connect (not available in direct-connect mode) - hapi notify (not available in direct-connect mode) - hapi hub Start the API + web hub - hapi hub --relay Start with public relay - hapi server Alias for hapi hub - hapi runner 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', - windowsHide: 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() diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts new file mode 100644 index 00000000..eece8e72 --- /dev/null +++ b/cli/src/commands/help.ts @@ -0,0 +1,41 @@ +import { CREATABLE_AGENT_FLAVORS, getFlavorLabel } from '@hapi/protocol' + +export function printCliHelp(): void { + const agents = [...CREATABLE_AGENT_FLAVORS].sort().map((agent) => ( + ` ${`hapi ${agent}`.padEnd(26)} ${getFlavorLabel(agent)}` + )).join('\n') + + console.log(`HAPI - Coding agents with remote control + +Usage: + hapi Choose an agent interactively + hapi [options] Start an agent session + hapi [options] Run a HAPI command + +Agents: +${agents} + +Commands: + hapi auth Manage authentication + hapi resume [id] Choose or resume an existing HAPI session + hapi hub [--relay] Start the API + web hub + hapi server Alias for hapi hub + hapi runner Manage the background runner + hapi doctor Run diagnostics and troubleshooting + hapi ping-peer List peers or message another session + hapi inspect-peer Read another session's metadata and messages + hapi mcp Start the MCP stdio bridge + +Options: + -h, --help Show HAPI help + -v, --version Show HAPI version + +Examples: + hapi Choose an installed agent + hapi claude --resume Resume a Claude session + hapi codex --yolo Start Codex with automatic approvals + hapi auth login Configure authentication + +Agent options belong after the agent name; supported options vary by agent. +Scripts and non-interactive shells must specify an agent explicitly.`) +} diff --git a/cli/src/commands/registry.test.ts b/cli/src/commands/registry.test.ts new file mode 100644 index 00000000..8195ca2a --- /dev/null +++ b/cli/src/commands/registry.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol' +import { resolveCommand } from './registry' + +describe('command registry', () => { + it.each(CREATABLE_AGENT_FLAVORS)('dispatches %s explicitly with its arguments intact', (agent) => { + const commandArgs = ['--help', '-h', '--version', '--', 'claude', '--model', 'literal'] + const args = [agent, ...commandArgs] + const resolved = resolveCommand(args) + + expect(resolved?.command.name).toBe(agent) + expect(resolved?.context).toEqual({ args, subcommand: agent, commandArgs }) + }) + + it.each([[], ['unknown'], ['--yolo'], ['--resume'], ['a prompt']])( + 'does not fall back to Claude for %j', (...args) => { + expect(resolveCommand(args)).toBeNull() + } + ) + + it('keeps the hub alias and Gemini tombstone', () => { + expect(resolveCommand(['server'])?.command.run).toBe(resolveCommand(['hub'])?.command.run) + expect(resolveCommand(['gemini'])?.command.name).toBe('gemini') + }) +}) diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 30f3c32b..2bf8c598 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -25,8 +25,7 @@ import type { CommandContext, CommandDefinition } from './types' // Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on // 2026-06-18) so the agent is no longer launchable. Keep an explicit tombstone -// command so `hapi gemini` reports a clear error instead of falling through to -// the default Claude command with "gemini" as a forwarded argument. +// command so `hapi gemini` explains why the agent is no longer available. const removedGeminiCommand: CommandDefinition = { name: 'gemini', requiresRuntimeAssets: false, @@ -42,6 +41,7 @@ const removedGeminiCommand: CommandDefinition = { const COMMANDS: CommandDefinition[] = [ agyCommand, authCommand, + claudeCommand, connectCommand, codexCommand, dshCommand, @@ -70,18 +70,17 @@ for (const command of COMMANDS) { commandMap.set(command.name, command) } -export function resolveCommand(args: string[]): { command: CommandDefinition; context: CommandContext } { +export function resolveCommand(args: string[]): { command: CommandDefinition; context: CommandContext } | null { const subcommand = args[0] const command = subcommand ? commandMap.get(subcommand) : undefined - const resolvedCommand = command ?? claudeCommand - const commandArgs = command ? args.slice(1) : args + if (!command) return null return { - command: resolvedCommand, + command, context: { args, subcommand, - commandArgs + commandArgs: args.slice(1) } } } diff --git a/cli/src/commands/runCli.test.ts b/cli/src/commands/runCli.test.ts new file mode 100644 index 00000000..c9fd0623 --- /dev/null +++ b/cli/src/commands/runCli.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSelection } from '@/ui/selectAgent' +import type { CommandContext } from './types' + +const { getCliArgsMock, resolveCommandMock, selectAgentMock, ensureRuntimeAssetsMock, runMock } = vi.hoisted(() => ({ + getCliArgsMock: vi.fn<() => string[]>(), + resolveCommandMock: vi.fn(), + selectAgentMock: vi.fn<() => Promise>(), + ensureRuntimeAssetsMock: vi.fn(async () => {}), + runMock: vi.fn<(_: CommandContext) => Promise>(async () => {}) +})) + +vi.mock('@/utils/cliArgs', () => ({ getCliArgs: getCliArgsMock })) +vi.mock('./registry', () => ({ resolveCommand: resolveCommandMock })) +vi.mock('@/ui/selectAgent', () => ({ selectAgent: selectAgentMock })) +vi.mock('@/runtime/assets', () => ({ ensureRuntimeAssets: ensureRuntimeAssetsMock })) +vi.mock('@/projectPath', () => ({ isBunCompiled: () => false })) +vi.mock('@/ui/logger', () => ({ logger: { debug: vi.fn() } })) +vi.mock('@/utils/proxyEnv', () => ({ ensureLoopbackProxyBypass: vi.fn() })) + +import { runCli } from './runCli' + +describe('CLI entrypoint', () => { + const originalStdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') + const originalStdoutTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + const originalExitCode = process.exitCode + + beforeEach(() => { + vi.clearAllMocks() + process.exitCode = undefined + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }) + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + resolveCommandMock.mockImplementation((args: string[]) => ({ + command: { name: args[0], requiresRuntimeAssets: args[0] !== 'pi', run: runMock }, + context: { args, subcommand: args[0], commandArgs: args.slice(1) } + })) + }) + + afterEach(() => { + for (const [stream, descriptor] of [ + [process.stdin, originalStdinTTY], [process.stdout, originalStdoutTTY] + ] as const) { + if (descriptor) Object.defineProperty(stream, 'isTTY', descriptor) + else Reflect.deleteProperty(stream, 'isTTY') + } + process.exitCode = originalExitCode + vi.restoreAllMocks() + }) + + it.each(['--help', '-h', 'help', '--version', '-v'])('handles top-level %s without session initialization', async (arg) => { + getCliArgsMock.mockReturnValue([arg]) + await runCli() + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain(arg === '-v' || arg === '--version' ? 'hapi version:' : 'HAPI - Coding agents') + expect(output).not.toContain('Claude Code Options') + expect(resolveCommandMock).not.toHaveBeenCalled() + expect(selectAgentMock).not.toHaveBeenCalled() + expect(ensureRuntimeAssetsMock).not.toHaveBeenCalled() + expect(runMock).not.toHaveBeenCalled() + }) + + it.each(['claude', 'codex', 'pi'] as const)('dispatches a selected %s exactly like an explicit command', async (agent) => { + getCliArgsMock.mockReturnValue([]) + selectAgentMock.mockResolvedValue({ type: 'selected', agent }) + await runCli() + + expect(runMock).toHaveBeenCalledWith({ args: [agent], subcommand: agent, commandArgs: [] }) + expect(selectAgentMock).toHaveBeenCalledOnce() + if (agent === 'pi') expect(ensureRuntimeAssetsMock).not.toHaveBeenCalled() + else expect(ensureRuntimeAssetsMock).toHaveBeenCalledOnce() + }) + + it.each(['--help', '-h', '--version', '-v'])('leaves agent %s arguments to the adapter', async (flag) => { + const args = ['codex', flag, '--', '--help', 'a prompt'] + getCliArgsMock.mockReturnValue(args) + await runCli() + + expect(runMock).toHaveBeenCalledWith({ args, subcommand: 'codex', commandArgs: args.slice(1) }) + expect(selectAgentMock).not.toHaveBeenCalled() + expect(console.log).not.toHaveBeenCalled() + }) + + it.each(['unknown', '--yolo', '--resume', 'a prompt'])('rejects %s instead of starting Claude', async (arg) => { + getCliArgsMock.mockReturnValue([arg]) + resolveCommandMock.mockReturnValue(null) + await runCli() + + expect(process.exitCode).toBe(1) + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('hapi ')) + expect(selectAgentMock).not.toHaveBeenCalled() + expect(runMock).not.toHaveBeenCalled() + expect(ensureRuntimeAssetsMock).not.toHaveBeenCalled() + }) + + it.each([[false, false], [true, false], [false, true]])('requires TTY input and output (%s, %s)', async (stdinTTY, stdoutTTY) => { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: stdinTTY }) + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: stdoutTTY }) + getCliArgsMock.mockReturnValue([]) + await runCli() + + expect(process.exitCode).toBe(1) + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('interactive terminal')) + expect(selectAgentMock).not.toHaveBeenCalled() + expect(resolveCommandMock).not.toHaveBeenCalled() + }) + + it.each([0, 1, 130] as const)('propagates picker exit code %s without starting a session', async (exitCode) => { + getCliArgsMock.mockReturnValue([]) + selectAgentMock.mockResolvedValue({ type: 'exit', exitCode }) + await runCli() + + expect(process.exitCode).toBe(exitCode) + expect(resolveCommandMock).not.toHaveBeenCalled() + expect(ensureRuntimeAssetsMock).not.toHaveBeenCalled() + expect(runMock).not.toHaveBeenCalled() + }) +}) diff --git a/cli/src/commands/runCli.ts b/cli/src/commands/runCli.ts index 7a7d7bb9..78caf461 100644 --- a/cli/src/commands/runCli.ts +++ b/cli/src/commands/runCli.ts @@ -1,30 +1,60 @@ import packageJson from '../../package.json' -import { isBunCompiled } from '@/projectPath' -import { logger } from '@/ui/logger' import { getCliArgs } from '@/utils/cliArgs' import { ensureLoopbackProxyBypass } from '@/utils/proxyEnv' -import { resolveCommand } from './registry' +import { printCliHelp } from './help' export async function runCli(): Promise { - ensureLoopbackProxyBypass() - const args = getCliArgs() - if (args.includes('-v') || args.includes('--version')) { - console.log(`hapi version: ${packageJson.version}`) - process.exit(0) + if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') { + printCliHelp() + return } + if (args[0] === '-v' || args[0] === '--version') { + console.log(`hapi version: ${packageJson.version}`) + return + } + + if (args.length === 0) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error('Agent selection requires an interactive terminal. Run: hapi [options]') + printCliHelp() + process.exitCode = 1 + return + } + + const { selectAgent } = await import('@/ui/selectAgent') + const selection = await selectAgent() + if (selection.type === 'exit') { + process.exitCode = selection.exitCode + return + } + args.push(selection.agent) + } + + const { resolveCommand } = await import('./registry') + const resolved = resolveCommand(args) + if (!resolved) { + console.error(`Unknown ${args[0].startsWith('-') ? 'option' : 'command'}: ${args[0]}`) + console.error('Run hapi to choose an agent, or use hapi [options]. See hapi --help.') + process.exitCode = 1 + return + } + + ensureLoopbackProxyBypass() + const { isBunCompiled } = await import('@/projectPath') if (isBunCompiled()) { process.env.DEV = 'false' } - const { command, context } = resolveCommand(args) + const { command, context } = resolved if (command.requiresRuntimeAssets) { const { ensureRuntimeAssets } = await import('@/runtime/assets') await ensureRuntimeAssets() - logger.debug('Starting hapi CLI with args: ', process.argv) + const { logger } = await import('@/ui/logger') + logger.debug('Starting hapi CLI with args: ', args) } await command.run(context) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index a9b9055c..6a9d3db5 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1163,8 +1163,8 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // but in compiled binary mode (`bun build --compile`) the raw argv shape is // `[hapi, runner, start-sync, ...]` so slice(2) produced `['start-sync', ...]`. // The replacement then spawned `hapi start-sync ...`, which `resolveCommand` - // treats as an unknown top-level command - falling back to Claude instead - // of starting the runner. `getCliArgs()` strips runtime + entrypoint + // now rejects as an unknown top-level command (previously it fell back to + // Claude). `getCliArgs()` strips runtime + entrypoint // correctly in all execution modes. // // Defensive guard: only replay the captured argv when it actually starts diff --git a/cli/src/runner/runner.integration.test.ts b/cli/src/runner/runner.integration.test.ts index f0941a40..ca124af7 100644 --- a/cli/src/runner/runner.integration.test.ts +++ b/cli/src/runner/runner.integration.test.ts @@ -259,6 +259,7 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: it('should track both runner-spawned and terminal sessions', async () => { // Spawn a real hapi process that looks like it was started from terminal const terminalHappyProcess = spawnHappyCLI([ + 'claude', '--hapi-starting-mode', 'remote', '--started-by', 'terminal' ], { @@ -544,6 +545,7 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: */ it('regression: registered detached child is reaped even when the test body never reaches its own cleanup', async () => { const child = spawnHappyCLI([ + 'claude', '--hapi-starting-mode', 'remote', '--started-by', 'terminal' ], { diff --git a/cli/src/ui/ink/AgentPicker.test.ts b/cli/src/ui/ink/AgentPicker.test.ts new file mode 100644 index 00000000..483758d7 --- /dev/null +++ b/cli/src/ui/ink/AgentPicker.test.ts @@ -0,0 +1,96 @@ +import React, { act } from 'react' +import { PassThrough } from 'node:stream' +import { render, type Instance } from 'ink' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentAvailabilityEntry } from '@hapi/protocol' +import { AgentPicker } from './AgentPicker' + +type InputHandler = (input: string, key: { + ctrl?: boolean; escape?: boolean; upArrow?: boolean; downArrow?: boolean; return?: boolean +}) => void +let inputHandler: InputHandler | null = null + +vi.mock('ink', async (importOriginal) => ({ + ...await importOriginal(), + useInput: (handler: InputHandler) => { inputHandler = handler } +})) + +describe('agent picker', () => { + let instance: Instance | undefined + let output: string + const onSelect = vi.fn() + const onCancel = vi.fn() + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.clearAllMocks() + inputHandler = null + output = '' + }) + + afterEach(async () => { + await act(async () => { instance?.unmount() }) + instance = undefined + vi.unstubAllGlobals() + }) + + async function mount(agents: AgentAvailabilityEntry[]): Promise { + const stdout = Object.assign(new PassThrough(), { isTTY: true, columns: 80, rows: 24 }) + const stdin = Object.assign(new PassThrough(), { isTTY: false }) + stdout.on('data', (chunk: Buffer) => { output += chunk.toString() }) + await act(async () => { + instance = render(React.createElement(AgentPicker, { agents, onSelect, onCancel }), { + stdout: stdout as unknown as NodeJS.WriteStream, + stderr: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false + }) + }) + } + + async function press(key: Parameters[1], input = ''): Promise { + if (!inputHandler) throw new Error('Input handler missing') + await act(async () => { inputHandler?.(input, key) }) + } + + it('shows unavailable reasons and skips disabled agents during navigation', async () => { + await mount([ + { agent: 'claude', available: false, reason: 'not_found' }, + { agent: 'codex', available: true }, + { agent: 'dsh', available: false, reason: 'invalid_configuration' }, + { agent: 'pi', available: true } + ]) + + expect(output).toContain('Claude (claude)') + expect(output).toContain('not installed or not on PATH') + expect(output).toContain('invalid configuration') + expect(onSelect).not.toHaveBeenCalled() + await press({ upArrow: true }) + await press({ return: true }) + expect(onSelect).toHaveBeenLastCalledWith('codex') + await press({ downArrow: true }) + await press({ downArrow: true }) + await press({ return: true }) + expect(onSelect).toHaveBeenLastCalledWith('pi') + await press({ upArrow: true }) + await press({ return: true }) + expect(onSelect).toHaveBeenLastCalledWith('codex') + }) + + it('requires confirmation even when only one agent is available', async () => { + await mount([{ agent: 'codex', available: true }]) + expect(onSelect).not.toHaveBeenCalled() + await press({ return: true }) + expect(onSelect).toHaveBeenCalledExactlyOnceWith('codex') + }) + + it('cancels with Escape or Ctrl-C without selecting an agent', async () => { + await mount([{ agent: 'codex', available: true }]) + await press({ escape: true }) + expect(onCancel).toHaveBeenLastCalledWith(0) + await press({ ctrl: true }, 'c') + expect(onCancel).toHaveBeenLastCalledWith(130) + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/cli/src/ui/ink/AgentPicker.tsx b/cli/src/ui/ink/AgentPicker.tsx new file mode 100644 index 00000000..dff7ebc0 --- /dev/null +++ b/cli/src/ui/ink/AgentPicker.tsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react' +import { Box, Text, useInput } from 'ink' +import { getFlavorLabel, type AgentAvailabilityEntry, type AgentFlavor } from '@hapi/protocol' + +export type AgentPickerProps = { + agents: readonly AgentAvailabilityEntry[] + onSelect: (agent: AgentFlavor) => void + onCancel: (exitCode: 0 | 130) => void +} + +export function formatAgentChoice(entry: AgentAvailabilityEntry): string { + const label = `${getFlavorLabel(entry.agent)} (${entry.agent})` + if (entry.available) return label + const reason = entry.reason === 'invalid_configuration' + ? 'invalid configuration' + : 'not installed or not on PATH' + return `${label} — ${reason}` +} + +export function AgentPicker({ agents, onSelect, onCancel }: AgentPickerProps): React.ReactElement { + const available = agents.filter((entry) => entry.available) + const [selectedIndex, setSelectedIndex] = useState(0) + const selected = available[selectedIndex]?.agent + + useInput((input, key) => { + if (key.ctrl && input === 'c') { + onCancel(130) + } else if (key.escape) { + onCancel(0) + } else if (key.upArrow) { + setSelectedIndex((index) => Math.max(0, index - 1)) + } else if (key.downArrow) { + setSelectedIndex((index) => Math.min(available.length - 1, index + 1)) + } else if (key.return && selected) { + onSelect(selected) + } + }) + + return ( + + Choose an agent + + {agents.map((entry) => ( + + {entry.agent === selected ? '> ' : ' '}{formatAgentChoice(entry)} + + ))} + + Up/Down move | Enter start | Esc / Ctrl-C cancel + + ) +} diff --git a/cli/src/ui/selectAgent.test.ts b/cli/src/ui/selectAgent.test.ts new file mode 100644 index 00000000..cf1d6ef6 --- /dev/null +++ b/cli/src/ui/selectAgent.test.ts @@ -0,0 +1,83 @@ +import type React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CREATABLE_AGENT_FLAVORS, type AgentAvailabilityEntry, type AgentFlavor } from '@hapi/protocol' +import type { AgentPickerProps } from './ink/AgentPicker' + +const { availabilityMock, renderMock, restoreMock } = vi.hoisted(() => ({ + availabilityMock: vi.fn<(agent: AgentFlavor) => AgentAvailabilityEntry>(), + renderMock: vi.fn<(element: React.ReactElement, options: unknown) => { + unmount: () => void + waitUntilExit: () => Promise + }>(), + restoreMock: vi.fn() +})) + +vi.mock('@/agent/agentAvailability', () => ({ getAgentAvailability: availabilityMock })) +vi.mock('./terminalState', () => ({ restoreTerminalState: restoreMock })) +vi.mock('ink', async (importOriginal) => ({ + ...await importOriginal(), + render: renderMock +})) + +import { selectAgent } from './selectAgent' + +describe('agent selection lifecycle', () => { + const unmount = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + availabilityMock.mockImplementation((agent) => ({ agent, available: agent === 'codex' })) + renderMock.mockImplementation(() => { + let finish: () => void = () => {} + const exited = new Promise((resolve) => { finish = resolve }) + unmount.mockImplementation(finish) + return { unmount, waitUntilExit: () => exited } + }) + }) + + it('lists all supported agents alphabetically and cleans up before returning the selection', async () => { + const pending = selectAgent() + const props = renderMock.mock.calls[0][0].props + expect(props.agents.map((entry) => entry.agent)).toEqual([...CREATABLE_AGENT_FLAVORS].sort()) + expect(availabilityMock).toHaveBeenCalledWith('codex', process.env, 'terminal') + expect(unmount).not.toHaveBeenCalled() + + props.onSelect('codex') + // Repeated input cannot change an already confirmed selection. + props.onCancel(130) + expect(await pending).toEqual({ type: 'selected', agent: 'codex' }) + expect(restoreMock).toHaveBeenCalledOnce() + expect(unmount.mock.invocationCallOrder[0]).toBeLessThan(restoreMock.mock.invocationCallOrder[0]) + }) + + it.each([0, 130] as const)('cleans up on cancellation with code %s', async (exitCode) => { + const pending = selectAgent() + renderMock.mock.calls[0][0].props.onCancel(exitCode) + expect(await pending).toEqual({ type: 'exit', exitCode }) + expect(unmount).toHaveBeenCalled() + expect(restoreMock).toHaveBeenCalledOnce() + }) + + it('reports missing agents without mounting an empty picker', async () => { + availabilityMock.mockImplementation((agent) => ({ agent, available: false, reason: 'not_found' })) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(await selectAgent()).toEqual({ type: 'exit', exitCode: 1 }) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No supported agents')) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('not installed or not on PATH')) + expect(renderMock).not.toHaveBeenCalled() + } finally { + errorSpy.mockRestore() + } + }) + + it('restores the terminal if Ink fails', async () => { + renderMock.mockReturnValue({ + unmount, + waitUntilExit: async () => { throw new Error('render failure') } + }) + await expect(selectAgent()).rejects.toThrow('render failure') + expect(unmount).toHaveBeenCalled() + expect(restoreMock).toHaveBeenCalledOnce() + }) +}) diff --git a/cli/src/ui/selectAgent.ts b/cli/src/ui/selectAgent.ts new file mode 100644 index 00000000..4536430a --- /dev/null +++ b/cli/src/ui/selectAgent.ts @@ -0,0 +1,49 @@ +import React from 'react' +import { render } from 'ink' +import { CREATABLE_AGENT_FLAVORS, type AgentFlavor } from '@hapi/protocol' +import { getAgentAvailability } from '@/agent/agentAvailability' +import { AgentPicker, formatAgentChoice } from './ink/AgentPicker' +import { restoreTerminalState } from './terminalState' + +export type AgentSelection = + | { type: 'selected'; agent: AgentFlavor } + | { type: 'exit'; exitCode: 0 | 1 | 130 } + +export async function selectAgent(): Promise { + const agents = [...CREATABLE_AGENT_FLAVORS].sort().map((agent) => ( + getAgentAvailability(agent, process.env, 'terminal') + )) + if (!agents.some((entry) => entry.available)) { + console.error('No supported agents are available:') + for (const entry of agents) { + console.error(` ${formatAgentChoice(entry)}`) + } + console.error('Install an agent or fix its configuration, then run hapi again or use hapi [options].') + return { type: 'exit', exitCode: 1 } + } + + let selection: AgentSelection = { type: 'exit', exitCode: 0 } + let settled = false + const complete = (result: AgentSelection) => { + if (settled) return + settled = true + selection = result + instance.unmount() + } + const instance = render(React.createElement(AgentPicker, { + agents, + onSelect: (agent) => complete({ type: 'selected', agent }), + onCancel: (exitCode) => complete({ type: 'exit', exitCode }) + }), { + patchConsole: false, + exitOnCtrlC: false + }) + + try { + await instance.waitUntilExit() + return selection + } finally { + instance.unmount() + restoreTerminalState() + } +} diff --git a/cli/src/utils/cliArgs.test.ts b/cli/src/utils/cliArgs.test.ts new file mode 100644 index 00000000..fdd68e5a --- /dev/null +++ b/cli/src/utils/cliArgs.test.ts @@ -0,0 +1,56 @@ +import { basename } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { normalizeCliArgs } from './cliArgs' + +describe('CLI argv normalization', () => { + const exec = process.execPath + const entrypoint = '/workspace/cli/src/index.ts' + + afterEach(() => { vi.unstubAllGlobals() }) + + it.each([ + [exec], + [basename(exec)], + [exec, entrypoint], + ['bun', entrypoint], + [exec, entrypoint, '--'] + ])('strips the runtime wrapper %j without consuming command arguments', (...prefix) => { + const args = ['codex', '--', '--help', '--version', 'prompt with spaces'] + expect(normalizeCliArgs([...prefix, ...args])).toEqual(args) + }) + + it('preserves a separator in already normalized arguments', () => { + expect(normalizeCliArgs(['claude', '--', '--model', 'literal'])) + .toEqual(['claude', '--', '--model', 'literal']) + expect(normalizeCliArgs(['--', '--help'])).toEqual(['--', '--help']) + }) + + it('preserves runner handoff arguments for compiled and source entrypoints', () => { + const args = ['runner', 'start-sync', '--workspace-root', '/my project'] + expect(normalizeCliArgs([exec, ...args])).toEqual(args) + expect(normalizeCliArgs([exec, entrypoint, ...args])).toEqual(args) + }) + + it('returns an empty command for bare invocations', () => { + expect(normalizeCliArgs([])).toEqual([]) + expect(normalizeCliArgs([exec])).toEqual([]) + expect(normalizeCliArgs([exec, entrypoint])).toEqual([]) + }) + + it('preserves filenames used as prompts instead of treating them as entrypoints', () => { + vi.stubGlobal('Bun', { main: entrypoint }) + expect(normalizeCliArgs([exec, entrypoint, 'prompt.ts', 'another.js'])) + .toEqual(['prompt.ts', 'another.js']) + expect(normalizeCliArgs(['prompt.ts'])).toEqual(['prompt.ts']) + }) + + it('handles Bun virtual entrypoints and compiled process argv', () => { + const main = '/$bunfs/root/hapi' + vi.stubGlobal('Bun', { main }) + expect(normalizeCliArgs(['bun', main, 'codex', '--', '--help'])) + .toEqual(['codex', '--', '--help']) + expect(normalizeCliArgs(['bun', main, 'prompt.ts'])).toEqual(['prompt.ts']) + expect(normalizeCliArgs([exec, 'prompt.ts'])).toEqual(['prompt.ts']) + expect(normalizeCliArgs(['bun', main])).toEqual([]) + }) +}) diff --git a/cli/src/utils/cliArgs.ts b/cli/src/utils/cliArgs.ts index 5470e90d..7bff13c9 100644 --- a/cli/src/utils/cliArgs.ts +++ b/cli/src/utils/cliArgs.ts @@ -12,28 +12,12 @@ function isEntrypointPath(value: string, bunMain: string): boolean { if (!value) { return false; } - if (value === bunMain) { - return true; + if (bunMain) { + return value === bunMain; } return /\.(c|m)?(ts|js)$/.test(value); } -function hasRuntimeWrapper(preArgs: string[], execPath: string, execBase: string, bunMain: string): boolean { - if (preArgs.length === 0) { - return false; - } - if (preArgs[0] === 'bun') { - return true; - } - if (preArgs.length < 2) { - return false; - } - if (preArgs[0] !== execPath && preArgs[0] !== execBase) { - return false; - } - return isEntrypointPath(preArgs[1], bunMain); -} - export function normalizeCliArgs(rawArgv: string[]): string[] { if (!Array.isArray(rawArgv) || rawArgv.length === 0) { return []; @@ -42,34 +26,30 @@ export function normalizeCliArgs(rawArgv: string[]): string[] { const execPath = process.execPath; const execBase = basename(execPath); const bunMain = globalThis.Bun?.main ?? ''; - const dashIndex = rawArgv.indexOf('--'); - let argv = rawArgv.slice(); - if (dashIndex >= 0) { - const preArgs = rawArgv.slice(0, dashIndex); - const postArgs = rawArgv.slice(dashIndex + 1); - argv = hasRuntimeWrapper(preArgs, execPath, execBase, bunMain) - ? postArgs - : [...preArgs, ...postArgs]; - } + const argv = rawArgv; let startIndex = 0; - while (startIndex < argv.length) { - const value = argv[startIndex] || ''; - const nextValue = argv[startIndex + 1] || ''; - if ( - value === 'bun' && - (nextValue === bunMain || nextValue === execPath || nextValue === execBase || isEntrypointPath(nextValue, bunMain)) - ) { - startIndex += 2; - continue; - } - if (value === execPath || value === execBase || isEntrypointPath(value, bunMain)) { - startIndex += 1; - continue; - } - break; + const nextValue = argv[1] || ''; + if (argv[0] === 'bun' && ( + nextValue === execPath || nextValue === execBase || isEntrypointPath(nextValue, bunMain) + )) { + startIndex += 1; + } + if (argv[startIndex] === execPath || argv[startIndex] === execBase) { + startIndex += 1; + } + // Consume at most one entrypoint. Later filenames, even *.ts / *.js, are + // user arguments, not additional runtime wrappers. + if ((startIndex > 0 || (bunMain && argv[0] === bunMain)) + && isEntrypointPath(argv[startIndex] || '', bunMain)) { + startIndex += 1; } + // Only a separator immediately after the runtime/entrypoint is a wrapper + // separator. A later `--` belongs to the selected command and its arguments. + if (startIndex > 0 && argv[startIndex] === '--') { + startIndex += 1; + } return argv.slice(startIndex); } diff --git a/docs/guide/agents.md b/docs/guide/agents.md index d83e6930..e314d0aa 100644 --- a/docs/guide/agents.md +++ b/docs/guide/agents.md @@ -2,11 +2,17 @@ HAPI is a wrapper around AI coding agents. One CLI (`hapi `) starts any supported agent locally and exposes the same session for remote control from the web app, PWA, and Telegram — with permission prompts, message queueing, and seamless handoff between terminal and phone. +Run `hapi` without arguments to choose an agent interactively. The picker shows +all supported agents alphabetically by command name; missing or misconfigured +agents are disabled with a reason. Scripts must use `hapi [options]`. +`hapi --help` shows HAPI's own help. Options after an agent name belong to that +agent's integration; their supported syntax varies by agent. + ## Support matrix | Agent | Command | Integration | Local | Remote | Permission modes | Resume | |-------|---------|-------------|:-----:|:------:|------------------|:------:| -| Claude Code | `hapi` / `hapi claude` | Terminal wrapper (local) + Claude Agent SDK (remote) | ✓ | ✓ | `default` `acceptEdits` `auto` `bypassPermissions` `plan` | ✓ | +| Claude Code | `hapi claude` | Terminal wrapper (local) + Claude Agent SDK (remote) | ✓ | ✓ | `default` `acceptEdits` `auto` `bypassPermissions` `plan` | ✓ | | Codex | `hapi codex` | TUI wrapper (local) + `codex app-server` JSON-RPC (remote) | ✓ | ✓ | `default` `read-only` `safe-yolo` `yolo` (+ `plan` collaboration mode) | ✓ | | Cursor Agent | `hapi cursor` | ACP (`agent acp`); legacy stream-json resume | ✓ | ✓ | `default` `plan` `ask` `debug` `autoReview` `yolo` | ✓ | | Grok Build | `hapi grok` | ACP (`grok agent stdio`) | ✓ | ✓ | `default` `auto` `plan` `bypassPermissions` | ✓ | @@ -264,7 +270,7 @@ overall permission policy. ## Other agents -- **Claude Code** (`hapi` / `hapi claude`) — the default and recommended flavor; local sessions wrap the native TUI, remote sessions drive the Claude Agent SDK. [Claude Code docs](https://docs.anthropic.com/en/docs/claude-code) +- **Claude Code** (`hapi claude`) — local sessions wrap the native TUI, remote sessions drive the Claude Agent SDK. [Claude Code docs](https://docs.anthropic.com/en/docs/claude-code) - **Codex** (`hapi codex`) — OpenAI's Codex CLI; remote sessions talk to `codex app-server` over JSON-RPC, with a dedicated `plan` collaboration mode. [openai/codex](https://github.com/openai/codex) - **GitHub Copilot** (`hapi copilot`) — Copilot CLI over ACP (`copilot --acp --stdio`). [GitHub Copilot](https://github.com/features/copilot) - **Kimi** (`hapi kimi`) — Moonshot AI's Kimi CLI over ACP (`kimi acp`). [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli) diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index c705baa5..bbc0139d 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -60,8 +60,8 @@ The CLI is a wrapper around AI coding agents. It supports multiple agent flavors **Key Commands:** ```bash -hapi # Start a session (Claude Code by default) -hapi # Start a session with another agent flavor (see Supported agents) +hapi # Choose an agent interactively and start a session +hapi # Start a supported agent directly (required in scripts) hapi runner start # Run background service for remote session spawning hapi ping-peer --list # Shell peer shortlist (prefer MCP list_peers in-session) ``` diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 9223dee9..606d29ec 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -60,7 +60,7 @@ HAPI has three components: └───────────┘ ``` -- **CLI**: Start a session with `hapi`. The CLI wraps your AI agent and syncs with the hub. +- **CLI**: Choose an agent with `hapi`, or start one directly with `hapi `. The CLI wraps your AI agent and syncs with the hub. Scripts must specify the agent explicitly. - **Hub**: Run `hapi hub`. Stores sessions, handles permissions, enables remote access. - **Runner**: Run `hapi runner start`. Lets you spawn sessions from phone/web without keeping a terminal open.