diff --git a/packages/happy-app/sources/app/(app)/new/index.tsx b/packages/happy-app/sources/app/(app)/new/index.tsx index 9eec5d1d4e..d26e54d3f2 100644 --- a/packages/happy-app/sources/app/(app)/new/index.tsx +++ b/packages/happy-app/sources/app/(app)/new/index.tsx @@ -105,6 +105,7 @@ const agentIcons = { rig: require('@/assets/images/logo-black.png'), claude: require('@/assets/images/icon-claude.png'), codex: require('@/assets/images/icon-gpt.png'), + cursor: require('@/assets/images/icon-cursor.png'), openclaw: require('@/assets/images/icon-openclaw.png'), gemini: require('@/assets/images/icon-gemini.png'), agy: require('@/assets/images/icon-agy.png'), @@ -116,6 +117,7 @@ type AgentKey = NewSessionAgentType; const ALL_AGENTS: { key: AgentKey; label: string }[] = [ { key: 'claude', label: 'claude code' }, { key: 'codex', label: 'codex' }, + { key: 'cursor', label: 'cursor' }, { key: 'agy', label: 'antigravity' }, { key: 'rig', label: 'happy' }, ]; diff --git a/packages/happy-app/sources/app/(app)/settings/agents.tsx b/packages/happy-app/sources/app/(app)/settings/agents.tsx index fe7db3521d..77e53e5356 100644 --- a/packages/happy-app/sources/app/(app)/settings/agents.tsx +++ b/packages/happy-app/sources/app/(app)/settings/agents.tsx @@ -44,6 +44,7 @@ type FieldConfig = { const agentLabels: Record = { claude: getHarnessName('claude'), codex: getHarnessName('codex'), + cursor: getHarnessName('cursor'), gemini: getHarnessName('gemini'), openclaw: getHarnessName('openclaw'), agy: getHarnessName('agy'), diff --git a/packages/happy-app/sources/assets/images/icon-cursor.png b/packages/happy-app/sources/assets/images/icon-cursor.png new file mode 100644 index 0000000000..ef51d1ea69 Binary files /dev/null and b/packages/happy-app/sources/assets/images/icon-cursor.png differ diff --git a/packages/happy-app/sources/assets/images/icon-cursor@2x.png b/packages/happy-app/sources/assets/images/icon-cursor@2x.png new file mode 100644 index 0000000000..f62d6dc7ae Binary files /dev/null and b/packages/happy-app/sources/assets/images/icon-cursor@2x.png differ diff --git a/packages/happy-app/sources/assets/images/icon-cursor@3x.png b/packages/happy-app/sources/assets/images/icon-cursor@3x.png new file mode 100644 index 0000000000..e4261dec4a Binary files /dev/null and b/packages/happy-app/sources/assets/images/icon-cursor@3x.png differ diff --git a/packages/happy-app/sources/components/AgentInput.tsx b/packages/happy-app/sources/components/AgentInput.tsx index 70d94d69a0..40471d2818 100644 --- a/packages/happy-app/sources/components/AgentInput.tsx +++ b/packages/happy-app/sources/components/AgentInput.tsx @@ -109,7 +109,7 @@ interface AgentInputProps { /** Plan quota windows from agent state, for the week stat and its popup. */ sessionStatusUsageLimits?: UsageLimitsLike | null; onFileViewerPress?: () => void; - agentType?: 'claude' | 'codex' | 'gemini' | 'openclaw' | 'agy'; + agentType?: 'claude' | 'codex' | 'gemini' | 'openclaw' | 'agy' | 'cursor'; onAgentClick?: () => void; machineName?: string | null; onMachineClick?: () => void; diff --git a/packages/happy-app/sources/components/Avatar.tsx b/packages/happy-app/sources/components/Avatar.tsx index 03081d9ba8..aec8277a65 100644 --- a/packages/happy-app/sources/components/Avatar.tsx +++ b/packages/happy-app/sources/components/Avatar.tsx @@ -29,6 +29,7 @@ const harnessIcons: Record = { claude: require('@/assets/images/icon-claude.png'), codex: require('@/assets/images/icon-gpt.png'), agy: require('@/assets/images/icon-agy.png'), + cursor: require('@/assets/images/icon-cursor.png'), rig: require('@/assets/images/logo-black.png'), }; @@ -45,7 +46,9 @@ function harnessBadgeSizes(size: number, harness: AvatarHarnessIcon) { ? Math.round(size * 0.3) : harness === 'claude' ? Math.round(size * 0.34) - : Math.round(size * 0.42); + : harness === 'cursor' + ? Math.round(size * 0.32) + : Math.round(size * 0.42); return { circleSize, iconSize }; } diff --git a/packages/happy-app/sources/components/modelModeOptions.test.ts b/packages/happy-app/sources/components/modelModeOptions.test.ts index 9bc59007cc..15a46da99a 100644 --- a/packages/happy-app/sources/components/modelModeOptions.test.ts +++ b/packages/happy-app/sources/components/modelModeOptions.test.ts @@ -11,6 +11,8 @@ import { getCodexPermissionModes, getClaudeModelModes, getClaudePermissionModes, + getCursorModelModes, + getCursorPermissionModes, getGeminiPermissionModes, getDefaultEffortKey, getDefaultModelKey, @@ -251,6 +253,17 @@ describe('modelModeOptions', () => { expect(keys).not.toContain('sonnet'); }); + it('returns the curated cursor models and default model key', () => { + const models = getAvailableModels('cursor', null, translate); + expect(models).toEqual(getCursorModelModes()); + const keys = models.map((m) => m.key); + expect(keys).toContain('default[]'); + expect(getDefaultModelKey('cursor')).toBe('default[]'); + expect(getDefaultPermissionModeKey('cursor')).toBe('agent'); + expect(getCursorPermissionModes(translate).map((m) => m.key)).toContain('agent'); + expect(getCursorPermissionModes(translate).map((m) => m.key)).toContain('plan'); + }); + it('resolves the first matching preferred key', () => { const options = [ { key: 'a', name: 'A' }, diff --git a/packages/happy-app/sources/components/modelModeOptions.ts b/packages/happy-app/sources/components/modelModeOptions.ts index 959a017743..ea96c28325 100644 --- a/packages/happy-app/sources/components/modelModeOptions.ts +++ b/packages/happy-app/sources/components/modelModeOptions.ts @@ -287,15 +287,44 @@ export function getHardcodedPermissionModes(flavor: AgentFlavor, translate: Tran if (flavor === 'agy') { return getAgyPermissionModes(translate); } + if (flavor === 'cursor') { + return getCursorPermissionModes(translate); + } return getClaudePermissionModes(translate); } +export function getCursorPermissionModes(translate: Translate): PermissionMode[] { + return [ + { key: 'agent', name: 'Agent', description: translate('agentInput.permissionMode.default') }, + { key: 'plan', name: 'Plan', description: translate('agentInput.permissionMode.plan') }, + { key: 'ask', name: 'Ask', description: 'Q&A mode - no edits or command execution' }, + { key: 'bypassPermissions', name: 'Yolo', description: translate('agentInput.permissionMode.bypassPermissions') }, + { key: 'default', name: 'Default', description: translate('agentInput.permissionMode.default') }, + ]; +} + export function getOpenClawModelModes(): ModelMode[] { return [ { key: 'default', name: 'Default model', description: null }, ]; } +export function getCursorModelModes(): ModelMode[] { + return [ + { key: 'default[]', name: 'Auto', description: 'Cursor Router (Default)' }, + { key: 'grok-4.6[effort=high,fast=true]', name: 'Grok 4.6', description: 'xAI' }, + { key: 'composer-2.5[fast=true]', name: 'Composer 2.5', description: 'Cursor' }, + { key: 'claude-opus-5[thinking=true,context=300k,effort=high,fast=false]', name: 'Claude Opus 5', description: 'Anthropic' }, + { key: 'claude-sonnet-5[thinking=true,context=300k,effort=high]', name: 'Claude Sonnet 5', description: 'Anthropic' }, + { key: 'gpt-5.6-sol[context=272k,reasoning=medium,fast=false]', name: 'GPT-5.6 Sol', description: 'OpenAI' }, + { key: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'GPT-5.5', description: 'OpenAI' }, + { key: 'gemini-3.7-flash[effort=high]', name: 'Gemini 3.7 Flash', description: 'Google' }, + { key: 'gemini-3.1-pro[]', name: 'Gemini 3.1 Pro', description: 'Google' }, + { key: 'claude-fable-5[thinking=true,context=300k,effort=high]', name: 'Claude Fable 5', description: 'Anthropic' }, + { key: 'grok-4.5[effort=high,fast=true]', name: 'Grok 4.5', description: 'xAI' }, + ]; +} + // Keys are the exact display names `agy --model` accepts (as printed by `agy models`). export function getAgyModelModes(): ModelMode[] { return [ @@ -326,6 +355,9 @@ export function getHardcodedModelModes(flavor: AgentFlavor, _translate: Translat if (flavor === 'agy') { return getAgyModelModes(); } + if (flavor === 'cursor') { + return getCursorModelModes(); + } return getClaudeModelModes(); } diff --git a/packages/happy-app/sources/sync/agentDefaults.test.ts b/packages/happy-app/sources/sync/agentDefaults.test.ts index cf6cd6b290..4bbf966756 100644 --- a/packages/happy-app/sources/sync/agentDefaults.test.ts +++ b/packages/happy-app/sources/sync/agentDefaults.test.ts @@ -48,5 +48,7 @@ describe('agent defaults', () => { expect(resolveAgentDefaultConfig({}, 'gemini', '1.0.0').permissionMode).toBe('default'); expect(resolveAgentDefaultConfig({}, 'openclaw', '1.0.0').permissionMode).toBe('default'); expect(resolveAgentDefaultConfig({}, 'agy', '1.0.0').permissionMode).toBe('default'); + expect(resolveAgentDefaultConfig({}, 'cursor', '1.0.0').permissionMode).toBe('agent'); + expect(resolveAgentDefaultConfig({}, 'cursor', '1.0.0').modelMode).toBe('default[]'); }); }); \ No newline at end of file diff --git a/packages/happy-app/sources/sync/agentDefaults.ts b/packages/happy-app/sources/sync/agentDefaults.ts index b610599a98..280143456c 100644 --- a/packages/happy-app/sources/sync/agentDefaults.ts +++ b/packages/happy-app/sources/sync/agentDefaults.ts @@ -1,7 +1,7 @@ import * as z from 'zod'; import { compareVersionsWithPrerelease, isWellFormedVersion } from '@/utils/versionUtils'; -export const agentKeys = ['claude', 'codex', 'gemini', 'openclaw', 'agy'] as const; +export const agentKeys = ['claude', 'codex', 'gemini', 'openclaw', 'agy', 'cursor'] as const; export type AgentKey = typeof agentKeys[number]; export const AgentDefaultOverrideSchema = z.object({ @@ -16,6 +16,7 @@ export const AgentDefaultOverridesSchema = z.object({ gemini: AgentDefaultOverrideSchema.optional(), openclaw: AgentDefaultOverrideSchema.optional(), agy: AgentDefaultOverrideSchema.optional(), + cursor: AgentDefaultOverrideSchema.optional(), }).passthrough().default({}); export type AgentDefaultOverride = z.infer; @@ -37,6 +38,7 @@ const codeAgentDefaults: Record = { gemini: { permissionMode: 'default', modelMode: 'gemini-2.5-pro', effortLevel: null }, openclaw: { permissionMode: 'default', modelMode: 'default', effortLevel: null }, agy: { permissionMode: 'default', modelMode: 'Gemini 3.1 Pro (High)', effortLevel: null }, + cursor: { permissionMode: 'agent', modelMode: 'default[]', effortLevel: null }, }; // `auto` first shipped in happy-cli 1.2.1-beta.2, for Claude and Codex alike. @@ -60,7 +62,7 @@ function resolveCodeDefaultPermissionMode( } export function normalizeAgentKey(flavor: string | null | undefined): AgentKey { - if (flavor === 'codex' || flavor === 'gemini' || flavor === 'openclaw' || flavor === 'agy') { + if (flavor === 'codex' || flavor === 'gemini' || flavor === 'openclaw' || flavor === 'agy' || flavor === 'cursor') { return flavor; } return 'claude'; diff --git a/packages/happy-app/sources/sync/ops.ts b/packages/happy-app/sources/sync/ops.ts index f206da6d03..21f45eae26 100644 --- a/packages/happy-app/sources/sync/ops.ts +++ b/packages/happy-app/sources/sync/ops.ts @@ -172,7 +172,7 @@ export interface SpawnSessionOptions { directory: string; approvedNewDirectoryCreation?: boolean; token?: string; - agent?: 'codex' | 'claude' | 'gemini' | 'openclaw' | 'agy' | 'rig'; + agent?: 'codex' | 'claude' | 'gemini' | 'openclaw' | 'agy' | 'cursor' | 'rig'; permissionMode?: string; modelMode?: string; effortLevel?: string; @@ -274,7 +274,7 @@ export async function machineSpawnNewSession(options: SpawnSessionOptions): Prom directory: string approvedNewDirectoryCreation?: boolean, token?: string, - agent?: 'codex' | 'claude' | 'gemini' | 'openclaw' | 'agy' | 'rig', + agent?: 'codex' | 'claude' | 'gemini' | 'openclaw' | 'agy' | 'cursor' | 'rig', permissionMode?: string, modelMode?: string, effortLevel?: string, diff --git a/packages/happy-app/sources/sync/persistence.ts b/packages/happy-app/sources/sync/persistence.ts index ca7f33d1a7..6c4ad8a1a0 100644 --- a/packages/happy-app/sources/sync/persistence.ts +++ b/packages/happy-app/sources/sync/persistence.ts @@ -12,7 +12,7 @@ const VOICE_SOFT_PAYWALL_SHOWN_KEY = 'voice-soft-paywall-shown'; const VOICE_ONBOARDING_PROMPT_LOAD_COUNT_KEY = 'voice-onboarding-prompt-load-count'; const VOICE_MESSAGE_COUNT_KEY = 'voice-message-count'; -export type NewSessionAgentType = 'claude' | 'codex' | 'gemini' | 'openclaw' | 'agy' | 'rig'; +export type NewSessionAgentType = 'claude' | 'codex' | 'gemini' | 'openclaw' | 'agy' | 'cursor' | 'rig'; export type NewSessionSessionType = 'simple' | 'worktree'; export interface NewSessionDraft { @@ -162,7 +162,7 @@ export function loadNewSessionDraft(): NewSessionDraft | null { const input = typeof parsed.input === 'string' ? parsed.input : ''; const selectedMachineId = typeof parsed.selectedMachineId === 'string' ? parsed.selectedMachineId : null; const selectedPath = typeof parsed.selectedPath === 'string' ? parsed.selectedPath : null; - const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini' || parsed.agentType === 'openclaw' || parsed.agentType === 'agy' || parsed.agentType === 'rig' + const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini' || parsed.agentType === 'openclaw' || parsed.agentType === 'agy' || parsed.agentType === 'cursor' || parsed.agentType === 'rig' ? parsed.agentType : 'claude'; const permissionMode: PermissionModeKey | null = typeof parsed.permissionMode === 'string' diff --git a/packages/happy-app/sources/sync/storageTypes.ts b/packages/happy-app/sources/sync/storageTypes.ts index e820ef218a..3e8bd70a70 100644 --- a/packages/happy-app/sources/sync/storageTypes.ts +++ b/packages/happy-app/sources/sync/storageTypes.ts @@ -453,6 +453,7 @@ export const MachineMetadataSchema = z.object({ gemini: z.boolean(), openclaw: z.boolean(), agy: z.boolean().optional(), // optional: older CLIs don't report agy + cursor: z.boolean().optional(), rig: z.boolean().optional(), // Rig runs its own Happy-connected daemon detectedAt: z.number(), }).optional(), diff --git a/packages/happy-app/sources/utils/avatarHarness.test.ts b/packages/happy-app/sources/utils/avatarHarness.test.ts index ce5ecb3936..51476ded30 100644 --- a/packages/happy-app/sources/utils/avatarHarness.test.ts +++ b/packages/happy-app/sources/utils/avatarHarness.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest'; import { resolveAvatarHarness } from './avatarHarness'; describe('resolveAvatarHarness', () => { - it('keeps the existing Claude, Codex, and Antigravity mappings', () => { + it('keeps the existing Claude, Codex, Cursor, and Antigravity mappings', () => { expect(resolveAvatarHarness('claude')).toBe('claude'); expect(resolveAvatarHarness('codex')).toBe('codex'); + expect(resolveAvatarHarness('cursor')).toBe('cursor'); expect(resolveAvatarHarness('agy')).toBe('agy'); }); diff --git a/packages/happy-app/sources/utils/avatarHarness.ts b/packages/happy-app/sources/utils/avatarHarness.ts index 4dbd58480d..b1317f894d 100644 --- a/packages/happy-app/sources/utils/avatarHarness.ts +++ b/packages/happy-app/sources/utils/avatarHarness.ts @@ -6,12 +6,13 @@ * metadata. Only the active harnesses have badges; retired and unknown * flavors stay badge-free. */ -export type AvatarHarnessIcon = 'claude' | 'codex' | 'agy' | 'rig'; +export type AvatarHarnessIcon = 'claude' | 'codex' | 'agy' | 'cursor' | 'rig'; const ACTIVE_HARNESS_ICONS: ReadonlySet = new Set([ 'claude', 'codex', 'agy', + 'cursor', ]); export function resolveAvatarHarness( diff --git a/packages/happy-app/sources/utils/harnessCatalog.test.ts b/packages/happy-app/sources/utils/harnessCatalog.test.ts index 586c3298af..e4cbbf4094 100644 --- a/packages/happy-app/sources/utils/harnessCatalog.test.ts +++ b/packages/happy-app/sources/utils/harnessCatalog.test.ts @@ -3,8 +3,9 @@ import { describe, expect, it } from 'vitest'; import { HARNESS_NAMES, isRetiredHarness, listAvailableHarnesses } from './harnessCatalog'; describe('harness catalog', () => { - it('names Happy and Antigravity by product, not by CLI id', () => { + it('names Happy, Cursor, and Antigravity by product, not by CLI id', () => { expect(HARNESS_NAMES.rig).toBe('Happy'); + expect(HARNESS_NAMES.cursor).toBe('Cursor'); expect(HARNESS_NAMES.agy).toBe('Antigravity'); }); @@ -12,21 +13,23 @@ describe('harness catalog', () => { expect(isRetiredHarness('gemini')).toBe(true); expect(isRetiredHarness('openclaw')).toBe(true); expect(isRetiredHarness('claude')).toBe(false); + expect(isRetiredHarness('cursor')).toBe(false); // Antigravity is what Gemini's own error message redirects people to. expect(isRetiredHarness('agy')).toBe(false); }); it('lists only installed harnesses, in pick order', () => { const harnesses = listAvailableHarnesses({ - availability: { claude: true, codex: true, agy: true }, + availability: { claude: true, codex: true, cursor: true, agy: true }, happyAgentAvailable: true, selected: 'claude', }); - expect(harnesses.map((harness) => harness.key)).toEqual(['claude', 'codex', 'agy', 'rig']); + expect(harnesses.map((harness) => harness.key)).toEqual(['claude', 'codex', 'cursor', 'agy', 'rig']); expect(harnesses.map((harness) => harness.name)).toEqual([ 'Claude Code', 'Codex', + 'Cursor', 'Antigravity', 'Happy', ]); @@ -90,7 +93,7 @@ describe('harness catalog', () => { availability: null, happyAgentAvailable: false, selected: 'agy', - }).map((harness) => harness.key)).toEqual(['claude', 'codex']); + }).map((harness) => harness.key)).toEqual(['claude', 'codex', 'cursor']); }); it('falls back to the whole catalog when a machine reports no capabilities', () => { @@ -98,12 +101,12 @@ describe('harness catalog', () => { availability: null, happyAgentAvailable: false, selected: null, - }).map((harness) => harness.key)).toEqual(['claude', 'codex']); + }).map((harness) => harness.key)).toEqual(['claude', 'codex', 'cursor']); expect(listAvailableHarnesses({ availability: {}, happyAgentAvailable: false, selected: null, - }).map((harness) => harness.key)).toEqual(['claude', 'codex', 'rig']); + }).map((harness) => harness.key)).toEqual(['claude', 'codex', 'cursor', 'rig']); }); }); diff --git a/packages/happy-app/sources/utils/harnessCatalog.ts b/packages/happy-app/sources/utils/harnessCatalog.ts index 18f4d21025..7492149c84 100644 --- a/packages/happy-app/sources/utils/harnessCatalog.ts +++ b/packages/happy-app/sources/utils/harnessCatalog.ts @@ -8,6 +8,7 @@ import type { NewSessionAgentType } from '@/sync/persistence'; export const HARNESS_NAMES: Record = { claude: 'Claude Code', codex: 'Codex', + cursor: 'Cursor', rig: 'Happy', agy: 'Antigravity', gemini: 'Gemini', @@ -32,6 +33,7 @@ export const RETIRED_HARNESSES: ReadonlySet = new Set([ export const HARNESS_ORDER: readonly NewSessionAgentType[] = [ 'claude', 'codex', + 'cursor', 'agy', 'rig', ]; diff --git a/packages/happy-cli/README.md b/packages/happy-cli/README.md index a511f8f374..f4956e16cc 100644 --- a/packages/happy-cli/README.md +++ b/packages/happy-cli/README.md @@ -32,6 +32,7 @@ This will: ``` happy codex +happy cursor # Cursor Agent CLI (via ACP) happy agy # Antigravity CLI (Gemini's successor) happy gemini # deprecated — use `happy agy` happy openclaw diff --git a/packages/happy-cli/src/agent/acp/acpAgentConfig.test.ts b/packages/happy-cli/src/agent/acp/acpAgentConfig.test.ts index ef296627a2..5f32c67103 100644 --- a/packages/happy-cli/src/agent/acp/acpAgentConfig.test.ts +++ b/packages/happy-cli/src/agent/acp/acpAgentConfig.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest'; import { KNOWN_ACP_AGENTS, resolveAcpAgentConfig } from './acpAgentConfig'; describe('KNOWN_ACP_AGENTS', () => { - it('defines built-in Gemini and OpenCode command mappings', () => { + it('defines built-in Gemini, Cursor, and OpenCode command mappings', () => { expect(KNOWN_ACP_AGENTS).toEqual({ gemini: { command: 'gemini', args: ['--experimental-acp'] }, + cursor: { command: 'agent', args: ['acp'] }, opencode: { command: 'opencode', args: ['acp'] }, }); }); @@ -17,6 +18,11 @@ describe('resolveAcpAgentConfig', () => { command: 'gemini', args: ['--experimental-acp'], }); + expect(resolveAcpAgentConfig(['cursor'])).toEqual({ + agentName: 'cursor', + command: 'agent', + args: ['acp'], + }); }); it('appends extra CLI args for known agent aliases', () => { diff --git a/packages/happy-cli/src/agent/acp/acpAgentConfig.ts b/packages/happy-cli/src/agent/acp/acpAgentConfig.ts index 1c3ee2cbc0..9d9475aaef 100644 --- a/packages/happy-cli/src/agent/acp/acpAgentConfig.ts +++ b/packages/happy-cli/src/agent/acp/acpAgentConfig.ts @@ -5,6 +5,7 @@ export type AcpAgentConfig = { export const KNOWN_ACP_AGENTS: Record = { gemini: { command: 'gemini', args: ['--experimental-acp'] }, + cursor: { command: 'agent', args: ['acp'] }, opencode: { command: 'opencode', args: ['acp'] }, }; diff --git a/packages/happy-cli/src/agent/acp/runAcp.ts b/packages/happy-cli/src/agent/acp/runAcp.ts index 9ba0b0c928..ac147e73f1 100644 --- a/packages/happy-cli/src/agent/acp/runAcp.ts +++ b/packages/happy-cli/src/agent/acp/runAcp.ts @@ -436,13 +436,16 @@ type PendingTurn = { timeout: NodeJS.Timeout; }; -function resolveSessionFlavor(agentName: string): 'gemini' | 'opencode' | 'acp' { +function resolveSessionFlavor(agentName: string): 'gemini' | 'opencode' | 'cursor' | 'acp' { if (agentName === 'gemini') { return 'gemini'; } if (agentName === 'opencode') { return 'opencode'; } + if (agentName === 'cursor') { + return 'cursor'; + } return 'acp'; } diff --git a/packages/happy-cli/src/agent/adapters/MessageAdapter.ts b/packages/happy-cli/src/agent/adapters/MessageAdapter.ts index 17928c2e86..fa5f2f4571 100644 --- a/packages/happy-cli/src/agent/adapters/MessageAdapter.ts +++ b/packages/happy-cli/src/agent/adapters/MessageAdapter.ts @@ -280,5 +280,6 @@ export const adapters = { gemini: new MessageAdapter({ agentType: 'gemini' }), codex: new MessageAdapter({ agentType: 'codex' }), claude: new MessageAdapter({ agentType: 'claude' }), + cursor: new MessageAdapter({ agentType: 'cursor' }), opencode: new MessageAdapter({ agentType: 'opencode' }), } as const; diff --git a/packages/happy-cli/src/agent/adapters/MobileMessageFormat.ts b/packages/happy-cli/src/agent/adapters/MobileMessageFormat.ts index b24ee04d4d..68b6f86d0a 100644 --- a/packages/happy-cli/src/agent/adapters/MobileMessageFormat.ts +++ b/packages/happy-cli/src/agent/adapters/MobileMessageFormat.ts @@ -11,7 +11,7 @@ /** * Supported agent types for the mobile app */ -export type MobileAgentType = 'gemini' | 'codex' | 'claude' | 'opencode'; +export type MobileAgentType = 'gemini' | 'codex' | 'claude' | 'cursor' | 'opencode'; /** * Message roles for the mobile app diff --git a/packages/happy-cli/src/agent/core/AgentBackend.ts b/packages/happy-cli/src/agent/core/AgentBackend.ts index cec9b73f44..4799ee5e58 100644 --- a/packages/happy-cli/src/agent/core/AgentBackend.ts +++ b/packages/happy-cli/src/agent/core/AgentBackend.ts @@ -48,7 +48,7 @@ export interface McpServerConfig { export type AgentTransport = 'native-claude' | 'mcp-codex' | 'acp'; /** Agent identifier */ -export type AgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'agy' | 'claude-acp' | 'codex-acp'; +export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'agy' | 'claude-acp' | 'codex-acp'; /** * Configuration for creating an agent backend diff --git a/packages/happy-cli/src/api/apiMachine.ts b/packages/happy-cli/src/api/apiMachine.ts index 91af7861bc..c4d13ff83f 100644 --- a/packages/happy-cli/src/api/apiMachine.ts +++ b/packages/happy-cli/src/api/apiMachine.ts @@ -531,7 +531,8 @@ export class ApiMachineClient { || prev.codex !== newAvailability.codex || prev.gemini !== newAvailability.gemini || prev.openclaw !== newAvailability.openclaw - || prev.agy !== newAvailability.agy; + || prev.agy !== newAvailability.agy + || prev.cursor !== newAvailability.cursor; const resumeSupportChanged = !prevResume || prevResume.rpcAvailable !== newResumeSupport.rpcAvailable || prevResume.happyAgentAuthenticated !== newResumeSupport.happyAgentAuthenticated; diff --git a/packages/happy-cli/src/api/apiSession.ts b/packages/happy-cli/src/api/apiSession.ts index d5394bc35d..26b6bb9a66 100644 --- a/packages/happy-cli/src/api/apiSession.ts +++ b/packages/happy-cli/src/api/apiSession.ts @@ -47,7 +47,7 @@ export type ACPMessageData = // Usage/metrics | { type: 'token_count';[key: string]: unknown }; -export type ACPProvider = 'gemini' | 'codex' | 'claude' | 'opencode'; +export type ACPProvider = 'gemini' | 'codex' | 'claude' | 'cursor' | 'opencode'; type V3SessionMessage = { id: string; @@ -823,7 +823,7 @@ export class ApiSessionClient extends EventEmitter { * @param provider - The agent provider sending the message (e.g., 'gemini', 'codex', 'claude') * @param body - The message payload (type: 'message' | 'reasoning' | 'tool-call' | 'tool-result') */ - sendAgentMessage(provider: 'gemini' | 'codex' | 'claude' | 'opencode' | 'openclaw', body: ACPMessageData) { + sendAgentMessage(provider: 'gemini' | 'codex' | 'claude' | 'cursor' | 'opencode' | 'openclaw', body: ACPMessageData) { let content = { role: 'agent', content: { diff --git a/packages/happy-cli/src/api/types.ts b/packages/happy-cli/src/api/types.ts index d1ae6169ff..f990c5a9ec 100644 --- a/packages/happy-cli/src/api/types.ts +++ b/packages/happy-cli/src/api/types.ts @@ -148,6 +148,7 @@ export const MachineMetadataSchema = z.object({ // Optional so metadata written by a CLI predating agy detection still // matches this shape. detectCLIAvailability always reports it. agy: z.boolean().optional(), + cursor: z.boolean().optional(), detectedAt: z.number(), }).optional(), resumeSupport: z.object({ diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index 92f167bd73..1192fdaefa 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -421,8 +421,16 @@ export async function startDaemon(): Promise { // Construct command for the CLI const cliPath = join(projectPath(), 'dist', 'index.mjs'); - // Determine agent command - support claude, codex, gemini, openclaw, and agy - const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'codex' ? 'codex' : (options.agent === 'openclaw' ? 'openclaw' : (options.agent === 'agy' ? 'agy' : 'claude'))); + // Determine agent command - support claude, codex, gemini, openclaw, agy, and cursor + const agent = options.agent === 'gemini' + ? 'gemini' + : (options.agent === 'codex' + ? 'codex' + : (options.agent === 'openclaw' + ? 'openclaw' + : (options.agent === 'agy' + ? 'agy' + : (options.agent === 'cursor' ? 'cursor' : 'claude')))); const resumeId = agent === 'claude' ? options.resumeClaudeSessionId : (agent === 'codex' ? options.resumeCodexThreadId : undefined); @@ -536,6 +544,9 @@ export async function startDaemon(): Promise { case 'agy': agentCommand = 'agy'; break; + case 'cursor': + agentCommand = 'cursor'; + break; default: return { type: 'error', diff --git a/packages/happy-cli/src/index.ts b/packages/happy-cli/src/index.ts index 114a0de18f..b80a1df974 100644 --- a/packages/happy-cli/src/index.ts +++ b/packages/happy-cli/src/index.ts @@ -473,6 +473,39 @@ Conversation history is preserved on the server, but in-flight tool calls are in process.exit(1) } return; + } else if (subcommand === 'cursor') { + try { + const { runAcp } = await import('@/agent/acp/runAcp'); + + let startedBy: 'daemon' | 'terminal' | undefined = undefined; + let verbose = false; + for (let i = 1; i < args.length; i++) { + if (args[i] === '--started-by') { + startedBy = args[++i] as 'daemon' | 'terminal'; + } else if (args[i] === '--verbose') { + verbose = true; + } + } + + const { credentials } = await authAndSetupMachineIfNeeded(); + await ensureDaemonRunning(); + + await runAcp({ + credentials, + agentName: 'cursor', + command: 'agent', + args: ['acp'], + startedBy, + verbose, + }); + } 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: "happy logout" is deprecated. Use "happy auth logout" instead.\n')); @@ -718,6 +751,7 @@ ${chalk.bold('Usage:')} happy codex Start Codex mode happy gemini Start Gemini mode (ACP) [deprecated — use agy] happy agy Start agy (Antigravity CLI) mode + happy cursor Start Cursor Agent CLI mode happy acp Start a generic ACP-compatible agent happy connect Connect AI vendor API keys happy sandbox Configure and manage OS-level sandboxing diff --git a/packages/happy-cli/src/modules/common/registerCommonHandlers.ts b/packages/happy-cli/src/modules/common/registerCommonHandlers.ts index 1921408d79..7826d2cc0d 100644 --- a/packages/happy-cli/src/modules/common/registerCommonHandlers.ts +++ b/packages/happy-cli/src/modules/common/registerCommonHandlers.ts @@ -120,7 +120,7 @@ export interface SpawnSessionOptions { directory: string; sessionId?: string; approvedNewDirectoryCreation?: boolean; - agent?: 'claude' | 'codex' | 'gemini' | 'openclaw' | 'agy'; + agent?: 'claude' | 'codex' | 'gemini' | 'openclaw' | 'agy' | 'cursor'; permissionMode?: string; modelMode?: string; effortLevel?: string; diff --git a/packages/happy-cli/src/utils/createSessionMetadata.ts b/packages/happy-cli/src/utils/createSessionMetadata.ts index aa6fb9e01b..f5db82005b 100644 --- a/packages/happy-cli/src/utils/createSessionMetadata.ts +++ b/packages/happy-cli/src/utils/createSessionMetadata.ts @@ -20,7 +20,7 @@ import packageJson from '../../package.json'; /** * Backend flavor identifier for session metadata. */ -export type BackendFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'agy' | 'acp'; +export type BackendFlavor = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'agy' | 'acp'; /** * Options for creating session metadata. diff --git a/packages/happy-cli/src/utils/detectCLI.test.ts b/packages/happy-cli/src/utils/detectCLI.test.ts index e7199ec28c..d65c68c2d2 100644 --- a/packages/happy-cli/src/utils/detectCLI.test.ts +++ b/packages/happy-cli/src/utils/detectCLI.test.ts @@ -41,4 +41,15 @@ describe('CLI availability detection', () => { expect(detectCLIAvailability().agy).toBe(true); }); + + it('detects Cursor CLI when agent binary is in PATH', () => { + expect(detectCLIAvailability().cursor).toBe(false); + + mockedExecSync.mockImplementation((cmd: string) => { + if (cmd.includes('agent')) return Buffer.from('/usr/local/bin/agent'); + throw new Error('not installed'); + }); + + expect(detectCLIAvailability().cursor).toBe(true); + }); }); \ No newline at end of file diff --git a/packages/happy-cli/src/utils/detectCLI.ts b/packages/happy-cli/src/utils/detectCLI.ts index 8c5ee7bacf..62a75b7826 100644 --- a/packages/happy-cli/src/utils/detectCLI.ts +++ b/packages/happy-cli/src/utils/detectCLI.ts @@ -8,6 +8,7 @@ export interface CLIAvailability { claude: boolean; codex: boolean; gemini: boolean; + cursor: boolean; openclaw: boolean; agy: boolean; detectedAt: number; @@ -39,6 +40,7 @@ function detectPosix(): CLIAvailability { const claude = commandExists('claude'); const codex = commandExists('codex'); const gemini = commandExists('gemini'); + const cursor = commandExists('agent') || commandExists('cursor'); const agy = findAgyBin() !== undefined; // OpenClaw: check command, config file, or env var @@ -47,7 +49,7 @@ function detectPosix(): CLIAvailability { const openclawEnv = !!process.env.OPENCLAW_GATEWAY_URL; const openclaw = openclawCommand || openclawConfig || openclawEnv; - return { claude, codex, gemini, openclaw, agy, detectedAt: Date.now() }; + return { claude, codex, gemini, cursor, openclaw, agy, detectedAt: Date.now() }; } function detectWindows(): CLIAvailability { @@ -63,6 +65,7 @@ function detectWindows(): CLIAvailability { const claude = checkCommand('claude'); const codex = checkCommand('codex'); const gemini = checkCommand('gemini'); + const cursor = checkCommand('agent') || checkCommand('cursor'); const agy = findAgyBin() !== undefined; // OpenClaw: check command, config file, or env var @@ -71,5 +74,5 @@ function detectWindows(): CLIAvailability { const openclawEnv = !!process.env.OPENCLAW_GATEWAY_URL; const openclaw = openclawCommand || openclawConfig || openclawEnv; - return { claude, codex, gemini, openclaw, agy, detectedAt: Date.now() }; + return { claude, codex, gemini, cursor, openclaw, agy, detectedAt: Date.now() }; }