diff --git a/AGENTS.md b/AGENTS.md index c3092eaaf..85d3ab742 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Short guide for AI agents in this repo. Prefer progressive loading: start with t ## What is HAPI? -Local-first platform for running AI coding agents (Claude Code, Codex, Gemini) with remote control via web/phone. CLI wraps agents and connects to hub; hub serves web app and handles real-time sync. +Local-first platform for running AI coding agents (Claude Code, Codex, Gemini, OpenCode, Cursor) with remote control via web/phone. CLI wraps agents and connects to hub; hub serves web app and handles real-time sync. ## Repo layout @@ -36,7 +36,7 @@ Bun workspaces; `shared` consumed by cli, hub, web. ``` **Data flow:** -1. CLI spawns agent (claude/codex/gemini), connects to hub via Socket.IO +1. CLI spawns agent (claude/codex/gemini/opencode/cursor), connects to hub via Socket.IO 2. Agent events → CLI → hub (socket `message` event) → DB + SSE broadcast 3. Web subscribes to SSE `/api/events`, receives live updates 4. User actions → Web → hub REST API → RPC to CLI → agent diff --git a/README.md b/README.md index f7eab7304..413e225b9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HAPI -Run official Claude Code / Codex / Gemini / OpenCode sessions locally and control them remotely through a Web / PWA / Telegram Mini App. +Run official Claude Code / Codex / Gemini / OpenCode / Cursor sessions locally and control them remotely through a Web / PWA / Telegram Mini App. > **Why HAPI?** HAPI is a local-first alternative to Happy. See [Why Not Happy?](docs/guide/why-hapi.md) for the key differences. @@ -9,7 +9,7 @@ Run official Claude Code / Codex / Gemini / OpenCode sessions locally and contro - **Seamless Handoff** - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart. - **Native First** - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory. - **AFK Without Stopping** - Step away from your desk? Approve AI requests from your phone with one tap. -- **Your AI, Your Choice** - Claude Code, Codex, Gemini, OpenCode—different models, one unified workflow. +- **Your AI, Your Choice** - Claude Code, Codex, Gemini, OpenCode, Cursor—different models, one unified workflow. - **Terminal Anywhere** - Run commands from your phone or browser, directly connected to the working machine. - **Voice Control** - Talk to your AI agent hands-free using the built-in voice assistant. diff --git a/cli/README.md b/cli/README.md index 006fb22ed..8f62657f3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,11 +1,12 @@ # hapi CLI -Run Claude Code, Codex, Gemini, or OpenCode sessions from your terminal and control them remotely through the hapi hub. +Run Claude Code, Codex, Gemini, OpenCode, or Cursor sessions from your terminal and control them remotely through the hapi hub. ## What it does - Starts Claude Code sessions and registers them with hapi-hub. - Starts Codex mode for OpenAI-based sessions. +- Starts Cursor mode using Cursor CLI. - Starts Gemini mode via ACP (Anthropic Code Plugins). - Starts OpenCode mode via ACP and its plugin hook system. - Provides an MCP stdio bridge for external tools. @@ -26,6 +27,7 @@ Run Claude Code, Codex, Gemini, or OpenCode sessions from your terminal and cont - `hapi` - Start a Claude Code session (passes through Claude CLI flags). See `src/index.ts`. - `hapi codex` - Start Codex mode. See `src/codex/runCodex.ts`. - `hapi codex resume ` - Resume existing Codex session. +- `hapi cursor` - Start Cursor mode. See `src/cursor/runCursor.ts`. - `hapi gemini` - Start Gemini mode via ACP. See `src/agent/runners/runAgentSession.ts`. Note: Gemini runs in remote mode only; it waits for messages from the hub UI/Telegram. - `hapi opencode` - Start OpenCode mode via ACP. See `src/opencode/runOpencode.ts`. @@ -103,6 +105,7 @@ Data is stored in `~/.hapi/` (or `$HAPI_HOME`): ## Requirements - Claude CLI installed and logged in (`claude` on PATH). +- Cursor CLI installed and logged in (`agent` on PATH). - OpenCode CLI installed (`opencode` on PATH). - Bun for building from source. @@ -127,6 +130,7 @@ bun run build:single-exe - `src/api/` - Bot communication (Socket.IO + REST). - `src/claude/` - Claude Code integration. - `src/codex/` - Codex mode integration. +- `src/cursor/` - Cursor mode integration. - `src/agent/` - Multi-agent support (Gemini via ACP). - `src/opencode/` - OpenCode ACP + hook integration. - `src/runner/` - Background service. diff --git a/cli/src/agent/runners/cursor.ts b/cli/src/agent/runners/cursor.ts new file mode 100644 index 000000000..7b3dffd94 --- /dev/null +++ b/cli/src/agent/runners/cursor.ts @@ -0,0 +1,16 @@ +import { AgentRegistry } from '@/agent/AgentRegistry'; +import { CursorCliBackend } from '@/cursor/CursorCliBackend'; + +export type CursorRunnerOptions = { + model?: string; + resumeSessionId?: string; + cursorArgs?: string[]; +}; + +export function registerCursorAgent(options: CursorRunnerOptions = {}): void { + AgentRegistry.register('cursor', () => new CursorCliBackend({ + model: options.model, + resumeSessionId: options.resumeSessionId, + extraArgs: options.cursorArgs + })); +} diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index e8f32cabd..b5105280b 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -28,6 +28,16 @@ export async function runAgentSession(opts: { agentType: string; startedBy?: 'runner' | 'terminal'; }): Promise { + const applyBackendSessionIdToMetadata = (backendSessionId: string) => { + if (opts.agentType !== 'cursor') { + return; + } + session.updateMetadata((metadata) => ({ + ...metadata, + cursorSessionId: backendSessionId + })); + }; + const initialState: AgentState = { controlledByUser: false }; @@ -70,6 +80,7 @@ export async function runAgentSession(opts: { cwd: process.cwd(), mcpServers }); + applyBackendSessionIdToMetadata(agentSessionId); let thinking = false; let shouldExit = false; @@ -138,6 +149,10 @@ export async function runAgentSession(opts: { session.sendCodexMessage(converted); } }); + const activeSessionId = backend.getActiveSessionId?.() + if (activeSessionId) { + applyBackendSessionIdToMetadata(activeSessionId); + } } catch (error) { logger.warn('[ACP] Prompt failed', error); session.sendSessionEvent({ diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts index 1441e4c1d..3819a83f6 100644 --- a/cli/src/agent/types.ts +++ b/cli/src/agent/types.ts @@ -62,6 +62,7 @@ export interface AgentBackend { cancelPrompt(sessionId: string): Promise; respondToPermission(sessionId: string, request: PermissionRequest, response: PermissionResponse): Promise; onPermissionRequest(handler: (request: PermissionRequest) => void): void; + getActiveSessionId?(): string | null; disconnect(): Promise; } diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index 951a6ffab..865e8660d 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -71,6 +71,7 @@ ${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 mode hapi gemini Start Gemini ACP mode hapi opencode Start OpenCode ACP mode hapi mcp Start MCP stdio bridge diff --git a/cli/src/commands/cursor.ts b/cli/src/commands/cursor.ts new file mode 100644 index 000000000..c6d00a7b9 --- /dev/null +++ b/cli/src/commands/cursor.ts @@ -0,0 +1,65 @@ +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 cursorCommand: CommandDefinition = { + name: 'cursor', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + const options: { + startedBy?: 'runner' | 'terminal' + model?: string + resumeSessionId?: string + cursorArgs?: string[] + } = {} + const passthroughArgs: string[] = [] + + for (let i = 0; i < commandArgs.length; i++) { + const arg = commandArgs[i] + if (arg === '--started-by') { + options.startedBy = commandArgs[++i] as 'runner' | 'terminal' + } else if (arg === '--yolo') { + // Cursor mode always runs in print mode with --force. Keep the flag for compatibility. + continue + } else if (arg === '--model') { + const model = commandArgs[++i] + if (!model) { + throw new Error('Missing --model value') + } + options.model = model + } else if (arg === '--resume') { + const resumeSessionId = commandArgs[++i] + if (!resumeSessionId) { + throw new Error('Missing --resume value') + } + options.resumeSessionId = resumeSessionId + } else if (arg === '--hapi-starting-mode') { + // Cursor integration is remote-only; consume this runner flag without forwarding it. + i += 1 + } else { + passthroughArgs.push(arg) + } + } + + if (passthroughArgs.length > 0) { + options.cursorArgs = passthroughArgs + } + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + + const { runCursor } = await import('@/cursor/runCursor') + await runCursor(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/registry.ts b/cli/src/commands/registry.ts index 32a27bb8a..416d9d59b 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -1,6 +1,7 @@ import { authCommand } from './auth' import { claudeCommand } from './claude' import { codexCommand } from './codex' +import { cursorCommand } from './cursor' import { connectCommand } from './connect' import { runnerCommand } from './runner' import { doctorCommand } from './doctor' @@ -16,6 +17,7 @@ const COMMANDS: CommandDefinition[] = [ authCommand, connectCommand, codexCommand, + cursorCommand, geminiCommand, opencodeCommand, mcpCommand, diff --git a/cli/src/cursor/CursorCliBackend.ts b/cli/src/cursor/CursorCliBackend.ts new file mode 100644 index 000000000..a0fae32ee --- /dev/null +++ b/cli/src/cursor/CursorCliBackend.ts @@ -0,0 +1,290 @@ +import { randomUUID } from 'node:crypto'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { logger } from '@/ui/logger'; +import { killProcessByChildProcess } from '@/utils/process'; +import type { + AgentBackend, + AgentMessage, + AgentSessionConfig, + PermissionRequest, + PermissionResponse, + PromptContent +} from '@/agent/types'; + +type CursorCliBackendOptions = { + model?: string; + resumeSessionId?: string; + extraArgs?: string[]; +}; + +type StreamEvent = { + type?: unknown; + subtype?: unknown; + session_id?: unknown; + call_id?: unknown; + tool_call?: unknown; + message?: unknown; + result?: unknown; +}; + +function toText(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; +} + +function buildPromptText(content: PromptContent[]): string { + const parts = content + .filter((item) => item.type === 'text') + .map((item) => item.text.trim()) + .filter(Boolean); + return parts.join('\n\n'); +} + +function normalizeToolName(key: string): string { + const trimmed = key.endsWith('ToolCall') ? key.slice(0, -'ToolCall'.length) : key; + if (!trimmed) { + return 'CursorTool'; + } + return `Cursor${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; +} + +function extractAssistantText(message: unknown): string | null { + const msgRecord = asRecord(message); + const content = msgRecord?.content; + if (!Array.isArray(content)) { + return null; + } + const chunks = content + .map((item) => { + const record = asRecord(item); + return record?.type === 'text' ? toText(record.text) : null; + }) + .filter((chunk): chunk is string => Boolean(chunk)); + if (chunks.length === 0) { + return null; + } + return chunks.join('\n'); +} + +export class CursorCliBackend implements AgentBackend { + private readonly sessionConfigById = new Map(); + private currentProcess: ChildProcess | null = null; + private activeCursorSessionId: string | null; + + constructor(private readonly options: CursorCliBackendOptions) { + this.activeCursorSessionId = options.resumeSessionId ?? null; + } + + getActiveSessionId(): string | null { + return this.activeCursorSessionId; + } + + async initialize(): Promise { + return; + } + + async newSession(config: AgentSessionConfig): Promise { + const sessionId = randomUUID(); + this.sessionConfigById.set(sessionId, config); + return sessionId; + } + + async prompt( + sessionId: string, + content: PromptContent[], + onUpdate: (msg: AgentMessage) => void + ): Promise { + const config = this.sessionConfigById.get(sessionId); + if (!config) { + throw new Error('Cursor session not initialized'); + } + + const prompt = buildPromptText(content); + if (!prompt) { + onUpdate({ type: 'turn_complete', stopReason: 'empty_prompt' }); + return; + } + + const args: string[] = ['--print', '--output-format', 'stream-json']; + if (this.options.model) { + args.push('--model', this.options.model); + } + // Cursor print mode requires --force for write operations. + args.push('--force'); + if (this.activeCursorSessionId) { + args.push(`--resume=${this.activeCursorSessionId}`); + } + if (this.options.extraArgs && this.options.extraArgs.length > 0) { + args.push(...this.options.extraArgs); + } + args.push('-p', prompt); + + let stderrTail = ''; + let stdoutBuffer = ''; + let finalText: string | null = null; + let assistantFallback: string | null = null; + + const handleStreamLine = (line: string) => { + let event: StreamEvent; + try { + event = JSON.parse(line) as StreamEvent; + } catch { + return; + } + + const eventType = toText(event.type); + if (!eventType) { + return; + } + + const sessionIdFromEvent = toText(event.session_id); + if (sessionIdFromEvent) { + this.activeCursorSessionId = sessionIdFromEvent; + } + + if (eventType === 'assistant') { + const assistantText = extractAssistantText(event.message); + if (assistantText) { + assistantFallback = assistantText; + } + return; + } + + if (eventType === 'tool_call') { + const subtype = toText(event.subtype); + const callId = toText(event.call_id) ?? randomUUID(); + const toolCall = asRecord(event.tool_call); + const toolEntry = toolCall ? Object.entries(toolCall)[0] : undefined; + if (!toolEntry) { + return; + } + + const [toolKey, rawPayload] = toolEntry; + const toolName = normalizeToolName(toolKey); + const payload = asRecord(rawPayload) ?? {}; + const toolArgs = payload.args ?? payload; + + if (subtype === 'started') { + onUpdate({ + type: 'tool_call', + id: callId, + name: toolName, + input: toolArgs, + status: 'in_progress' + }); + return; + } + + if (subtype === 'completed') { + onUpdate({ + type: 'tool_result', + id: callId, + output: payload.result ?? payload, + status: 'completed' + }); + } + return; + } + + if (eventType === 'result') { + finalText = toText(event.result) ?? finalText; + } + }; + + await new Promise((resolve, reject) => { + const child = spawn('agent', args, { + cwd: config.cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32' + }); + this.currentProcess = child; + + child.stdout?.on('data', (chunk) => { + stdoutBuffer += chunk.toString(); + let newlineIndex = stdoutBuffer.indexOf('\n'); + while (newlineIndex !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex).trim(); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + if (line.length > 0) { + handleStreamLine(line); + } + newlineIndex = stdoutBuffer.indexOf('\n'); + } + }); + + child.stderr?.on('data', (chunk) => { + const text = chunk.toString(); + if (!text) { + return; + } + const combined = `${stderrTail}${text}`; + stderrTail = combined.length > 4000 ? combined.slice(-4000) : combined; + }); + + child.on('error', (error) => { + const message = error instanceof Error ? error.message : String(error); + reject(new Error(`Failed to spawn Cursor CLI: ${message}`)); + }); + + child.on('exit', (code, signal) => { + this.currentProcess = null; + if (stdoutBuffer.trim().length > 0) { + handleStreamLine(stdoutBuffer.trim()); + } + if (code === 0 && !signal) { + resolve(); + return; + } + const detail = stderrTail.trim(); + reject(new Error( + `Cursor CLI exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'})` + + (detail ? `\n${detail}` : '') + )); + }); + }); + + const text = finalText ?? assistantFallback; + if (text) { + onUpdate({ type: 'text', text }); + } + onUpdate({ type: 'turn_complete', stopReason: 'completed' }); + } + + async cancelPrompt(_sessionId: string): Promise { + await this.terminateCurrentProcess('cancel Cursor CLI prompt') + } + + async respondToPermission( + _sessionId: string, + _request: PermissionRequest, + _response: PermissionResponse + ): Promise { + return; + } + + onPermissionRequest(handler: (request: PermissionRequest) => void): void { + // Cursor print mode with --force does not emit permission request callbacks. + void handler + } + + async disconnect(): Promise { + await this.terminateCurrentProcess('stop Cursor CLI process') + } + + private async terminateCurrentProcess(context: string): Promise { + if (!this.currentProcess) { + return + } + const current = this.currentProcess + this.currentProcess = null + try { + await killProcessByChildProcess(current, true) + } catch (error) { + logger.debug(`[cursor] Failed to ${context}`, error) + } + } +} diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts new file mode 100644 index 000000000..c28212aec --- /dev/null +++ b/cli/src/cursor/runCursor.ts @@ -0,0 +1,20 @@ +import { runAgentSession } from '@/agent/runners/runAgentSession'; +import { registerCursorAgent } from '@/agent/runners/cursor'; + +export async function runCursor(opts: { + startedBy?: 'runner' | 'terminal'; + model?: string; + resumeSessionId?: string; + cursorArgs?: string[]; +} = {}): Promise { + registerCursorAgent({ + model: opts.model, + resumeSessionId: opts.resumeSessionId, + cursorArgs: opts.cursorArgs + }); + + await runAgentSession({ + agentType: 'cursor', + startedBy: opts.startedBy + }); +} diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 2c6f72941..d432b9805 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -4,7 +4,7 @@ export interface SpawnSessionOptions { sessionId?: string resumeSessionId?: string approvedNewDirectoryCreation?: boolean - agent?: 'claude' | 'codex' | 'gemini' | 'opencode' + agent?: 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' model?: string yolo?: boolean token?: string diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index 906161ed3..b887c6a1f 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -39,6 +39,7 @@ const BUILTIN_COMMANDS: Record = { { name: 'compress', description: 'Compress context', source: 'builtin' }, ], opencode: [], + cursor: [], }; /** diff --git a/cli/src/runner/README.md b/cli/src/runner/README.md index ba782dab7..bc1f79c06 100644 --- a/cli/src/runner/README.md +++ b/cli/src/runner/README.md @@ -84,6 +84,7 @@ The runner supports spawning sessions with different AI agents: |-------|---------|-------------------| | `claude` (default) | `hapi claude` | `CLAUDE_CODE_OAUTH_TOKEN` | | `codex` | `hapi codex` | `CODEX_HOME` (temp directory with `auth.json`) | +| `cursor` | `hapi cursor` | - | | `gemini` | `hapi gemini` | - | | `opencode` | `hapi opencode` | OpenCode config (no token injection) | diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index b6494077d..08bc6fcca 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -323,14 +323,17 @@ export async function startRunner(): Promise { } // Construct arguments for the CLI - const agentCommand = agent === 'codex' - ? 'codex' - : agent === 'gemini' - ? 'gemini' - : agent === 'opencode' - ? 'opencode' - : 'claude'; - const args = [agentCommand]; + let agentCommand: 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' = 'claude'; + if (agent === 'codex') { + agentCommand = 'codex'; + } else if (agent === 'cursor') { + agentCommand = 'cursor'; + } else if (agent === 'gemini') { + agentCommand = 'gemini'; + } else if (agent === 'opencode') { + agentCommand = 'opencode'; + } + const args: string[] = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { args.push('resume', options.resumeSessionId); diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 7cac778b7..745f73bcb 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -4,7 +4,7 @@ ### What is HAPI? -HAPI is a local-first, self-hosted platform for running and controlling AI coding agents (Claude Code, Codex, Gemini, OpenCode) remotely. It lets you start coding sessions on your computer and monitor/control them from your phone. +HAPI is a local-first, self-hosted platform for running and controlling AI coding agents (Claude Code, Codex, Gemini, OpenCode, Cursor) remotely. It lets you start coding sessions on your computer and monitor/control them from your phone. ### What does HAPI stand for? @@ -20,6 +20,7 @@ Yes, HAPI is open source and free to use under the AGPL-3.0-only license. - **OpenAI Codex** - **Google Gemini** - **OpenCode** +- **Cursor** ## Setup & Installation diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index e49c11e59..30fd57f1b 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -47,7 +47,7 @@ HAPI consists of three interconnected components that work together to provide r ### HAPI CLI -The CLI is a wrapper around AI coding agents (Claude Code, Codex, Gemini, OpenCode). It: +The CLI is a wrapper around AI coding agents (Claude Code, Codex, Gemini, OpenCode, Cursor). It: - Starts and manages coding sessions - Registers sessions with the HAPI hub @@ -58,6 +58,7 @@ The CLI is a wrapper around AI coding agents (Claude Code, Codex, Gemini, OpenCo ```bash hapi # Start Claude Code session hapi codex # Start OpenAI Codex session +hapi cursor # Start Cursor session hapi gemini # Start Google Gemini session hapi opencode # Start OpenCode session hapi runner start # Run background service for remote session spawning @@ -174,7 +175,7 @@ HAPI's defining feature is the ability to seamlessly hand off control between lo ### Local Mode -When working in local mode, you have the full terminal experience — it is native Claude Code, Codex, or OpenCode: +When working in local mode, you have the full terminal experience — it is native Claude Code, Codex, Gemini, OpenCode, or Cursor: - Direct keyboard input with instant response - Full terminal UI with syntax highlighting diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 88392f94b..b304cfb4a 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -4,7 +4,7 @@ Install the HAPI CLI and set up the hub. ## Prerequisites -- Claude Code, OpenAI Codex CLI, Google Gemini CLI, or OpenCode CLI installed +- Claude Code, OpenAI Codex CLI, Google Gemini CLI, OpenCode CLI, or Cursor CLI installed Verify your CLI is installed: @@ -20,6 +20,9 @@ gemini --version # For OpenCode CLI opencode --version + +# For Cursor CLI +agent --version ``` ## Architecture @@ -28,7 +31,7 @@ HAPI has three components: | Component | Role | Required | |-----------|------|----------| -| **CLI** | Wraps AI agents (Claude/Codex/Gemini/OpenCode), runs sessions | Yes | +| **CLI** | Wraps AI agents (Claude/Codex/Gemini/OpenCode/Cursor), runs sessions | Yes | | **Hub** | Central coordinator: persistence, real-time sync, remote access | Yes | | **Runner** | Background service for remote session spawning | Optional | diff --git a/docs/guide/voice-assistant.md b/docs/guide/voice-assistant.md index bca0f9be9..86e19a46b 100644 --- a/docs/guide/voice-assistant.md +++ b/docs/guide/voice-assistant.md @@ -10,7 +10,7 @@ The voice assistant lets you: - **Approve permissions by voice** - Say "yes" or "no" to approve or deny permission requests - **Monitor progress** - Receive spoken updates when tasks complete or errors occur -The assistant bridges voice communication with your active coding agent (Claude Code, Codex, Gemini, or OpenCode), relaying your requests and summarizing responses in natural speech. +The assistant bridges voice communication with your active coding agent (Claude Code, Codex, Gemini, OpenCode, or Cursor), relaying your requests and summarizing responses in natural speech. ## Prerequisites diff --git a/hub/src/notifications/sessionInfo.ts b/hub/src/notifications/sessionInfo.ts index 7094f208f..d739cd129 100644 --- a/hub/src/notifications/sessionInfo.ts +++ b/hub/src/notifications/sessionInfo.ts @@ -16,5 +16,6 @@ export function getAgentName(session: Session): string { if (flavor === 'codex') return 'Codex' if (flavor === 'gemini') return 'Gemini' if (flavor === 'opencode') return 'OpenCode' + if (flavor === 'cursor') return 'Cursor' return 'Agent' } diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 84a3b05e5..e81d2728b 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -106,7 +106,7 @@ export class RpcGateway { async spawnSession( machineId: string, directory: string, - agent: 'claude' | 'codex' | 'gemini' | 'opencode' = 'claude', + agent: 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' = 'claude', model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 45e9824e5..a0100b1d2 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -373,7 +373,7 @@ export class SyncEngine { async spawnSession( machineId: string, directory: string, - agent: 'claude' | 'codex' | 'gemini' | 'opencode' = 'claude', + agent: 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' = 'claude', model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', @@ -403,16 +403,30 @@ export class SyncEngine { return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' } } - const flavor = metadata.flavor === 'codex' || metadata.flavor === 'gemini' || metadata.flavor === 'opencode' + const flavor = metadata.flavor === 'codex' + || metadata.flavor === 'gemini' + || metadata.flavor === 'opencode' + || metadata.flavor === 'cursor' ? metadata.flavor : 'claude' - const resumeToken = flavor === 'codex' - ? metadata.codexSessionId - : flavor === 'gemini' - ? metadata.geminiSessionId - : flavor === 'opencode' - ? metadata.opencodeSessionId - : metadata.claudeSessionId + let resumeToken: string | undefined + switch (flavor) { + case 'codex': + resumeToken = metadata.codexSessionId + break + case 'gemini': + resumeToken = metadata.geminiSessionId + break + case 'opencode': + resumeToken = metadata.opencodeSessionId + break + case 'cursor': + resumeToken = metadata.cursorSessionId + break + default: + resumeToken = metadata.claudeSessionId + break + } if (!resumeToken) { return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' } diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 5749d0b8f..093822f8a 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -6,7 +6,7 @@ import { requireMachine } from './guards' const spawnBodySchema = z.object({ directory: z.string().min(1), - agent: z.enum(['claude', 'codex', 'gemini', 'opencode']).optional(), + agent: z.enum(['claude', 'codex', 'gemini', 'opencode', 'cursor']).optional(), model: z.string().optional(), yolo: z.boolean().optional(), sessionType: z.enum(['simple', 'worktree']).optional(), diff --git a/shared/src/modes.ts b/shared/src/modes.ts index a07f317b6..9039c3439 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -10,6 +10,10 @@ export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number] export const OPENCODE_PERMISSION_MODES = ['default', 'yolo'] as const export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number] +// Cursor CLI print mode does not expose a runtime permission mode toggle. +export const CURSOR_PERMISSION_MODES = [] as const +export type CursorPermissionMode = typeof CURSOR_PERMISSION_MODES[number] + export const PERMISSION_MODES = [ 'default', 'acceptEdits', @@ -24,7 +28,7 @@ export type PermissionMode = typeof PERMISSION_MODES[number] export const MODEL_MODES = ['default', 'sonnet', 'opus'] as const export type ModelMode = typeof MODEL_MODES[number] -export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' +export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' export const PERMISSION_MODE_LABELS: Record = { default: 'Default', @@ -78,6 +82,9 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'opencode') { return OPENCODE_PERMISSION_MODES } + if (flavor === 'cursor') { + return CURSOR_PERMISSION_MODES + } return CLAUDE_PERMISSION_MODES } @@ -94,7 +101,7 @@ export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: } export function getModelModesForFlavor(flavor?: string | null): readonly ModelMode[] { - if (flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode') { + if (flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' || flavor === 'cursor') { return [] } return MODEL_MODES diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 37c026f5f..b398396c1 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -50,6 +50,7 @@ export const MetadataSchema = z.object({ codexSessionId: z.string().optional(), geminiSessionId: z.string().optional(), opencodeSessionId: z.string().optional(), + cursorSessionId: z.string().optional(), tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), homeDir: z.string().optional(), diff --git a/shared/src/types.ts b/shared/src/types.ts index b69d96357..b4f81ffea 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -20,6 +20,7 @@ export type { AgentFlavor, ClaudePermissionMode, CodexPermissionMode, + CursorPermissionMode, GeminiPermissionMode, OpencodePermissionMode, ModelMode, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 44a9ee375..3164952b5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -413,7 +413,7 @@ export class ApiClient { async spawnSession( machineId: string, directory: string, - agent?: 'claude' | 'codex' | 'gemini' | 'opencode', + agent?: 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor', model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx index 2e31c95f2..c1726814a 100644 --- a/web/src/components/NewSession/AgentSelector.tsx +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -13,8 +13,8 @@ export function AgentSelector(props: { -
- {(['claude', 'codex', 'gemini', 'opencode'] as const).map((agentType) => ( +
+ {(['claude', 'codex', 'gemini', 'opencode', 'cursor'] as const).map((agentType) => (