From 81f35ad6aa98eabdbc6ebfa2cfed928c7fa32ae7 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 27 Dec 2025 23:50:17 +0100 Subject: [PATCH 001/120] chore: Add Cursor CLI integration plan and phases - Introduced a comprehensive integration plan for the Cursor CLI, including detailed phases for implementation. - Created initial markdown files for each phase, outlining objectives, tasks, and verification steps. - Established a global prompt template for starting new sessions with the Cursor CLI. - Added necessary types and configuration for Cursor models and their integration into the AutoMaker architecture. - Implemented routing logic to ensure proper model handling between Cursor and Claude providers. - Developed UI components for setup and settings management related to Cursor integration. - Included extensive testing and validation plans to ensure robust functionality across all scenarios. --- plan/cursor-cli-integration/PHASE_PROMPT.md | 89 ++ plan/cursor-cli-integration/README.md | 395 ++++++++ .../phases/phase-0-analysis.md | 201 +++++ .../phases/phase-1-types.md | 443 +++++++++ .../phases/phase-10-testing.md | 649 +++++++++++++ .../phases/phase-2-provider.md | 850 ++++++++++++++++++ .../phases/phase-3-factory.md | 229 +++++ .../phases/phase-4-routes.md | 348 +++++++ .../phases/phase-5-log-parser.md | 374 ++++++++ .../phases/phase-6-setup-wizard.md | 457 ++++++++++ .../phases/phase-7-settings.md | 556 ++++++++++++ .../phases/phase-8-profiles.md | 590 ++++++++++++ .../phases/phase-9-execution.md | 451 ++++++++++ 13 files changed, 5632 insertions(+) create mode 100644 plan/cursor-cli-integration/PHASE_PROMPT.md create mode 100644 plan/cursor-cli-integration/README.md create mode 100644 plan/cursor-cli-integration/phases/phase-0-analysis.md create mode 100644 plan/cursor-cli-integration/phases/phase-1-types.md create mode 100644 plan/cursor-cli-integration/phases/phase-10-testing.md create mode 100644 plan/cursor-cli-integration/phases/phase-2-provider.md create mode 100644 plan/cursor-cli-integration/phases/phase-3-factory.md create mode 100644 plan/cursor-cli-integration/phases/phase-4-routes.md create mode 100644 plan/cursor-cli-integration/phases/phase-5-log-parser.md create mode 100644 plan/cursor-cli-integration/phases/phase-6-setup-wizard.md create mode 100644 plan/cursor-cli-integration/phases/phase-7-settings.md create mode 100644 plan/cursor-cli-integration/phases/phase-8-profiles.md create mode 100644 plan/cursor-cli-integration/phases/phase-9-execution.md diff --git a/plan/cursor-cli-integration/PHASE_PROMPT.md b/plan/cursor-cli-integration/PHASE_PROMPT.md new file mode 100644 index 00000000..9dd01b1a --- /dev/null +++ b/plan/cursor-cli-integration/PHASE_PROMPT.md @@ -0,0 +1,89 @@ +# Global Prompt for Cursor CLI Integration Phases + +Copy the prompt below when starting a new Claude session for any phase. + +--- + +## Prompt Template + +``` +I'm implementing the Cursor CLI integration for AutoMaker. + +## Context +- Plan location: `P:\automaker\plan\cursor-cli-integration\` +- Read the README.md first for architecture overview and design decisions +- Then read the specific phase file I mention below + +## Phase to Implement +[REPLACE THIS LINE WITH: Phase X - phases/phase-X-*.md] + +## Critical Requirements + +### 1. Use @automaker/* Packages (see docs\llm-shared-packages.md) + +**From @automaker/types:** +- Reuse `InstallationStatus` (don't create new status types) +- Use `ModelProvider` type ('claude' | 'cursor') +- Use `CursorModelId`, `CURSOR_MODEL_MAP` for Cursor models + +**From @automaker/utils:** +import { createLogger, isAbortError, mkdirSafe, existsSafe } from '@automaker/utils'; + +**From @automaker/platform:** +import { spawnJSONLProcess, getAutomakerDir } from '@automaker/platform'; + +### 2. UI Components (apps/ui) +All UI must use components from `@/components/ui/*`: +- Card, CardHeader, CardTitle, CardContent, CardFooter +- Button, Badge, Label, Input, Textarea +- Select, SelectContent, SelectItem, SelectTrigger, SelectValue +- Checkbox, Alert, AlertDescription +- Tabs, TabsList, TabsTrigger, TabsContent + +Icons from `lucide-react`: Terminal (Cursor), Bot (Claude), CheckCircle2, XCircle, Loader2, RefreshCw, ExternalLink + +### 3. API Requests (apps/ui) +Use HttpApiClient, NOT raw fetch(): +import { api } from '@/lib/http-api-client'; +const result = await api.setup.getCursorStatus(); + +### 4. Do NOT Extend @automaker/model-resolver +Cursor models use `CURSOR_MODEL_MAP` in @automaker/types instead. + +## Instructions +1. Read the phase file completely +2. Implement each task in order +3. Run the verification steps before marking complete +4. Update the phase status in the markdown file when done +``` + +--- + +## Quick Reference: Phase Order + +| Phase | File | Description | +| ----- | -------------------------------- | ------------------------------- | +| 0 | `phases/phase-0-analysis.md` | Analysis & Documentation | +| 1 | `phases/phase-1-types.md` | Core Types & Configuration | +| 2 | `phases/phase-2-provider.md` | Cursor Provider Implementation | +| 3 | `phases/phase-3-factory.md` | Provider Factory Integration | +| 4 | `phases/phase-4-routes.md` | Setup Routes & Status Endpoints | +| 5 | `phases/phase-5-log-parser.md` | Log Parser Integration | +| 6 | `phases/phase-6-setup-wizard.md` | UI Setup Wizard | +| 7 | `phases/phase-7-settings.md` | Settings View Provider Tabs | +| 8 | `phases/phase-8-profiles.md` | AI Profiles Integration | +| 9 | `phases/phase-9-execution.md` | Task Execution Integration | +| 10 | `phases/phase-10-testing.md` | Testing & Validation | + +## Dependencies + +``` +Phase 0 → Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 6 + ↘ ↘ Phase 7 + Phase 5 → Phase 8 → Phase 9 → Phase 10 +``` + +Phases 4-7 can run in parallel after Phase 3. +Phase 8 depends on Phase 1 and Phase 7. +Phase 9 depends on Phase 8. +Phase 10 is final integration testing. diff --git a/plan/cursor-cli-integration/README.md b/plan/cursor-cli-integration/README.md new file mode 100644 index 00000000..0ca19185 --- /dev/null +++ b/plan/cursor-cli-integration/README.md @@ -0,0 +1,395 @@ +# Cursor CLI Integration Plan + +> Integration of Cursor Agent CLI (`cursor-agent`) as an alternative AI provider in AutoMaker + +## Status Overview + +| Phase | Name | Status | Test Status | +| ----- | ------------------------------------------------------------ | --------- | ----------- | +| 0 | [Analysis & Documentation](phases/phase-0-analysis.md) | `pending` | - | +| 1 | [Core Types & Configuration](phases/phase-1-types.md) | `pending` | - | +| 2 | [Cursor Provider Implementation](phases/phase-2-provider.md) | `pending` | - | +| 3 | [Provider Factory Integration](phases/phase-3-factory.md) | `pending` | - | +| 4 | [Setup Routes & Status Endpoints](phases/phase-4-routes.md) | `pending` | - | +| 5 | [Log Parser Integration](phases/phase-5-log-parser.md) | `pending` | - | +| 6 | [UI Setup Wizard](phases/phase-6-setup-wizard.md) | `pending` | - | +| 7 | [Settings View Provider Tabs](phases/phase-7-settings.md) | `pending` | - | +| 8 | [AI Profiles Integration](phases/phase-8-profiles.md) | `pending` | - | +| 9 | [Task Execution Integration](phases/phase-9-execution.md) | `pending` | - | +| 10 | [Testing & Validation](phases/phase-10-testing.md) | `pending` | - | + +**Status Legend:** `pending` | `in_progress` | `completed` | `blocked` + +--- + +## Quick Links + +- **Reference PR**: [#279](https://github.com/AutoMaker-Org/automaker/pull/279) (incomplete, patterns only) +- **Cursor CLI Docs**: [cursor.com/docs/cli](https://cursor.com/docs/cli) +- **Output Format Spec**: [Output Format Reference](https://cursor.com/docs/cli/reference/output-format) + +--- + +## Architecture Summary + +### Existing Provider Pattern + +AutoMaker uses an extensible provider architecture: + +``` +BaseProvider (abstract) + ├── getName(): string + ├── executeQuery(options): AsyncGenerator + ├── detectInstallation(): Promise + └── getAvailableModels(): ModelDefinition[] + +ClaudeProvider extends BaseProvider + └── Uses @anthropic-ai/claude-agent-sdk + +ProviderFactory + └── getProviderForModel(modelId) → routes to correct provider +``` + +### Target Architecture + +``` +BaseProvider + ├── ClaudeProvider (existing) + └── CursorProvider (new) + ├── Spawns cursor-agent CLI process + ├── Uses --output-format stream-json + └── Normalizes events to ProviderMessage format +``` + +--- + +## Key Requirements + +1. **Model Selection**: Explicit model selection via config (not just "auto" mode) +2. **Authentication**: Browser login (`cursor-agent login`) as primary method +3. **Setup Wizard**: Optional CLI status check (skippable, configure later) +4. **AI Profiles**: Separate Cursor profiles with embedded thinking mode (e.g., `claude-sonnet-4-thinking`) +5. **Settings View**: Separate tabs/sections per provider +6. **Streaming**: Full `stream-json` parsing with tool call events for log-viewer +7. **Error Handling**: Detailed error mapping with recovery suggestions + +--- + +## Cursor CLI Reference + +### Installation + +```bash +curl https://cursor.com/install -fsS | bash +``` + +### Authentication Methods + +1. **Browser Login** (Recommended): `cursor-agent login` +2. **API Key**: `CURSOR_API_KEY` environment variable + +### CLI Flags for Integration + +```bash +cursor-agent \ + -p "prompt" # Print/non-interactive mode + --model gpt-4o # Explicit model selection + --output-format stream-json # NDJSON streaming + --stream-partial-output # Real-time character streaming + --force # Allow file modifications +``` + +### Available Models (from Cursor docs) + +| Model ID | Description | Thinking | +| -------------------------- | -------------------------- | -------- | +| `auto` | Auto-select best model | - | +| `claude-sonnet-4` | Claude Sonnet 4 | No | +| `claude-sonnet-4-thinking` | Claude Sonnet 4 + Thinking | Yes | +| `gpt-4o` | GPT-4o | No | +| `gpt-4o-mini` | GPT-4o Mini | No | +| `gemini-2.5-pro` | Gemini 2.5 Pro | No | +| `o3-mini` | O3 Mini (reasoning) | Built-in | + +--- + +## Stream JSON Event Types + +### System Init + +```json +{ + "type": "system", + "subtype": "init", + "apiKeySource": "login", + "cwd": "/path", + "session_id": "uuid", + "model": "Claude 4 Sonnet", + "permissionMode": "default" +} +``` + +### User Message + +```json +{ + "type": "user", + "message": { "role": "user", "content": [{ "type": "text", "text": "prompt" }] }, + "session_id": "uuid" +} +``` + +### Assistant Message + +```json +{ + "type": "assistant", + "message": { "role": "assistant", "content": [{ "type": "text", "text": "response" }] }, + "session_id": "uuid" +} +``` + +### Tool Call Started + +```json +{ + "type": "tool_call", + "subtype": "started", + "call_id": "id", + "tool_call": { "readToolCall": { "args": { "path": "file.txt" } } }, + "session_id": "uuid" +} +``` + +### Tool Call Completed + +```json +{ + "type": "tool_call", + "subtype": "completed", + "call_id": "id", + "tool_call": { + "readToolCall": { + "args": { "path": "file.txt" }, + "result": { "success": { "content": "...", "totalLines": 54 } } + } + }, + "session_id": "uuid" +} +``` + +### Result (Final) + +```json +{ + "type": "result", + "subtype": "success", + "duration_ms": 1234, + "is_error": false, + "result": "full text", + "session_id": "uuid" +} +``` + +--- + +## File Map + +### Files to Create + +| File | Phase | Description | +| ------------------------------------------------------------------------------ | ----- | ---------------------------- | +| `libs/types/src/cursor-models.ts` | 1 | Cursor model definitions | +| `apps/server/src/providers/cursor-provider.ts` | 2 | Main provider implementation | +| `apps/server/src/providers/cursor-config-manager.ts` | 2 | Config file management | +| `apps/server/src/routes/setup/routes/cursor-status.ts` | 4 | CLI status endpoint | +| `apps/server/src/routes/setup/routes/cursor-config.ts` | 4 | Config management endpoints | +| `apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx` | 6 | Setup wizard step | +| `apps/ui/src/components/views/settings-view/providers/cursor-settings-tab.tsx` | 7 | Settings tab | +| `apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx` | 7 | Tab container | +| `apps/server/tests/unit/providers/cursor-provider.test.ts` | 10 | Unit tests | + +### Files to Modify + +| File | Phase | Changes | +| ------------------------------------------------------------------------ | ----- | ------------------------------ | +| `libs/types/src/index.ts` | 1 | Export Cursor types | +| `libs/types/src/settings.ts` | 1 | Extend `ModelProvider` type | +| `apps/server/src/providers/provider-factory.ts` | 3 | Add Cursor routing | +| `apps/server/src/routes/setup/index.ts` | 4 | Register Cursor routes | +| `apps/ui/src/lib/log-parser.ts` | 5 | Add Cursor event normalization | +| `apps/ui/src/components/views/setup-view.tsx` | 6 | Add Cursor setup step | +| `apps/ui/src/components/views/profiles-view/components/profile-form.tsx` | 8 | Add Cursor provider fields | +| `apps/server/src/services/agent-service.ts` | 9 | Use ProviderFactory | + +--- + +## Dependencies + +### Between Phases + +``` +Phase 0 ─────────────────────────────────────────────┐ + │ │ +Phase 1 (Types) ─────────────────────────────────────┤ + │ │ +Phase 2 (Provider) ──────────────────────────────────┤ + │ │ +Phase 3 (Factory) ───────────────────────────────────┤ + │ │ + ├── Phase 4 (Routes) ────────────────────────────┤ + │ │ │ + │ ├── Phase 6 (Setup Wizard) ──────────────┤ + │ │ │ + │ └── Phase 7 (Settings View) ─────────────┤ + │ │ + ├── Phase 5 (Log Parser) ────────────────────────┤ + │ │ + └── Phase 8 (Profiles) ──────────────────────────┤ + │ │ + Phase 9 (Execution) ─────────────────────┤ + │ + Phase 10 (Tests) ┘ +``` + +### External Dependencies + +- `cursor-agent` CLI must be installed for testing +- Cursor account for authentication testing + +--- + +## Design Decisions + +### 1. Use HttpApiClient for All API Requests + +All UI components must use `HttpApiClient` from `@/lib/http-api-client.ts` instead of raw `fetch()`: + +```typescript +// ✓ Correct - uses HttpApiClient +import { api } from '@/lib/http-api-client'; +const result = await api.setup.getCursorStatus(); + +// ✗ Incorrect - raw fetch +const response = await fetch('/api/setup/cursor-status'); +``` + +New Cursor API methods added to `HttpApiClient.setup`: + +- `getCursorStatus()` - Installation and auth status +- `getCursorConfig()` - Configuration settings +- `setCursorDefaultModel(model)` - Update default model +- `setCursorModels(models)` - Update enabled models + +### 2. Use Existing UI Components + +All UI must use components from `@/components/ui/*`: + +- `Card`, `CardHeader`, `CardTitle`, `CardContent` - Layout +- `Button`, `Badge`, `Label` - Controls +- `Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue` - Selection +- `Checkbox` - Toggle inputs +- `Alert`, `AlertDescription` - Messages +- `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` - Navigation + +Icons from `lucide-react`: + +- `Terminal` - Cursor provider +- `Bot` - Claude provider +- `CheckCircle2`, `XCircle` - Status indicators +- `Loader2` - Loading states +- `RefreshCw` - Refresh action +- `ExternalLink` - External links + +### 3. Cursor CLI Installation Paths + +Based on official cursor-agent install script: + +**Linux/macOS:** + +- Primary symlink: `~/.local/bin/cursor-agent` +- Versions directory: `~/.local/share/cursor-agent/versions//cursor-agent` +- Fallback: `/usr/local/bin/cursor-agent` + +**Windows:** + +- Primary: `%APPDATA%\Local\Programs\cursor-agent\cursor-agent.exe` +- Fallback: `~/.local/bin/cursor-agent.exe` +- Fallback: `C:\Program Files\cursor-agent\cursor-agent.exe` + +### 4. Use @automaker/\* Packages + +All server-side code must use shared packages from `libs/`: + +**From `@automaker/types`:** + +- Reuse existing `InstallationStatus` (don't create `CursorInstallationStatus`) +- Extend `ModelProvider` type to include `'cursor'` +- Extend `DEFAULT_MODELS` to include `cursor: 'auto'` +- Update `ModelOption.provider` from `'claude'` to `ModelProvider` + +**From `@automaker/utils`:** + +```typescript +import { createLogger, isAbortError } from '@automaker/utils'; + +const logger = createLogger('CursorProvider'); +// Use isAbortError() for abort signal detection +``` + +**From `@automaker/platform`:** + +```typescript +import { spawnJSONLProcess, getAutomakerDir } from '@automaker/platform'; + +// Use spawnJSONLProcess for JSONL streaming (handles buffering, timeout, abort) +// Use getAutomakerDir for consistent .automaker path resolution +``` + +### 5. Do NOT Extend @automaker/model-resolver + +The model-resolver is Claude-specific and should **not** be extended for Cursor: + +- Claude uses aliases (`sonnet` → `claude-sonnet-4-5-20250929`) +- Cursor model IDs are final-form (`claude-sonnet-4` passed directly to CLI) +- Cursor models have metadata (`hasThinking`, `tier`) that doesn't fit the string-only map + +Cursor models use their own `CURSOR_MODEL_MAP` in `@automaker/types`. + +--- + +## Risk Mitigation + +1. **Phase Isolation**: Each phase can be tested independently +2. **Feature Flags**: Cursor provider can be disabled if issues arise +3. **Fallback**: Default to Claude provider for unknown models +4. **Graceful Degradation**: UI shows "not installed" state clearly + +--- + +## How to Use This Plan + +1. **Start with Phase 0** - Read and understand existing patterns +2. **Complete phases sequentially** - Dependencies require order +3. **Test each phase** - Run the verification steps before moving on +4. **Update status** - Mark phases as `in_progress`, `completed`, or `blocked` +5. **Document issues** - Add notes to individual phase files + +--- + +## Changelog + +| Date | Phase | Change | +| ---------- | ----- | ---------------------------------------------------------------------------------- | +| 2025-12-27 | - | Initial plan created | +| 2025-12-27 | 2 | Updated findCliPath() with platform-specific paths and versions directory scanning | +| 2025-12-27 | 4 | Updated to use HttpApiClient instead of raw fetch | +| 2025-12-27 | 6 | Updated to use HttpApiClient and existing UI components | +| 2025-12-27 | 7 | Updated to use HttpApiClient and existing UI components | +| 2025-12-27 | - | Added Design Decisions section to README | +| 2025-12-27 | 2 | Updated to use `createLogger` from `@automaker/utils` | +| 2025-12-27 | 4 | Updated to use `createLogger` from `@automaker/utils` | +| 2025-12-27 | 8 | Added proper UI component imports from `@/components/ui/*` | +| 2025-12-27 | 1 | Added tasks 1.5-1.7: ModelOption, DEFAULT_MODELS, reuse InstallationStatus | +| 2025-12-27 | 2 | Refactored to use `spawnJSONLProcess` and `isAbortError` from @automaker packages | +| 2025-12-27 | - | Added design decisions 4-5: @automaker packages usage, model-resolver note | diff --git a/plan/cursor-cli-integration/phases/phase-0-analysis.md b/plan/cursor-cli-integration/phases/phase-0-analysis.md new file mode 100644 index 00000000..48ebbfab --- /dev/null +++ b/plan/cursor-cli-integration/phases/phase-0-analysis.md @@ -0,0 +1,201 @@ +# Phase 0: Analysis & Documentation + +**Status:** `pending` +**Dependencies:** None +**Estimated Effort:** Research only (no code changes) + +--- + +## Objective + +Understand existing AutoMaker architecture patterns before writing any code. Document findings to ensure consistent implementation. + +--- + +## Tasks + +### Task 0.1: Read Core Provider Files + +**Status:** `pending` + +Read and understand these files: + +| File | Purpose | Key Patterns | +| ----------------------------------------------- | ------------------------ | --------------------------------------- | +| `apps/server/src/providers/base-provider.ts` | Abstract base class | `executeQuery()` AsyncGenerator pattern | +| `apps/server/src/providers/claude-provider.ts` | Reference implementation | SDK integration, streaming | +| `apps/server/src/providers/provider-factory.ts` | Model routing | `getProviderForModel()` pattern | +| `apps/server/src/providers/types.ts` | Type definitions | `ProviderMessage`, `ExecuteOptions` | + +**Verification:** + +```bash +# Files should exist and be readable +cat apps/server/src/providers/base-provider.ts | head -50 +cat apps/server/src/providers/claude-provider.ts | head -100 +``` + +### Task 0.2: Read Service Integration + +**Status:** `pending` + +Understand how providers are consumed: + +| File | Purpose | Key Patterns | +| ----------------------------------------------- | --------------------- | ---------------------------------- | +| `apps/server/src/services/agent-service.ts` | Chat sessions | Provider streaming, event emission | +| `apps/server/src/services/auto-mode-service.ts` | Autonomous tasks | executeOptions, tool handling | +| `apps/server/src/lib/sdk-options.ts` | Configuration factory | Tool presets, max turns | + +### Task 0.3: Read UI Streaming/Logging + +**Status:** `pending` + +Understand log parsing and display: + +| File | Purpose | Key Patterns | +| ------------------------------------------ | ------------------ | ---------------------------- | +| `apps/ui/src/lib/log-parser.ts` | Parse agent output | Entry types, tool categories | +| `apps/ui/src/components/ui/log-viewer.tsx` | Display logs | Collapsible entries, search | + +### Task 0.4: Read Setup Flow + +**Status:** `pending` + +Understand setup wizard patterns: + +| File | Purpose | Key Patterns | +| --------------------------------------------------- | ------------------ | ------------------------ | +| `apps/server/src/routes/setup/index.ts` | Route registration | Handler patterns | +| `apps/server/src/routes/setup/get-claude-status.ts` | CLI detection | Installation check logic | +| `apps/ui/src/components/views/setup-view.tsx` | Wizard UI | Step components | + +### Task 0.5: Read Types Package + +**Status:** `pending` + +Understand type definitions: + +| File | Purpose | Key Patterns | +| --------------------------------- | -------------- | ---------------------------- | +| `libs/types/src/index.ts` | Re-exports | Export patterns | +| `libs/types/src/settings.ts` | Settings types | `AIProfile`, `ModelProvider` | +| `libs/types/src/model.ts` | Model aliases | `CLAUDE_MODEL_MAP` | +| `libs/types/src/model-display.ts` | UI metadata | Display info pattern | + +### Task 0.6: Document Cursor CLI Behavior + +**Status:** `pending` + +Test and document Cursor CLI behavior: + +```bash +# Check installation +cursor-agent --version + +# Check auth status (if available) +cursor-agent status 2>&1 || echo "No status command" + +# Test stream-json output (dry run) +echo "Test prompt" | cursor-agent -p --output-format stream-json --model auto 2>&1 | head -20 +``` + +Document: + +- [ ] Exact event sequence for simple prompt +- [ ] Error message formats +- [ ] Exit codes for different failure modes +- [ ] How tool calls appear in stream + +--- + +## Deliverable: Analysis Document + +Create `docs/cursor-integration-analysis.md` with findings: + +```markdown +# Cursor CLI Integration Analysis + +## Provider Pattern Summary + +### BaseProvider Interface + +- `executeQuery()` returns `AsyncGenerator` +- Messages must match format: { type, message?, result?, error? } +- Session IDs propagated through all messages + +### ClaudeProvider Patterns + +- Uses Claude Agent SDK `query()` function +- Streaming handled natively by SDK +- Yields messages directly from SDK stream + +### Key Interfaces + +[Document: ProviderMessage, ExecuteOptions, InstallationStatus] + +## Cursor CLI Behavior + +### Stream Event Sequence + +1. system/init - session start +2. user - input prompt +3. assistant - response text +4. tool_call/started - tool invocation +5. tool_call/completed - tool result +6. result/success - final output + +### Event Format Differences + +[Document any transformations needed] + +### Error Scenarios + +- Not authenticated: [error message/code] +- Rate limited: [error message/code] +- Network error: [error message/code] + +## Integration Points + +### Files to Create + +[List with descriptions] + +### Files to Modify + +[List with specific changes needed] + +## Open Questions + +[Any unresolved issues] +``` + +--- + +## Verification Checklist + +Before marking this phase complete: + +- [ ] All provider files read and understood +- [ ] Service integration patterns documented +- [ ] Log parser patterns understood +- [ ] Setup wizard flow mapped +- [ ] Types package structure documented +- [ ] Cursor CLI behavior tested (if installed) +- [ ] Analysis document created in `docs/` + +--- + +## Notes + +- This phase is **read-only** - no code changes +- Document anything unclear for later clarification +- Note any differences from the high-level plan provided + +--- + +## References + +- [Cursor CLI Output Format](https://cursor.com/docs/cli/reference/output-format) +- [Cursor CLI Usage](https://cursor.com/docs/cli/using) +- [Cursor CLI GitHub Actions](https://cursor.com/docs/cli/github-actions) diff --git a/plan/cursor-cli-integration/phases/phase-1-types.md b/plan/cursor-cli-integration/phases/phase-1-types.md new file mode 100644 index 00000000..d3b1cc6a --- /dev/null +++ b/plan/cursor-cli-integration/phases/phase-1-types.md @@ -0,0 +1,443 @@ +# Phase 1: Core Types & Configuration + +**Status:** `pending` +**Dependencies:** Phase 0 (Analysis) +**Estimated Effort:** Small (type definitions only) + +--- + +## Objective + +Define all Cursor-specific types and extend existing types to support the new provider. + +--- + +## Tasks + +### Task 1.1: Create Cursor Model Definitions + +**Status:** `pending` + +**File:** `libs/types/src/cursor-models.ts` + +```typescript +/** + * Cursor CLI Model IDs + * Reference: https://cursor.com/docs + */ +export type CursorModelId = + | 'auto' // Auto-select best model + | 'claude-sonnet-4' // Claude Sonnet 4 + | 'claude-sonnet-4-thinking' // Claude Sonnet 4 with extended thinking + | 'gpt-4o' // GPT-4o + | 'gpt-4o-mini' // GPT-4o Mini + | 'gemini-2.5-pro' // Gemini 2.5 Pro + | 'o3-mini'; // O3 Mini + +/** + * Cursor model metadata + */ +export interface CursorModelConfig { + id: CursorModelId; + label: string; + description: string; + hasThinking: boolean; + tier: 'free' | 'pro'; +} + +/** + * Complete model map for Cursor CLI + */ +export const CURSOR_MODEL_MAP: Record = { + auto: { + id: 'auto', + label: 'Auto (Recommended)', + description: 'Automatically selects the best model for each task', + hasThinking: false, + tier: 'free', + }, + 'claude-sonnet-4': { + id: 'claude-sonnet-4', + label: 'Claude Sonnet 4', + description: 'Anthropic Claude Sonnet 4 via Cursor', + hasThinking: false, + tier: 'pro', + }, + 'claude-sonnet-4-thinking': { + id: 'claude-sonnet-4-thinking', + label: 'Claude Sonnet 4 (Thinking)', + description: 'Claude Sonnet 4 with extended thinking enabled', + hasThinking: true, + tier: 'pro', + }, + 'gpt-4o': { + id: 'gpt-4o', + label: 'GPT-4o', + description: 'OpenAI GPT-4o via Cursor', + hasThinking: false, + tier: 'pro', + }, + 'gpt-4o-mini': { + id: 'gpt-4o-mini', + label: 'GPT-4o Mini', + description: 'OpenAI GPT-4o Mini (faster, cheaper)', + hasThinking: false, + tier: 'free', + }, + 'gemini-2.5-pro': { + id: 'gemini-2.5-pro', + label: 'Gemini 2.5 Pro', + description: 'Google Gemini 2.5 Pro via Cursor', + hasThinking: false, + tier: 'pro', + }, + 'o3-mini': { + id: 'o3-mini', + label: 'O3 Mini', + description: 'OpenAI O3 Mini reasoning model', + hasThinking: true, + tier: 'pro', + }, +}; + +/** + * Helper: Check if model has thinking capability + */ +export function cursorModelHasThinking(modelId: CursorModelId): boolean { + return CURSOR_MODEL_MAP[modelId]?.hasThinking ?? false; +} + +/** + * Helper: Get display name for model + */ +export function getCursorModelLabel(modelId: CursorModelId): string { + return CURSOR_MODEL_MAP[modelId]?.label ?? modelId; +} + +/** + * Helper: Get all cursor model IDs + */ +export function getAllCursorModelIds(): CursorModelId[] { + return Object.keys(CURSOR_MODEL_MAP) as CursorModelId[]; +} +``` + +### Task 1.2: Create Cursor CLI Types + +**Status:** `pending` + +**File:** `libs/types/src/cursor-cli.ts` + +```typescript +import { CursorModelId } from './cursor-models'; + +/** + * Cursor CLI configuration file schema + * Stored in: .automaker/cursor-config.json + */ +export interface CursorCliConfig { + defaultModel?: CursorModelId; + models?: CursorModelId[]; // Enabled models + mcpServers?: string[]; // MCP server configs to load + rules?: string[]; // .cursor/rules paths +} + +/** + * Cursor authentication status + */ +export interface CursorAuthStatus { + authenticated: boolean; + method: 'login' | 'api_key' | 'none'; + hasCredentialsFile?: boolean; +} + +/** + * NOTE: Reuse existing InstallationStatus from provider.ts + * The existing type already has: installed, path, version, method, hasApiKey, authenticated + * + * Add 'login' to the method union if needed: + * method?: 'cli' | 'npm' | 'brew' | 'sdk' | 'login'; + */ + +/** + * Cursor stream-json event types (from CLI output) + */ +export interface CursorSystemEvent { + type: 'system'; + subtype: 'init'; + apiKeySource: 'env' | 'flag' | 'login'; + cwd: string; + session_id: string; + model: string; + permissionMode: string; +} + +export interface CursorUserEvent { + type: 'user'; + message: { + role: 'user'; + content: Array<{ type: 'text'; text: string }>; + }; + session_id: string; +} + +export interface CursorAssistantEvent { + type: 'assistant'; + message: { + role: 'assistant'; + content: Array<{ type: 'text'; text: string }>; + }; + session_id: string; +} + +export interface CursorToolCallEvent { + type: 'tool_call'; + subtype: 'started' | 'completed'; + call_id: string; + tool_call: { + readToolCall?: { + args: { path: string }; + result?: { + success?: { + content: string; + isEmpty: boolean; + exceededLimit: boolean; + totalLines: number; + totalChars: number; + }; + }; + }; + writeToolCall?: { + args: { path: string; fileText: string; toolCallId?: string }; + result?: { + success?: { + path: string; + linesCreated: number; + fileSize: number; + }; + }; + }; + function?: { + name: string; + arguments: string; + }; + }; + session_id: string; +} + +export interface CursorResultEvent { + type: 'result'; + subtype: 'success' | 'error'; + duration_ms: number; + duration_api_ms: number; + is_error: boolean; + result: string; + session_id: string; + request_id?: string; + error?: string; +} + +export type CursorStreamEvent = + | CursorSystemEvent + | CursorUserEvent + | CursorAssistantEvent + | CursorToolCallEvent + | CursorResultEvent; +``` + +### Task 1.3: Extend ModelProvider Type + +**Status:** `pending` + +**File:** `libs/types/src/settings.ts` + +Find and update: + +```typescript +// BEFORE: +export type ModelProvider = 'claude'; + +// AFTER: +export type ModelProvider = 'claude' | 'cursor'; +``` + +### Task 1.4: Add Cursor Profile Config Type + +**Status:** `pending` + +**File:** `libs/types/src/settings.ts` + +Add after existing AIProfile interface: + +```typescript +/** + * Cursor-specific profile configuration + * Note: For Cursor, thinking is embedded in model ID (e.g., 'claude-sonnet-4-thinking') + */ +export interface CursorProfileConfig { + model: CursorModelId; + // No separate thinkingLevel needed - embedded in model ID +} +``` + +### Task 1.5: Update ModelOption Interface + +**Status:** `pending` + +**File:** `libs/types/src/model-display.ts` + +Update the hardcoded provider type to use ModelProvider: + +```typescript +// BEFORE (line 24): +export interface ModelOption { + id: AgentModel; + label: string; + description: string; + badge?: string; + provider: 'claude'; // ❌ Hardcoded +} + +// AFTER: +import { ModelProvider } from './settings.js'; + +export interface ModelOption { + id: AgentModel | CursorModelId; // Union for both providers + label: string; + description: string; + badge?: string; + provider: ModelProvider; // ✅ Supports both 'claude' and 'cursor' +} +``` + +### Task 1.6: Extend DEFAULT_MODELS + +**Status:** `pending` + +**File:** `libs/types/src/model.ts` + +Add cursor default model: + +```typescript +// BEFORE: +export const DEFAULT_MODELS = { + claude: 'claude-opus-4-5-20251101', +} as const; + +// AFTER: +export const DEFAULT_MODELS = { + claude: 'claude-opus-4-5-20251101', + cursor: 'auto', // Cursor's recommended default +} as const; +``` + +### Task 1.7: Update Type Exports + +**Status:** `pending` + +**File:** `libs/types/src/index.ts` + +Add exports: + +```typescript +// Cursor types +export * from './cursor-models.js'; +export * from './cursor-cli.js'; +``` + +--- + +## Verification + +### Test 1: Type Compilation + +```bash +cd libs/types +pnpm build +``` + +**Expected:** No compilation errors + +### Test 2: Import Check + +Create a temporary test file: + +```typescript +// test-cursor-types.ts +import { + CursorModelId, + CursorModelConfig, + CURSOR_MODEL_MAP, + cursorModelHasThinking, + CursorStreamEvent, + CursorCliConfig, + ModelProvider, +} from '@automaker/types'; + +// Should compile without errors +const model: CursorModelId = 'claude-sonnet-4'; +const provider: ModelProvider = 'cursor'; +const hasThinking = cursorModelHasThinking('claude-sonnet-4-thinking'); +console.log(model, provider, hasThinking); +``` + +```bash +npx tsc test-cursor-types.ts --noEmit +rm test-cursor-types.ts +``` + +**Expected:** No errors + +### Test 3: Model Map Validity + +```typescript +// In Node REPL or test file +import { CURSOR_MODEL_MAP, CursorModelId } from '@automaker/types'; + +const modelIds = Object.keys(CURSOR_MODEL_MAP) as CursorModelId[]; +console.log('Models:', modelIds.length); + +// All models should have required fields +for (const [id, config] of Object.entries(CURSOR_MODEL_MAP)) { + console.assert(config.id === id, `ID mismatch: ${id}`); + console.assert(typeof config.label === 'string', `Missing label: ${id}`); + console.assert(typeof config.hasThinking === 'boolean', `Missing hasThinking: ${id}`); + console.assert(['free', 'pro'].includes(config.tier), `Invalid tier: ${id}`); +} +console.log('All models valid'); +``` + +**Expected:** All assertions pass + +--- + +## Verification Checklist + +Before marking this phase complete: + +- [ ] `libs/types/src/cursor-models.ts` created with all model definitions +- [ ] `libs/types/src/cursor-cli.ts` created with CLI types +- [ ] `libs/types/src/settings.ts` extended with `cursor` provider +- [ ] `libs/types/src/index.ts` exports new types +- [ ] `pnpm build` succeeds in libs/types +- [ ] No TypeScript errors in dependent packages +- [ ] Model map contains all expected models + +--- + +## Files Changed + +| File | Action | Description | +| --------------------------------- | ------ | ----------------------------- | +| `libs/types/src/cursor-models.ts` | Create | Model definitions and helpers | +| `libs/types/src/cursor-cli.ts` | Create | CLI and stream event types | +| `libs/types/src/settings.ts` | Modify | Add `cursor` to ModelProvider | +| `libs/types/src/index.ts` | Modify | Export new types | + +--- + +## Notes + +- Model IDs may need updating as Cursor adds/removes models +- The `hasThinking` property is critical for UI display +- Stream event types must match actual CLI output exactly diff --git a/plan/cursor-cli-integration/phases/phase-10-testing.md b/plan/cursor-cli-integration/phases/phase-10-testing.md new file mode 100644 index 00000000..c4841715 --- /dev/null +++ b/plan/cursor-cli-integration/phases/phase-10-testing.md @@ -0,0 +1,649 @@ +# Phase 10: Testing & Validation + +**Status:** `pending` +**Dependencies:** All previous phases +**Estimated Effort:** Medium (comprehensive testing) + +--- + +## Objective + +Create comprehensive tests and perform validation to ensure the Cursor CLI integration works correctly across all scenarios. + +--- + +## Tasks + +### Task 10.1: Unit Tests - Cursor Provider + +**Status:** `pending` + +**File:** `apps/server/tests/unit/providers/cursor-provider.test.ts` + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CursorProvider, CursorErrorCode } from '../../../src/providers/cursor-provider'; +import { execSync, spawn } from 'child_process'; +import * as fs from 'fs'; + +// Mock child_process +vi.mock('child_process', () => ({ + execSync: vi.fn(), + spawn: vi.fn(), +})); + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +describe('CursorProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getName', () => { + it('should return "cursor"', () => { + const provider = new CursorProvider(); + expect(provider.getName()).toBe('cursor'); + }); + }); + + describe('isInstalled', () => { + it('should return true when CLI is found in PATH', async () => { + vi.mocked(execSync).mockReturnValue('/usr/local/bin/cursor-agent\n'); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const provider = new CursorProvider(); + const result = await provider.isInstalled(); + + expect(result).toBe(true); + }); + + it('should return false when CLI is not found', async () => { + vi.mocked(execSync).mockImplementation(() => { + throw new Error('not found'); + }); + vi.mocked(fs.existsSync).mockReturnValue(false); + + const provider = new CursorProvider(); + const result = await provider.isInstalled(); + + expect(result).toBe(false); + }); + }); + + describe('checkAuth', () => { + it('should detect API key authentication', async () => { + process.env.CURSOR_API_KEY = 'test-key'; + + const provider = new CursorProvider(); + const result = await provider.checkAuth(); + + expect(result.authenticated).toBe(true); + expect(result.method).toBe('api_key'); + + delete process.env.CURSOR_API_KEY; + }); + + it('should detect login authentication from credentials file', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ accessToken: 'token' })); + + const provider = new CursorProvider(); + const result = await provider.checkAuth(); + + expect(result.authenticated).toBe(true); + expect(result.method).toBe('login'); + }); + + it('should return not authenticated when no credentials', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const provider = new CursorProvider(); + const result = await provider.checkAuth(); + + expect(result.authenticated).toBe(false); + expect(result.method).toBe('none'); + }); + }); + + describe('parseStreamLine', () => { + it('should parse valid JSON event', () => { + const provider = new CursorProvider(); + const line = '{"type":"system","subtype":"init","session_id":"abc"}'; + + const result = (provider as any).parseStreamLine(line); + + expect(result).toEqual({ + type: 'system', + subtype: 'init', + session_id: 'abc', + }); + }); + + it('should return null for invalid JSON', () => { + const provider = new CursorProvider(); + const result = (provider as any).parseStreamLine('not json'); + + expect(result).toBeNull(); + }); + + it('should return null for empty lines', () => { + const provider = new CursorProvider(); + expect((provider as any).parseStreamLine('')).toBeNull(); + expect((provider as any).parseStreamLine(' ')).toBeNull(); + }); + }); + + describe('mapError', () => { + it('should map authentication errors', () => { + const provider = new CursorProvider(); + + const error = (provider as any).mapError('Error: not authenticated', 1); + + expect(error.code).toBe(CursorErrorCode.NOT_AUTHENTICATED); + expect(error.recoverable).toBe(true); + expect(error.suggestion).toBeDefined(); + }); + + it('should map rate limit errors', () => { + const provider = new CursorProvider(); + + const error = (provider as any).mapError('Rate limit exceeded', 1); + + expect(error.code).toBe(CursorErrorCode.RATE_LIMITED); + expect(error.recoverable).toBe(true); + }); + + it('should map network errors', () => { + const provider = new CursorProvider(); + + const error = (provider as any).mapError('ECONNREFUSED', 1); + + expect(error.code).toBe(CursorErrorCode.NETWORK_ERROR); + expect(error.recoverable).toBe(true); + }); + + it('should return unknown error for unrecognized messages', () => { + const provider = new CursorProvider(); + + const error = (provider as any).mapError('Something weird happened', 1); + + expect(error.code).toBe(CursorErrorCode.UNKNOWN); + }); + }); + + describe('getAvailableModels', () => { + it('should return all Cursor models', () => { + const provider = new CursorProvider(); + const models = provider.getAvailableModels(); + + expect(models.length).toBeGreaterThan(0); + expect(models.every((m) => m.provider === 'cursor')).toBe(true); + expect(models.some((m) => m.id.includes('auto'))).toBe(true); + }); + }); +}); +``` + +### Task 10.2: Unit Tests - Provider Factory + +**Status:** `pending` + +**File:** `apps/server/tests/unit/providers/provider-factory.test.ts` + +```typescript +import { describe, it, expect } from 'vitest'; +import { ProviderFactory } from '../../../src/providers/provider-factory'; +import { ClaudeProvider } from '../../../src/providers/claude-provider'; +import { CursorProvider } from '../../../src/providers/cursor-provider'; + +describe('ProviderFactory', () => { + describe('getProviderNameForModel', () => { + it('should route cursor-prefixed models to cursor', () => { + expect(ProviderFactory.getProviderNameForModel('cursor-auto')).toBe('cursor'); + expect(ProviderFactory.getProviderNameForModel('cursor-gpt-4o')).toBe('cursor'); + expect(ProviderFactory.getProviderNameForModel('cursor-claude-sonnet-4')).toBe('cursor'); + }); + + it('should route claude models to claude', () => { + expect(ProviderFactory.getProviderNameForModel('claude-sonnet-4')).toBe('claude'); + expect(ProviderFactory.getProviderNameForModel('opus')).toBe('claude'); + expect(ProviderFactory.getProviderNameForModel('sonnet')).toBe('claude'); + expect(ProviderFactory.getProviderNameForModel('haiku')).toBe('claude'); + }); + + it('should default unknown models to claude', () => { + expect(ProviderFactory.getProviderNameForModel('unknown-model')).toBe('claude'); + expect(ProviderFactory.getProviderNameForModel('random')).toBe('claude'); + }); + }); + + describe('getProviderForModel', () => { + it('should return CursorProvider for cursor models', () => { + const provider = ProviderFactory.getProviderForModel('cursor-auto'); + expect(provider).toBeInstanceOf(CursorProvider); + expect(provider.getName()).toBe('cursor'); + }); + + it('should return ClaudeProvider for claude models', () => { + const provider = ProviderFactory.getProviderForModel('sonnet'); + expect(provider).toBeInstanceOf(ClaudeProvider); + expect(provider.getName()).toBe('claude'); + }); + }); + + describe('getAllProviders', () => { + it('should return both providers', () => { + const providers = ProviderFactory.getAllProviders(); + const names = providers.map((p) => p.getName()); + + expect(names).toContain('claude'); + expect(names).toContain('cursor'); + }); + }); + + describe('getProviderByName', () => { + it('should return correct provider by name', () => { + expect(ProviderFactory.getProviderByName('cursor')?.getName()).toBe('cursor'); + expect(ProviderFactory.getProviderByName('claude')?.getName()).toBe('claude'); + expect(ProviderFactory.getProviderByName('unknown')).toBeNull(); + }); + }); + + describe('getAllAvailableModels', () => { + it('should include models from all providers', () => { + const models = ProviderFactory.getAllAvailableModels(); + + const cursorModels = models.filter((m) => m.provider === 'cursor'); + const claudeModels = models.filter((m) => m.provider === 'claude'); + + expect(cursorModels.length).toBeGreaterThan(0); + expect(claudeModels.length).toBeGreaterThan(0); + }); + }); +}); +``` + +### Task 10.3: Unit Tests - Types + +**Status:** `pending` + +**File:** `libs/types/tests/cursor-types.test.ts` + +```typescript +import { describe, it, expect } from 'vitest'; +import { + CURSOR_MODEL_MAP, + cursorModelHasThinking, + getCursorModelLabel, + getAllCursorModelIds, + CursorModelId, +} from '../src/cursor-models'; +import { profileHasThinking, getProfileModelString, AIProfile } from '../src/settings'; + +describe('Cursor Model Types', () => { + describe('CURSOR_MODEL_MAP', () => { + it('should have all required models', () => { + const requiredModels: CursorModelId[] = [ + 'auto', + 'claude-sonnet-4', + 'claude-sonnet-4-thinking', + 'gpt-4o', + 'gpt-4o-mini', + ]; + + for (const model of requiredModels) { + expect(CURSOR_MODEL_MAP[model]).toBeDefined(); + expect(CURSOR_MODEL_MAP[model].id).toBe(model); + } + }); + + it('should have valid tier values', () => { + for (const config of Object.values(CURSOR_MODEL_MAP)) { + expect(['free', 'pro']).toContain(config.tier); + } + }); + }); + + describe('cursorModelHasThinking', () => { + it('should return true for thinking models', () => { + expect(cursorModelHasThinking('claude-sonnet-4-thinking')).toBe(true); + expect(cursorModelHasThinking('o3-mini')).toBe(true); + }); + + it('should return false for non-thinking models', () => { + expect(cursorModelHasThinking('auto')).toBe(false); + expect(cursorModelHasThinking('gpt-4o')).toBe(false); + expect(cursorModelHasThinking('claude-sonnet-4')).toBe(false); + }); + }); + + describe('getCursorModelLabel', () => { + it('should return correct labels', () => { + expect(getCursorModelLabel('auto')).toBe('Auto (Recommended)'); + expect(getCursorModelLabel('gpt-4o')).toBe('GPT-4o'); + }); + + it('should return model ID for unknown models', () => { + expect(getCursorModelLabel('unknown' as CursorModelId)).toBe('unknown'); + }); + }); +}); + +describe('Profile Helpers', () => { + describe('profileHasThinking', () => { + it('should detect Claude thinking levels', () => { + const profile: AIProfile = { + id: '1', + name: 'Test', + description: '', + provider: 'claude', + model: 'sonnet', + thinkingLevel: 'high', + isBuiltIn: false, + }; + expect(profileHasThinking(profile)).toBe(true); + + profile.thinkingLevel = 'none'; + expect(profileHasThinking(profile)).toBe(false); + }); + + it('should detect Cursor thinking models', () => { + const profile: AIProfile = { + id: '1', + name: 'Test', + description: '', + provider: 'cursor', + cursorModel: 'claude-sonnet-4-thinking', + isBuiltIn: false, + }; + expect(profileHasThinking(profile)).toBe(true); + + profile.cursorModel = 'gpt-4o'; + expect(profileHasThinking(profile)).toBe(false); + }); + }); + + describe('getProfileModelString', () => { + it('should format Cursor models correctly', () => { + const profile: AIProfile = { + id: '1', + name: 'Test', + description: '', + provider: 'cursor', + cursorModel: 'gpt-4o', + isBuiltIn: false, + }; + expect(getProfileModelString(profile)).toBe('cursor-gpt-4o'); + }); + + it('should format Claude models correctly', () => { + const profile: AIProfile = { + id: '1', + name: 'Test', + description: '', + provider: 'claude', + model: 'sonnet', + isBuiltIn: false, + }; + expect(getProfileModelString(profile)).toBe('sonnet'); + }); + }); +}); +``` + +### Task 10.4: Integration Tests + +**Status:** `pending` + +**File:** `apps/server/tests/integration/cursor-integration.test.ts` + +```typescript +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { CursorProvider } from '../../src/providers/cursor-provider'; +import { ProviderFactory } from '../../src/providers/provider-factory'; + +describe('Cursor Integration (requires cursor-agent)', () => { + let provider: CursorProvider; + let isInstalled: boolean; + + beforeAll(async () => { + provider = new CursorProvider(); + isInstalled = await provider.isInstalled(); + }); + + describe('when cursor-agent is installed', () => { + it.skipIf(!isInstalled)('should get version', async () => { + const version = await provider.getVersion(); + expect(version).toBeTruthy(); + expect(typeof version).toBe('string'); + }); + + it.skipIf(!isInstalled)('should check auth status', async () => { + const auth = await provider.checkAuth(); + expect(auth).toHaveProperty('authenticated'); + expect(auth).toHaveProperty('method'); + }); + + it.skipIf(!isInstalled)('should detect installation', async () => { + const status = await provider.detectInstallation(); + expect(status.installed).toBe(true); + expect(status.path).toBeTruthy(); + }); + }); + + describe('when cursor-agent is not installed', () => { + it.skipIf(isInstalled)('should report not installed', async () => { + const status = await provider.detectInstallation(); + expect(status.installed).toBe(false); + }); + }); +}); +``` + +### Task 10.5: E2E Tests + +**Status:** `pending` + +**File:** `apps/ui/tests/e2e/cursor-setup.spec.ts` + +```typescript +import { test, expect } from '@playwright/test'; + +test.describe('Cursor Setup Wizard', () => { + test('should show Cursor setup step', async ({ page }) => { + // Navigate to setup (fresh install) + await page.goto('/setup'); + + // Wait for Cursor step to appear + await expect(page.getByText('Cursor CLI Setup')).toBeVisible(); + await expect(page.getByText('Optional')).toBeVisible(); + }); + + test('should allow skipping Cursor setup', async ({ page }) => { + await page.goto('/setup'); + + // Find and click skip button + await page.getByRole('button', { name: 'Skip for now' }).click(); + + // Should proceed to next step + await expect(page.getByText('Cursor CLI Setup')).not.toBeVisible(); + }); + + test('should show installation instructions when not installed', async ({ page }) => { + await page.goto('/setup'); + + // Check for install command + await expect(page.getByText('curl https://cursor.com/install')).toBeVisible(); + }); +}); + +test.describe('Cursor Settings', () => { + test('should show Cursor tab in settings', async ({ page }) => { + await page.goto('/settings/providers'); + + // Should have tabs for both providers + await expect(page.getByRole('tab', { name: 'Claude' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Cursor' })).toBeVisible(); + }); + + test('should switch between provider tabs', async ({ page }) => { + await page.goto('/settings/providers'); + + // Click Cursor tab + await page.getByRole('tab', { name: 'Cursor' }).click(); + + // Should show Cursor settings + await expect(page.getByText('Cursor CLI Status')).toBeVisible(); + }); +}); +``` + +### Task 10.6: Manual Testing Checklist + +**Status:** `pending` + +Create a manual testing checklist: + +```markdown +## Manual Testing Checklist + +### Setup Flow + +- [ ] Fresh install shows Cursor step +- [ ] Can skip Cursor setup +- [ ] Installation status is accurate +- [ ] Login flow works (copy command, poll for auth) +- [ ] Refresh button updates status + +### Settings + +- [ ] Provider tabs work +- [ ] Cursor status shows correctly +- [ ] Model selection works +- [ ] Default model saves +- [ ] Enabled models save + +### Profiles + +- [ ] Can create Cursor profile +- [ ] Provider switch resets options +- [ ] Cursor models show thinking badge +- [ ] Built-in Cursor profiles appear +- [ ] Profile cards show provider info + +### Execution + +- [ ] Tasks with Cursor models execute +- [ ] Streaming works correctly +- [ ] Tool calls are displayed +- [ ] Errors show suggestions +- [ ] Can abort Cursor tasks + +### Log Viewer + +- [ ] Cursor events parsed correctly +- [ ] Tool calls categorized +- [ ] File paths highlighted +- [ ] Provider badge shown + +### Edge Cases + +- [ ] Switch provider mid-session +- [ ] Cursor not installed handling +- [ ] Network errors handled +- [ ] Rate limiting handled +- [ ] Auth expired handling +``` + +--- + +## Verification + +### Test 1: Run All Unit Tests + +```bash +pnpm test:unit +``` + +All tests should pass. + +### Test 2: Run Integration Tests + +```bash +pnpm test:integration +``` + +Tests requiring cursor-agent will be skipped if not installed. + +### Test 3: Run E2E Tests + +```bash +pnpm test:e2e +``` + +Browser tests should pass. + +### Test 4: Type Check + +```bash +pnpm typecheck +``` + +No TypeScript errors. + +### Test 5: Lint Check + +```bash +pnpm lint +``` + +No linting errors. + +### Test 6: Build + +```bash +pnpm build +``` + +Build should succeed without errors. + +--- + +## Verification Checklist + +Before marking this phase complete: + +- [ ] Unit tests pass (cursor-provider) +- [ ] Unit tests pass (provider-factory) +- [ ] Unit tests pass (types) +- [ ] Integration tests pass (or skip if not installed) +- [ ] E2E tests pass +- [ ] Manual testing checklist completed +- [ ] No TypeScript errors +- [ ] No linting errors +- [ ] Build succeeds +- [ ] Documentation updated + +--- + +## Files Changed + +| File | Action | Description | +| ----------------------------------------------------------- | ------ | ----------------- | +| `apps/server/tests/unit/providers/cursor-provider.test.ts` | Create | Provider tests | +| `apps/server/tests/unit/providers/provider-factory.test.ts` | Create | Factory tests | +| `libs/types/tests/cursor-types.test.ts` | Create | Type tests | +| `apps/server/tests/integration/cursor-integration.test.ts` | Create | Integration tests | +| `apps/ui/tests/e2e/cursor-setup.spec.ts` | Create | E2E tests | + +--- + +## Notes + +- Integration tests may be skipped if cursor-agent is not installed +- E2E tests should work regardless of cursor-agent installation +- Manual testing should cover both installed and not-installed scenarios diff --git a/plan/cursor-cli-integration/phases/phase-2-provider.md b/plan/cursor-cli-integration/phases/phase-2-provider.md new file mode 100644 index 00000000..4ff2d83c --- /dev/null +++ b/plan/cursor-cli-integration/phases/phase-2-provider.md @@ -0,0 +1,850 @@ +# Phase 2: Cursor Provider Implementation + +**Status:** `pending` +**Dependencies:** Phase 1 (Types) +**Estimated Effort:** Medium-Large (core implementation) + +--- + +## Objective + +Implement the main `CursorProvider` class that spawns the cursor-agent CLI and streams responses in the AutoMaker provider format. + +--- + +## Tasks + +### Task 2.1: Create Cursor Provider + +**Status:** `pending` + +**File:** `apps/server/src/providers/cursor-provider.ts` + +```typescript +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { BaseProvider } from './base-provider'; +import { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from './types'; +import { + CursorModelId, + CursorStreamEvent, + CursorSystemEvent, + CursorAssistantEvent, + CursorToolCallEvent, + CursorResultEvent, + CURSOR_MODEL_MAP, + CursorAuthStatus, +} from '@automaker/types'; +import { createLogger, isAbortError } from '@automaker/utils'; +import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform'; + +// Create logger for this module +const logger = createLogger('CursorProvider'); + +/** + * Cursor-specific error codes for detailed error handling + */ +export enum CursorErrorCode { + NOT_INSTALLED = 'CURSOR_NOT_INSTALLED', + NOT_AUTHENTICATED = 'CURSOR_NOT_AUTHENTICATED', + RATE_LIMITED = 'CURSOR_RATE_LIMITED', + MODEL_UNAVAILABLE = 'CURSOR_MODEL_UNAVAILABLE', + NETWORK_ERROR = 'CURSOR_NETWORK_ERROR', + PROCESS_CRASHED = 'CURSOR_PROCESS_CRASHED', + TIMEOUT = 'CURSOR_TIMEOUT', + UNKNOWN = 'CURSOR_UNKNOWN_ERROR', +} + +export interface CursorError extends Error { + code: CursorErrorCode; + recoverable: boolean; + suggestion?: string; +} + +/** + * CursorProvider - Integrates cursor-agent CLI as an AI provider + * + * Uses the cursor-agent CLI with --output-format stream-json for streaming responses. + * Normalizes Cursor events to the AutoMaker ProviderMessage format. + */ +export class CursorProvider extends BaseProvider { + private static CLI_NAME = 'cursor-agent'; + + /** + * Installation paths based on official cursor-agent install script: + * + * Linux/macOS: + * - Binary: ~/.local/share/cursor-agent/versions//cursor-agent + * - Symlink: ~/.local/bin/cursor-agent -> versions//cursor-agent + * + * The install script creates versioned folders like: + * ~/.local/share/cursor-agent/versions/2025.12.17-996666f/cursor-agent + * And symlinks to ~/.local/bin/cursor-agent + */ + private static COMMON_PATHS: Record = { + linux: [ + path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location + '/usr/local/bin/cursor-agent', + ], + darwin: [ + path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location + '/usr/local/bin/cursor-agent', + ], + win32: [ + path.join(os.homedir(), 'AppData/Local/Programs/cursor-agent/cursor-agent.exe'), + path.join(os.homedir(), '.local/bin/cursor-agent.exe'), + 'C:\\Program Files\\cursor-agent\\cursor-agent.exe', + ], + }; + + // Version data directory where cursor-agent stores versions + private static VERSIONS_DIR = path.join(os.homedir(), '.local/share/cursor-agent/versions'); + + private cliPath: string | null = null; + private currentProcess: ChildProcess | null = null; + + constructor(config: ProviderConfig = {}) { + super(config); + this.cliPath = config.cliPath || this.findCliPath(); + } + + getName(): string { + return 'cursor'; + } + + /** + * Find cursor-agent CLI in PATH or common installation locations + */ + private findCliPath(): string | null { + // Try 'which' / 'where' first + try { + const cmd = process.platform === 'win32' ? 'where cursor-agent' : 'which cursor-agent'; + const result = execSync(cmd, { encoding: 'utf8', timeout: 5000 }).trim().split('\n')[0]; + if (result && fs.existsSync(result)) { + return result; + } + } catch { + // Not in PATH + } + + // Check common installation paths for current platform + const platform = process.platform as 'linux' | 'darwin' | 'win32'; + const platformPaths = CursorProvider.COMMON_PATHS[platform] || []; + + for (const p of platformPaths) { + if (fs.existsSync(p)) { + return p; + } + } + + // Also check versions directory for any installed version + if (fs.existsSync(CursorProvider.VERSIONS_DIR)) { + try { + const versions = fs + .readdirSync(CursorProvider.VERSIONS_DIR) + .filter((v) => !v.startsWith('.')) + .sort() + .reverse(); // Most recent first + + for (const version of versions) { + const binaryName = platform === 'win32' ? 'cursor-agent.exe' : 'cursor-agent'; + const versionPath = path.join(CursorProvider.VERSIONS_DIR, version, binaryName); + if (fs.existsSync(versionPath)) { + return versionPath; + } + } + } catch { + // Ignore directory read errors + } + } + + return null; + } + + /** + * Check if Cursor CLI is installed + */ + async isInstalled(): Promise { + return this.cliPath !== null; + } + + /** + * Get Cursor CLI version + */ + async getVersion(): Promise { + if (!this.cliPath) return null; + + try { + const result = execSync(`"${this.cliPath}" --version`, { + encoding: 'utf8', + timeout: 5000, + }).trim(); + return result; + } catch { + return null; + } + } + + /** + * Check authentication status + */ + async checkAuth(): Promise { + if (!this.cliPath) { + return { authenticated: false, method: 'none' }; + } + + // Check for API key in environment + if (process.env.CURSOR_API_KEY) { + return { authenticated: true, method: 'api_key' }; + } + + // Check for credentials file (location may vary) + const credentialPaths = [ + path.join(os.homedir(), '.cursor', 'credentials.json'), + path.join(os.homedir(), '.config', 'cursor', 'credentials.json'), + ]; + + for (const credPath of credentialPaths) { + if (fs.existsSync(credPath)) { + try { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + if (creds.accessToken || creds.token) { + return { authenticated: true, method: 'login', hasCredentialsFile: true }; + } + } catch { + // Invalid credentials file + } + } + } + + // Try running a simple command to check auth + try { + execSync(`"${this.cliPath}" --version`, { + encoding: 'utf8', + timeout: 10000, + env: { ...process.env }, + }); + // If we get here without error, assume authenticated + // (actual auth check would need a real API call) + return { authenticated: true, method: 'login' }; + } catch (error: any) { + if (error.stderr?.includes('not authenticated') || error.stderr?.includes('log in')) { + return { authenticated: false, method: 'none' }; + } + } + + return { authenticated: false, method: 'none' }; + } + + /** + * Detect installation status (required by BaseProvider) + */ + async detectInstallation(): Promise { + const installed = await this.isInstalled(); + const version = installed ? await this.getVersion() : undefined; + const auth = await this.checkAuth(); + + return { + installed, + version: version || undefined, + path: this.cliPath || undefined, + method: 'cli', + hasApiKey: !!process.env.CURSOR_API_KEY, + authenticated: auth.authenticated, + }; + } + + /** + * Get available Cursor models + */ + getAvailableModels(): ModelDefinition[] { + return Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => ({ + id: `cursor-${id}`, + name: config.label, + modelString: id, + provider: 'cursor', + description: config.description, + tier: config.tier === 'pro' ? 'premium' : 'basic', + supportsTools: true, + supportsVision: false, // Cursor CLI may not support vision + })); + } + + /** + * Create a CursorError with details + */ + private createError( + code: CursorErrorCode, + message: string, + recoverable: boolean = false, + suggestion?: string + ): CursorError { + const error = new Error(message) as CursorError; + error.code = code; + error.recoverable = recoverable; + error.suggestion = suggestion; + error.name = 'CursorError'; + return error; + } + + /** + * Map stderr/exit codes to detailed CursorError + */ + private mapError(stderr: string, exitCode: number | null): CursorError { + const lower = stderr.toLowerCase(); + + if ( + lower.includes('not authenticated') || + lower.includes('please log in') || + lower.includes('unauthorized') + ) { + return this.createError( + CursorErrorCode.NOT_AUTHENTICATED, + 'Cursor CLI is not authenticated', + true, + 'Run "cursor-agent login" to authenticate with your browser' + ); + } + + if ( + lower.includes('rate limit') || + lower.includes('too many requests') || + lower.includes('429') + ) { + return this.createError( + CursorErrorCode.RATE_LIMITED, + 'Cursor API rate limit exceeded', + true, + 'Wait a few minutes and try again, or upgrade to Cursor Pro' + ); + } + + if ( + lower.includes('model not available') || + lower.includes('invalid model') || + lower.includes('unknown model') + ) { + return this.createError( + CursorErrorCode.MODEL_UNAVAILABLE, + 'Requested model is not available', + true, + 'Try using "auto" mode or select a different model' + ); + } + + if ( + lower.includes('network') || + lower.includes('connection') || + lower.includes('econnrefused') || + lower.includes('timeout') + ) { + return this.createError( + CursorErrorCode.NETWORK_ERROR, + 'Network connection error', + true, + 'Check your internet connection and try again' + ); + } + + if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) { + return this.createError( + CursorErrorCode.PROCESS_CRASHED, + 'Cursor agent process was terminated', + true, + 'The process may have run out of memory. Try a simpler task.' + ); + } + + return this.createError( + CursorErrorCode.UNKNOWN, + stderr || `Cursor agent exited with code ${exitCode}`, + false + ); + } + + /** + * Parse a line of stream-json output + */ + private parseStreamLine(line: string): CursorStreamEvent | null { + if (!line.trim()) return null; + + try { + return JSON.parse(line) as CursorStreamEvent; + } catch { + logger.debug('[CursorProvider] Failed to parse stream line:', line); + return null; + } + } + + /** + * Convert Cursor event to AutoMaker ProviderMessage format + */ + private normalizeEvent(event: CursorStreamEvent): ProviderMessage | null { + switch (event.type) { + case 'system': + // System init - we capture session_id but don't yield a message + return null; + + case 'user': + // User message - already handled by caller + return null; + + case 'assistant': { + const assistantEvent = event as CursorAssistantEvent; + return { + type: 'assistant', + session_id: assistantEvent.session_id, + message: { + role: 'assistant', + content: assistantEvent.message.content.map((c) => ({ + type: 'text' as const, + text: c.text, + })), + }, + }; + } + + case 'tool_call': { + const toolEvent = event as CursorToolCallEvent; + const toolCall = toolEvent.tool_call; + + // Determine tool name and input + let toolName: string; + let toolInput: unknown; + + if (toolCall.readToolCall) { + toolName = 'Read'; + toolInput = { file_path: toolCall.readToolCall.args.path }; + } else if (toolCall.writeToolCall) { + toolName = 'Write'; + toolInput = { + file_path: toolCall.writeToolCall.args.path, + content: toolCall.writeToolCall.args.fileText, + }; + } else if (toolCall.function) { + toolName = toolCall.function.name; + try { + toolInput = JSON.parse(toolCall.function.arguments || '{}'); + } catch { + toolInput = { raw: toolCall.function.arguments }; + } + } else { + return null; + } + + // For started events, emit tool_use + if (toolEvent.subtype === 'started') { + return { + type: 'assistant', + session_id: toolEvent.session_id, + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: toolName, + tool_use_id: toolEvent.call_id, + input: toolInput, + }, + ], + }, + }; + } + + // For completed events, emit tool_result + if (toolEvent.subtype === 'completed') { + let resultContent = ''; + + if (toolCall.readToolCall?.result?.success) { + resultContent = toolCall.readToolCall.result.success.content; + } else if (toolCall.writeToolCall?.result?.success) { + resultContent = `Wrote ${toolCall.writeToolCall.result.success.linesCreated} lines to ${toolCall.writeToolCall.result.success.path}`; + } + + return { + type: 'assistant', + session_id: toolEvent.session_id, + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: toolEvent.call_id, + content: resultContent, + }, + ], + }, + }; + } + + return null; + } + + case 'result': { + const resultEvent = event as CursorResultEvent; + + if (resultEvent.is_error) { + return { + type: 'error', + session_id: resultEvent.session_id, + error: resultEvent.error || resultEvent.result || 'Unknown error', + }; + } + + return { + type: 'result', + subtype: 'success', + session_id: resultEvent.session_id, + result: resultEvent.result, + }; + } + + default: + return null; + } + } + + /** + * Execute a prompt using Cursor CLI with streaming + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + if (!this.cliPath) { + throw this.createError( + CursorErrorCode.NOT_INSTALLED, + 'Cursor CLI is not installed', + true, + 'Install with: curl https://cursor.com/install -fsS | bash' + ); + } + + // Extract model from options (strip 'cursor-' prefix if present) + let model = options.model || 'auto'; + if (model.startsWith('cursor-')) { + model = model.substring(7); + } + + const cwd = options.cwd || process.cwd(); + + // Build prompt content + let promptText: string; + if (typeof options.prompt === 'string') { + promptText = options.prompt; + } else if (Array.isArray(options.prompt)) { + promptText = options.prompt + .filter((p) => p.type === 'text' && p.text) + .map((p) => p.text) + .join('\n'); + } else { + throw new Error('Invalid prompt format'); + } + + // Build CLI arguments + const args: string[] = [ + '-p', // Print mode (non-interactive) + '--force', // Allow file modifications + '--output-format', + 'stream-json', + '--stream-partial-output', // Real-time streaming + ]; + + // Add model if not auto + if (model !== 'auto') { + args.push('--model', model); + } + + // Add the prompt + args.push(promptText); + + logger.debug(`[CursorProvider] Executing: ${this.cliPath} ${args.slice(0, 6).join(' ')}...`); + + // Use spawnJSONLProcess from @automaker/platform for JSONL streaming + // This handles line buffering, timeouts, and abort signals automatically + const subprocessOptions: SubprocessOptions = { + command: this.cliPath, + args, + cwd, + env: { ...process.env }, + abortController: options.abortController, + timeout: 120000, // 2 min timeout for CLI operations (may take longer than default 30s) + }; + + let sessionId: string | undefined; + + try { + // spawnJSONLProcess yields parsed JSON objects, handles errors + for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { + const event = rawEvent as CursorStreamEvent; + + // Capture session ID from system init + if (event.type === 'system' && (event as CursorSystemEvent).subtype === 'init') { + sessionId = event.session_id; + } + + // Normalize and yield the event + const normalized = this.normalizeEvent(event); + if (normalized) { + // Ensure session_id is always set + if (!normalized.session_id && sessionId) { + normalized.session_id = sessionId; + } + yield normalized; + } + } + } catch (error) { + // Use isAbortError from @automaker/utils for abort detection + if (isAbortError(error)) { + return; // Clean abort, don't throw + } + + // Map CLI errors to CursorError + if (error instanceof Error && 'stderr' in error) { + throw this.mapError((error as any).stderr || error.message, (error as any).exitCode); + } + throw error; + } + } + + /** + * Abort the current execution + */ + abort(): void { + if (this.currentProcess) { + this.currentProcess.kill('SIGTERM'); + this.currentProcess = null; + } + } + + /** + * Check if a feature is supported + */ + supportsFeature(feature: string): boolean { + const supported = ['tools', 'text', 'streaming']; + return supported.includes(feature); + } +} +``` + +### Task 2.2: Create Cursor Config Manager + +**Status:** `pending` + +**File:** `apps/server/src/providers/cursor-config-manager.ts` + +```typescript +import * as path from 'path'; +import { CursorCliConfig, CursorModelId } from '@automaker/types'; +import { createLogger, mkdirSafe, existsSafe } from '@automaker/utils'; +import { getAutomakerDir } from '@automaker/platform'; +import { secureFs } from '@automaker/platform'; + +// Create logger for this module +const logger = createLogger('CursorConfigManager'); + +/** + * Manages Cursor CLI configuration + * Config location: .automaker/cursor-config.json + */ +export class CursorConfigManager { + private configPath: string; + private config: CursorCliConfig; + + constructor(projectPath: string) { + // Use getAutomakerDir for consistent path resolution + this.configPath = path.join(getAutomakerDir(projectPath), 'cursor-config.json'); + this.config = this.loadConfig(); + } + + private loadConfig(): CursorCliConfig { + try { + if (fs.existsSync(this.configPath)) { + const content = fs.readFileSync(this.configPath, 'utf8'); + return JSON.parse(content); + } + } catch (error) { + logger.warn('[CursorConfigManager] Failed to load config:', error); + } + + // Return default config + return { + defaultModel: 'auto', + models: ['auto', 'claude-sonnet-4', 'gpt-4o-mini'], + }; + } + + private saveConfig(): void { + try { + const dir = path.dirname(this.configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2)); + logger.debug('[CursorConfigManager] Config saved'); + } catch (error) { + logger.error('[CursorConfigManager] Failed to save config:', error); + throw error; + } + } + + getConfig(): CursorCliConfig { + return { ...this.config }; + } + + getDefaultModel(): CursorModelId { + return this.config.defaultModel || 'auto'; + } + + setDefaultModel(model: CursorModelId): void { + this.config.defaultModel = model; + this.saveConfig(); + } + + getEnabledModels(): CursorModelId[] { + return this.config.models || ['auto']; + } + + setEnabledModels(models: CursorModelId[]): void { + this.config.models = models; + this.saveConfig(); + } + + addModel(model: CursorModelId): void { + if (!this.config.models) { + this.config.models = []; + } + if (!this.config.models.includes(model)) { + this.config.models.push(model); + this.saveConfig(); + } + } + + removeModel(model: CursorModelId): void { + if (this.config.models) { + this.config.models = this.config.models.filter((m) => m !== model); + this.saveConfig(); + } + } +} +``` + +--- + +## Verification + +### Test 1: Provider Instantiation + +```typescript +// test-cursor-provider.ts +import { CursorProvider } from './apps/server/src/providers/cursor-provider'; + +const provider = new CursorProvider(); +console.log('Provider name:', provider.getName()); // Should be 'cursor' + +const status = await provider.detectInstallation(); +console.log('Installation status:', status); + +const models = provider.getAvailableModels(); +console.log('Available models:', models.length); +``` + +### Test 2: CLI Detection (requires cursor-agent installed) + +```bash +# Check if cursor-agent is found +node -e " +const { CursorProvider } = require('./apps/server/dist/providers/cursor-provider'); +const p = new CursorProvider(); +p.isInstalled().then(installed => { + console.log('Installed:', installed); + if (installed) { + p.getVersion().then(v => console.log('Version:', v)); + p.checkAuth().then(a => console.log('Auth:', a)); + } +}); +" +``` + +### Test 3: Simple Query (requires cursor-agent authenticated) + +```typescript +// test-cursor-query.ts +import { CursorProvider } from './apps/server/src/providers/cursor-provider'; + +const provider = new CursorProvider(); +const stream = provider.executeQuery({ + prompt: 'What is 2 + 2? Reply with just the number.', + model: 'auto', + cwd: process.cwd(), +}); + +for await (const msg of stream) { + console.log('Message:', JSON.stringify(msg, null, 2)); +} +``` + +### Test 4: Error Handling + +```typescript +// Test with invalid model +try { + const stream = provider.executeQuery({ + prompt: 'test', + model: 'invalid-model-xyz', + cwd: process.cwd(), + }); + for await (const msg of stream) { + // Should not reach here + } +} catch (error) { + console.log('Error code:', error.code); + console.log('Suggestion:', error.suggestion); +} +``` + +--- + +## Verification Checklist + +Before marking this phase complete: + +- [ ] `cursor-provider.ts` compiles without errors +- [ ] `cursor-config-manager.ts` compiles without errors +- [ ] Provider returns correct name ('cursor') +- [ ] `detectInstallation()` correctly detects CLI +- [ ] `getAvailableModels()` returns model definitions +- [ ] `executeQuery()` streams messages (if CLI installed) +- [ ] Errors are properly mapped to CursorError +- [ ] Abort signal terminates process + +--- + +## Files Changed + +| File | Action | Description | +| ---------------------------------------------------- | ------ | ----------------- | +| `apps/server/src/providers/cursor-provider.ts` | Create | Main provider | +| `apps/server/src/providers/cursor-config-manager.ts` | Create | Config management | + +--- + +## Known Limitations + +1. **Windows Support**: CLI path detection may need adjustment +2. **Vision**: Cursor CLI may not support image inputs +3. **Resume**: Session resumption not implemented in Phase 2 + +--- + +## Notes + +- The provider uses `--stream-partial-output` for real-time character streaming +- Tool call events are normalized to match Claude SDK format +- Session IDs are captured from system init event diff --git a/plan/cursor-cli-integration/phases/phase-3-factory.md b/plan/cursor-cli-integration/phases/phase-3-factory.md new file mode 100644 index 00000000..551d7af5 --- /dev/null +++ b/plan/cursor-cli-integration/phases/phase-3-factory.md @@ -0,0 +1,229 @@ +# Phase 3: Provider Factory Integration + +**Status:** `pending` +**Dependencies:** Phase 2 (Provider) +**Estimated Effort:** Small (routing logic only) + +--- + +## Objective + +Integrate CursorProvider into the ProviderFactory so models are automatically routed to the correct provider. + +--- + +## Tasks + +### Task 3.1: Update Provider Factory + +**Status:** `pending` + +**File:** `apps/server/src/providers/provider-factory.ts` + +Add Cursor provider import and routing: + +```typescript +import { CursorProvider } from './cursor-provider'; +import { CURSOR_MODEL_MAP } from '@automaker/types'; + +export class ProviderFactory { + /** + * Determine which provider to use for a given model + */ + static getProviderNameForModel(model: string): 'claude' | 'cursor' { + const lowerModel = model.toLowerCase(); + + // Check for explicit cursor prefix + if (lowerModel.startsWith('cursor-')) { + return 'cursor'; + } + + // Check if it's a known Cursor model ID + const cursorModelId = lowerModel.replace('cursor-', ''); + if (cursorModelId in CURSOR_MODEL_MAP) { + return 'cursor'; + } + + // Check for Cursor-specific patterns + if ( + lowerModel === 'auto' || + lowerModel.includes('gpt-') || + lowerModel.includes('gemini-') || + lowerModel === 'o3-mini' + ) { + // These could be Cursor models, but we default to Claude + // unless explicitly prefixed with cursor- + } + + // Check for Claude model patterns + if ( + lowerModel.startsWith('claude-') || + ['opus', 'sonnet', 'haiku'].some((n) => lowerModel.includes(n)) + ) { + return 'claude'; + } + + // Default to Claude + return 'claude'; + } + + /** + * Get a provider instance for the given model + */ + static getProviderForModel(model: string, config?: ProviderConfig): BaseProvider { + const providerName = this.getProviderNameForModel(model); + + if (providerName === 'cursor') { + return new CursorProvider(config); + } + + return new ClaudeProvider(config); + } + + /** + * Get all registered providers + */ + static getAllProviders(): BaseProvider[] { + return [new ClaudeProvider(), new CursorProvider()]; + } + + /** + * Get a provider by name + */ + static getProviderByName(name: string): BaseProvider | null { + const lowerName = name.toLowerCase(); + + switch (lowerName) { + case 'claude': + return new ClaudeProvider(); + case 'cursor': + return new CursorProvider(); + default: + return null; + } + } + + /** + * Check installation status of all providers + */ + static async checkAllProviders(): Promise> { + const providers = this.getAllProviders(); + const statuses: Record = {}; + + await Promise.all( + providers.map(async (provider) => { + const status = await provider.detectInstallation(); + statuses[provider.getName()] = status; + }) + ); + + return statuses; + } + + /** + * Get all available models from all providers + */ + static getAllAvailableModels(): ModelDefinition[] { + const providers = this.getAllProviders(); + return providers.flatMap((p) => p.getAvailableModels()); + } +} +``` + +### Task 3.2: Export CursorProvider + +**Status:** `pending` + +**File:** `apps/server/src/providers/index.ts` + +Add export: + +```typescript +export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider'; +export { CursorConfigManager } from './cursor-config-manager'; +``` + +--- + +## Verification + +### Test 1: Model Routing + +```typescript +import { ProviderFactory } from './apps/server/src/providers/provider-factory'; + +// Cursor models +console.assert(ProviderFactory.getProviderNameForModel('cursor-auto') === 'cursor'); +console.assert(ProviderFactory.getProviderNameForModel('cursor-gpt-4o') === 'cursor'); +console.assert(ProviderFactory.getProviderNameForModel('cursor-claude-sonnet-4') === 'cursor'); + +// Claude models (default) +console.assert(ProviderFactory.getProviderNameForModel('claude-sonnet-4') === 'claude'); +console.assert(ProviderFactory.getProviderNameForModel('opus') === 'claude'); +console.assert(ProviderFactory.getProviderNameForModel('sonnet') === 'claude'); +console.assert(ProviderFactory.getProviderNameForModel('haiku') === 'claude'); + +// Unknown models default to Claude +console.assert(ProviderFactory.getProviderNameForModel('unknown-model') === 'claude'); + +console.log('All routing tests passed!'); +``` + +### Test 2: Provider Instantiation + +```typescript +import { ProviderFactory } from './apps/server/src/providers/provider-factory'; + +const cursorProvider = ProviderFactory.getProviderForModel('cursor-auto'); +console.assert(cursorProvider.getName() === 'cursor'); + +const claudeProvider = ProviderFactory.getProviderForModel('sonnet'); +console.assert(claudeProvider.getName() === 'claude'); + +console.log('Provider instantiation tests passed!'); +``` + +### Test 3: All Providers Check + +```typescript +import { ProviderFactory } from './apps/server/src/providers/provider-factory'; + +const statuses = await ProviderFactory.checkAllProviders(); +console.log('Provider statuses:', statuses); +// Should have both 'claude' and 'cursor' keys + +const allModels = ProviderFactory.getAllAvailableModels(); +console.log('Total models:', allModels.length); +// Should include models from both providers +``` + +--- + +## Verification Checklist + +Before marking this phase complete: + +- [ ] ProviderFactory routes `cursor-*` models to CursorProvider +- [ ] ProviderFactory routes Claude models to ClaudeProvider +- [ ] `getAllProviders()` returns both providers +- [ ] `getProviderByName('cursor')` returns CursorProvider +- [ ] `checkAllProviders()` returns status for both providers +- [ ] `getAllAvailableModels()` includes Cursor models +- [ ] Existing Claude routing not broken + +--- + +## Files Changed + +| File | Action | Description | +| ----------------------------------------------- | ------ | --------------------- | +| `apps/server/src/providers/provider-factory.ts` | Modify | Add Cursor routing | +| `apps/server/src/providers/index.ts` | Modify | Export CursorProvider | + +--- + +## Notes + +- Model routing uses prefix matching for explicit `cursor-` models +- Unknown models default to Claude for backward compatibility +- The factory is stateless - new provider instances created per call diff --git a/plan/cursor-cli-integration/phases/phase-4-routes.md b/plan/cursor-cli-integration/phases/phase-4-routes.md new file mode 100644 index 00000000..bf71750a --- /dev/null +++ b/plan/cursor-cli-integration/phases/phase-4-routes.md @@ -0,0 +1,348 @@ +# Phase 4: Setup Routes & Status Endpoints + +**Status:** `pending` +**Dependencies:** Phase 3 (Factory) +**Estimated Effort:** Medium (API endpoints) + +--- + +## Objective + +Create API endpoints for checking Cursor CLI status and managing configuration. + +--- + +## Tasks + +### Task 4.1: Create Cursor Status Route + +**Status:** `pending` + +**File:** `apps/server/src/routes/setup/routes/cursor-status.ts` + +```typescript +import { Router, Request, Response } from 'express'; +import { CursorProvider } from '../../../providers/cursor-provider'; +import { createLogger } from '@automaker/utils'; + +// Create logger for this module +const logger = createLogger('CursorStatusRoute'); + +/** + * GET /api/setup/cursor-status + * Returns Cursor CLI installation and authentication status + */ +export function createCursorStatusHandler() { + return async (req: Request, res: Response) => { + try { + const provider = new CursorProvider(); + + const [installed, version, auth] = await Promise.all([ + provider.isInstalled(), + provider.getVersion(), + provider.checkAuth(), + ]); + + res.json({ + success: true, + installed, + version: version || null, + path: installed ? (provider as any).cliPath : null, + auth: { + authenticated: auth.authenticated, + method: auth.method, + }, + installCommand: 'curl https://cursor.com/install -fsS | bash', + loginCommand: 'cursor-agent login', + }); + } catch (error) { + logger.error('[cursor-status] Error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }; +} + +export function createCursorStatusRoute(): Router { + const router = Router(); + router.get('/cursor-status', createCursorStatusHandler()); + return router; +} +``` + +### Task 4.2: Create Cursor Config Routes + +**Status:** `pending` + +**File:** `apps/server/src/routes/setup/routes/cursor-config.ts` + +```typescript +import { Router, Request, Response } from 'express'; +import { CursorConfigManager } from '../../../providers/cursor-config-manager'; +import { CURSOR_MODEL_MAP, CursorModelId } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; + +// Create logger for this module +const logger = createLogger('CursorConfigRoute'); + +export function createCursorConfigRoutes(dataDir: string): Router { + const router = Router(); + const configManager = new CursorConfigManager(dataDir); + + /** + * GET /api/setup/cursor-config + * Get current Cursor configuration + */ + router.get('/cursor-config', (req: Request, res: Response) => { + try { + res.json({ + success: true, + config: configManager.getConfig(), + availableModels: Object.values(CURSOR_MODEL_MAP), + }); + } catch (error) { + logger.error('[cursor-config] GET error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * POST /api/setup/cursor-config/default-model + * Set the default Cursor model + */ + router.post('/cursor-config/default-model', (req: Request, res: Response) => { + try { + const { model } = req.body; + + if (!model || !(model in CURSOR_MODEL_MAP)) { + res.status(400).json({ + success: false, + error: `Invalid model ID. Valid models: ${Object.keys(CURSOR_MODEL_MAP).join(', ')}`, + }); + return; + } + + configManager.setDefaultModel(model as CursorModelId); + res.json({ success: true, model }); + } catch (error) { + logger.error('[cursor-config] POST default-model error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + /** + * POST /api/setup/cursor-config/models + * Set enabled Cursor models + */ + router.post('/cursor-config/models', (req: Request, res: Response) => { + try { + const { models } = req.body; + + if (!Array.isArray(models)) { + res.status(400).json({ + success: false, + error: 'Models must be an array', + }); + return; + } + + // Filter to valid models only + const validModels = models.filter((m): m is CursorModelId => m in CURSOR_MODEL_MAP); + + if (validModels.length === 0) { + res.status(400).json({ + success: false, + error: 'No valid models provided', + }); + return; + } + + configManager.setEnabledModels(validModels); + res.json({ success: true, models: validModels }); + } catch (error) { + logger.error('[cursor-config] POST models error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + return router; +} +``` + +### Task 4.3: Register Routes in Setup Index + +**Status:** `pending` + +**File:** `apps/server/src/routes/setup/index.ts` + +Add to existing router: + +```typescript +import { createCursorStatusRoute } from './routes/cursor-status'; +import { createCursorConfigRoutes } from './routes/cursor-config'; + +// In the router setup function: +export function createSetupRouter(dataDir: string): Router { + const router = Router(); + + // Existing routes... + router.get('/claude-status', createClaudeStatusHandler()); + // ... + + // Add Cursor routes + router.use(createCursorStatusRoute()); + router.use(createCursorConfigRoutes(dataDir)); + + return router; +} +``` + +### Task 4.4: Update HttpApiClient + +**Status:** `pending` + +**File:** `apps/ui/src/lib/http-api-client.ts` + +Add Cursor methods to the HttpApiClient setup object: + +```typescript +// In HttpApiClient class, extend the setup object: + +setup = { + // Existing methods... + getClaudeStatus: () => this.get('/api/setup/claude-status'), + + // Add Cursor methods + getCursorStatus: () => + this.get<{ + success: boolean; + installed?: boolean; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + }; + installCommand?: string; + loginCommand?: string; + error?: string; + }>('/api/setup/cursor-status'), + + getCursorConfig: () => + this.get<{ + success: boolean; + config?: CursorCliConfig; + availableModels?: CursorModelConfig[]; + error?: string; + }>('/api/setup/cursor-config'), + + setCursorDefaultModel: (model: CursorModelId) => + this.post<{ success: boolean; error?: string }>('/api/setup/cursor-config/default-model', { + model, + }), + + setCursorModels: (models: CursorModelId[]) => + this.post<{ success: boolean; error?: string }>('/api/setup/cursor-config/models', { models }), +}; +``` + +This integrates with the existing HttpApiClient pattern used throughout the UI. + +--- + +## Verification + +### Test 1: Status Endpoint + +```bash +# Start the server, then: +curl http://localhost:3001/api/setup/cursor-status + +# Expected response (if installed): +# { +# "success": true, +# "installed": true, +# "version": "0.1.0", +# "path": "/home/user/.local/bin/cursor-agent", +# "auth": { "authenticated": true, "method": "login" } +# } + +# Expected response (if not installed): +# { +# "success": true, +# "installed": false, +# "installCommand": "curl https://cursor.com/install -fsS | bash" +# } +``` + +### Test 2: Config Endpoints + +```bash +# Get config +curl http://localhost:3001/api/setup/cursor-config + +# Set default model +curl -X POST http://localhost:3001/api/setup/cursor-config/default-model \ + -H "Content-Type: application/json" \ + -d '{"model": "gpt-4o"}' + +# Set enabled models +curl -X POST http://localhost:3001/api/setup/cursor-config/models \ + -H "Content-Type: application/json" \ + -d '{"models": ["auto", "gpt-4o", "claude-sonnet-4"]}' +``` + +### Test 3: Error Handling + +```bash +# Invalid model should return 400 +curl -X POST http://localhost:3001/api/setup/cursor-config/default-model \ + -H "Content-Type: application/json" \ + -d '{"model": "invalid-model"}' + +# Expected: {"success": false, "error": "Invalid model ID..."} +``` + +--- + +## Verification Checklist + +Before marking this phase complete: + +- [ ] `/api/setup/cursor-status` returns installation status +- [ ] `/api/setup/cursor-config` returns current config +- [ ] `/api/setup/cursor-config/default-model` updates default +- [ ] `/api/setup/cursor-config/models` updates enabled models +- [ ] Error responses have correct status codes (400, 500) +- [ ] Config persists to file after changes +- [ ] SetupAPI type updated (if using Electron IPC) + +--- + +## Files Changed + +| File | Action | Description | +| ------------------------------------------------------ | ------ | ---------------- | +| `apps/server/src/routes/setup/routes/cursor-status.ts` | Create | Status endpoint | +| `apps/server/src/routes/setup/routes/cursor-config.ts` | Create | Config endpoints | +| `apps/server/src/routes/setup/index.ts` | Modify | Register routes | +| `apps/ui/src/lib/electron.ts` | Modify | Add API types | + +--- + +## Notes + +- Config is stored in `.automaker/cursor-config.json` +- The status endpoint is optimized for quick checks (parallel calls) +- Install/login commands are included in response for UI display diff --git a/plan/cursor-cli-integration/phases/phase-5-log-parser.md b/plan/cursor-cli-integration/phases/phase-5-log-parser.md new file mode 100644 index 00000000..9ba54b53 --- /dev/null +++ b/plan/cursor-cli-integration/phases/phase-5-log-parser.md @@ -0,0 +1,374 @@ +# Phase 5: Log Parser Integration + +**Status:** `pending` +**Dependencies:** Phase 2 (Provider), Phase 3 (Factory) +**Estimated Effort:** Small (parser extension) + +--- + +## Objective + +Update the log parser to recognize and normalize Cursor CLI stream events for display in the log viewer. + +--- + +## Tasks + +### Task 5.1: Add Cursor Event Type Detection + +**Status:** `pending` + +**File:** `apps/ui/src/lib/log-parser.ts` + +Add Cursor event detection and normalization: + +```typescript +import { + CursorStreamEvent, + CursorSystemEvent, + CursorAssistantEvent, + CursorToolCallEvent, + CursorResultEvent, +} from '@automaker/types'; + +/** + * Detect if a parsed JSON object is a Cursor stream event + */ +function isCursorEvent(obj: any): obj is CursorStreamEvent { + return ( + obj && + typeof obj === 'object' && + 'type' in obj && + 'session_id' in obj && + ['system', 'user', 'assistant', 'tool_call', 'result'].includes(obj.type) + ); +} + +/** + * Normalize Cursor stream event to log entry + */ +export function normalizeCursorEvent(event: CursorStreamEvent): LogEntry | null { + const timestamp = new Date().toISOString(); + const baseEntry = { + id: `cursor-${event.session_id}-${Date.now()}`, + timestamp, + }; + + switch (event.type) { + case 'system': { + const sysEvent = event as CursorSystemEvent; + return { + ...baseEntry, + type: 'info' as LogEntryType, + title: 'Session Started', + content: `Model: ${sysEvent.model}\nAuth: ${sysEvent.apiKeySource}\nCWD: ${sysEvent.cwd}`, + collapsed: true, + metadata: { + phase: 'init', + }, + }; + } + + case 'assistant': { + const assistEvent = event as CursorAssistantEvent; + const text = assistEvent.message.content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join(''); + + if (!text.trim()) return null; + + return { + ...baseEntry, + type: 'info' as LogEntryType, + title: 'Assistant', + content: text, + collapsed: false, + }; + } + + case 'tool_call': { + const toolEvent = event as CursorToolCallEvent; + return normalizeCursorToolCall(toolEvent, baseEntry); + } + + case 'result': { + const resultEvent = event as CursorResultEvent; + + if (resultEvent.is_error) { + return { + ...baseEntry, + type: 'error' as LogEntryType, + title: 'Error', + content: resultEvent.error || resultEvent.result || 'Unknown error', + collapsed: false, + }; + } + + return { + ...baseEntry, + type: 'success' as LogEntryType, + title: 'Completed', + content: `Duration: ${resultEvent.duration_ms}ms`, + collapsed: true, + }; + } + + default: + return null; + } +} + +/** + * Normalize Cursor tool call event + */ +function normalizeCursorToolCall( + event: CursorToolCallEvent, + baseEntry: { id: string; timestamp: string } +): LogEntry | null { + const toolCall = event.tool_call; + const isStarted = event.subtype === 'started'; + const isCompleted = event.subtype === 'completed'; + + // Read tool + if (toolCall.readToolCall) { + const path = toolCall.readToolCall.args.path; + const result = toolCall.readToolCall.result?.success; + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: isStarted ? `Reading ${path}` : `Read ${path}`, + content: + isCompleted && result + ? `${result.totalLines} lines, ${result.totalChars} chars` + : `Path: ${path}`, + collapsed: true, + metadata: { + toolName: 'Read', + toolCategory: 'read' as ToolCategory, + filePath: path, + summary: isCompleted ? `Read ${result?.totalLines || 0} lines` : `Reading file...`, + }, + }; + } + + // Write tool + if (toolCall.writeToolCall) { + const path = + toolCall.writeToolCall.args?.path || + toolCall.writeToolCall.result?.success?.path || + 'unknown'; + const result = toolCall.writeToolCall.result?.success; + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: isStarted ? `Writing ${path}` : `Wrote ${path}`, + content: + isCompleted && result + ? `${result.linesCreated} lines, ${result.fileSize} bytes` + : `Path: ${path}`, + collapsed: true, + metadata: { + toolName: 'Write', + toolCategory: 'write' as ToolCategory, + filePath: path, + summary: isCompleted ? `Wrote ${result?.linesCreated || 0} lines` : `Writing file...`, + }, + }; + } + + // Generic function tool + if (toolCall.function) { + const name = toolCall.function.name; + const args = toolCall.function.arguments; + + // Determine category based on tool name + let category: ToolCategory = 'other'; + if (['Read', 'Glob'].includes(name)) category = 'read'; + if (['Write', 'Edit'].includes(name)) category = 'edit'; + if (['Bash'].includes(name)) category = 'bash'; + if (['Grep'].includes(name)) category = 'search'; + if (['TodoWrite'].includes(name)) category = 'todo'; + if (['Task'].includes(name)) category = 'task'; + + return { + ...baseEntry, + id: `${baseEntry.id}-${event.call_id}`, + type: 'tool_call' as LogEntryType, + title: `${name} ${isStarted ? 'started' : 'completed'}`, + content: args || '', + collapsed: true, + metadata: { + toolName: name, + toolCategory: category, + summary: `${name} ${event.subtype}`, + }, + }; + } + + return null; +} +``` + +### Task 5.2: Update parseLogLine Function + +**Status:** `pending` + +**File:** `apps/ui/src/lib/log-parser.ts` + +Update the main parsing function to detect Cursor events: + +```typescript +/** + * Parse a single log line into a structured entry + */ +export function parseLogLine(line: string): LogEntry | null { + if (!line.trim()) return null; + + try { + const parsed = JSON.parse(line); + + // Check if it's a Cursor stream event + if (isCursorEvent(parsed)) { + return normalizeCursorEvent(parsed); + } + + // Existing AutoMaker/Claude event parsing... + return parseAutoMakerEvent(parsed); + } catch { + // Non-JSON line - treat as plain text + return { + id: `text-${Date.now()}-${Math.random().toString(36).slice(2)}`, + type: 'info', + title: 'Output', + content: line, + timestamp: new Date().toISOString(), + collapsed: false, + }; + } +} +``` + +### Task 5.3: Add Cursor-Specific Styling (Optional) + +**Status:** `pending` + +**File:** `apps/ui/src/lib/log-parser.ts` + +Add provider-aware styling: + +```typescript +/** + * Get provider-specific styling for log entries + */ +export function getProviderStyle(entry: LogEntry): { badge?: string; icon?: string } { + // Check if entry has Cursor session ID pattern + if (entry.id.startsWith('cursor-')) { + return { + badge: 'Cursor', + icon: 'terminal', // Or a Cursor-specific icon + }; + } + + // Default (Claude) + return { + badge: 'Claude', + icon: 'bot', + }; +} +``` + +--- + +## Verification + +### Test 1: Cursor Event Parsing + +```typescript +import { parseLogLine, normalizeCursorEvent } from './apps/ui/src/lib/log-parser'; + +// Test system init +const systemEvent = + '{"type":"system","subtype":"init","apiKeySource":"login","cwd":"/project","session_id":"abc-123","model":"Claude 4 Sonnet","permissionMode":"default"}'; +const systemEntry = parseLogLine(systemEvent); +console.assert(systemEntry?.type === 'info', 'System event should be info type'); +console.assert(systemEntry?.title === 'Session Started', 'System should have correct title'); + +// Test assistant message +const assistantEvent = + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello world"}]},"session_id":"abc-123"}'; +const assistantEntry = parseLogLine(assistantEvent); +console.assert(assistantEntry?.content === 'Hello world', 'Assistant content should match'); + +// Test tool call +const toolEvent = + '{"type":"tool_call","subtype":"started","call_id":"call-1","tool_call":{"readToolCall":{"args":{"path":"test.ts"}}},"session_id":"abc-123"}'; +const toolEntry = parseLogLine(toolEvent); +console.assert(toolEntry?.metadata?.toolName === 'Read', 'Tool name should be Read'); +console.assert(toolEntry?.metadata?.toolCategory === 'read', 'Category should be read'); + +console.log('All Cursor parsing tests passed!'); +``` + +### Test 2: Mixed Event Stream + +```typescript +// Simulate a stream with both Claude and Cursor events +const events = [ + // Cursor events + '{"type":"system","subtype":"init","session_id":"cur-1","model":"GPT-4o","apiKeySource":"login","cwd":"/project","permissionMode":"default"}', + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Reading file..."}]},"session_id":"cur-1"}', + '{"type":"tool_call","subtype":"started","call_id":"t1","tool_call":{"readToolCall":{"args":{"path":"README.md"}}},"session_id":"cur-1"}', + // Claude-style event (existing format) + '{"type":"assistant","content":[{"type":"text","text":"From Claude"}]}', +]; + +const entries = events.map(parseLogLine).filter(Boolean); +console.log('Parsed entries:', entries.length); +// Should parse all events correctly +``` + +### Test 3: Log Viewer Integration + +1. Start the app with a Cursor provider task +2. Observe log viewer updates in real-time +3. Verify: + - Tool calls show correct icons + - File paths are highlighted + - Collapsed by default where appropriate + - Timestamps are displayed + +--- + +## Verification Checklist + +Before marking this phase complete: + +- [ ] `isCursorEvent()` correctly identifies Cursor events +- [ ] `normalizeCursorEvent()` handles all event types +- [ ] Tool calls are categorized correctly +- [ ] File paths extracted for Read/Write tools +- [ ] Existing Claude event parsing not broken +- [ ] Log viewer displays Cursor events correctly +- [ ] No runtime errors with malformed events + +--- + +## Files Changed + +| File | Action | Description | +| ------------------------------- | ------ | ------------------------------ | +| `apps/ui/src/lib/log-parser.ts` | Modify | Add Cursor event normalization | + +--- + +## Notes + +- Cursor events have `session_id` on all events (unlike Claude SDK) +- Tool call events come in pairs: started + completed +- The `call_id` is used to correlate started/completed events +- Entry IDs include session_id for uniqueness diff --git a/plan/cursor-cli-integration/phases/phase-6-setup-wizard.md b/plan/cursor-cli-integration/phases/phase-6-setup-wizard.md new file mode 100644 index 00000000..453fcbf4 --- /dev/null +++ b/plan/cursor-cli-integration/phases/phase-6-setup-wizard.md @@ -0,0 +1,457 @@ +# Phase 6: UI Setup Wizard + +**Status:** `pending` +**Dependencies:** Phase 4 (Routes) +**Estimated Effort:** Medium (React component) + +--- + +## Objective + +Add an optional Cursor CLI setup step to the welcome wizard, allowing users to configure Cursor as an AI provider during initial setup. + +--- + +## Tasks + +### Task 6.1: Create Cursor Setup Step Component + +**Status:** `pending` + +**File:** `apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx` + +```tsx +import React, { useState, useEffect, useCallback } from 'react'; +import { CheckCircle2, XCircle, Loader2, ExternalLink, Terminal, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; +import { api } from '@/lib/http-api-client'; + +interface CursorSetupStepProps { + onComplete: () => void; + onSkip: () => void; +} + +interface CliStatus { + installed: boolean; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + }; + installCommand?: string; + loginCommand?: string; +} + +export function CursorSetupStep({ onComplete, onSkip }: CursorSetupStepProps) { + const [status, setStatus] = useState(null); + const [isChecking, setIsChecking] = useState(true); + const [isLoggingIn, setIsLoggingIn] = useState(false); + + const checkStatus = useCallback(async () => { + setIsChecking(true); + try { + const result = await api.setup.getCursorStatus(); + + if (result.success) { + setStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + + if (result.auth?.authenticated) { + toast.success('Cursor CLI is ready!'); + } + } else { + toast.error('Failed to check Cursor status'); + } + } catch (error) { + console.error('Failed to check Cursor status:', error); + toast.error('Failed to check Cursor CLI status'); + } finally { + setIsChecking(false); + } + }, []); + + useEffect(() => { + checkStatus(); + }, [checkStatus]); + + const handleLogin = async () => { + setIsLoggingIn(true); + + try { + // Copy login command to clipboard and show instructions + if (status?.loginCommand) { + await navigator.clipboard.writeText(status.loginCommand); + toast.info('Login command copied! Paste in terminal to authenticate.'); + } + + // Poll for auth status + let attempts = 0; + const maxAttempts = 60; // 2 minutes with 2s interval + + const pollInterval = setInterval(async () => { + attempts++; + + try { + const result = await api.setup.getCursorStatus(); + + if (result.auth?.authenticated) { + clearInterval(pollInterval); + setStatus((prev) => (prev ? { ...prev, auth: result.auth } : null)); + setIsLoggingIn(false); + toast.success('Successfully logged in to Cursor!'); + } + } catch { + // Ignore polling errors + } + + if (attempts >= maxAttempts) { + clearInterval(pollInterval); + setIsLoggingIn(false); + toast.error('Login timed out. Please try again.'); + } + }, 2000); + } catch (error) { + console.error('Login failed:', error); + toast.error('Failed to start login process'); + setIsLoggingIn(false); + } + }; + + const handleCopyInstallCommand = async () => { + if (status?.installCommand) { + await navigator.clipboard.writeText(status.installCommand); + toast.success('Install command copied to clipboard!'); + } + }; + + const isComplete = status?.installed && status?.auth?.authenticated; + + return ( + + + + + Cursor CLI Setup + + Optional + + + + Configure Cursor CLI as an alternative AI provider. You can skip this and use Claude + instead, or configure it later in Settings. + + + + + {/* Installation Status */} +
+
+ CLI Installation + {isChecking ? ( + + ) : status?.installed ? ( +
+ + v{status.version} +
+ ) : ( +
+ + Not installed +
+ )} +
+ + {!status?.installed && !isChecking && ( + + +

Install Cursor CLI to use Cursor models:

+
+ + {status?.installCommand || 'curl https://cursor.com/install -fsS | bash'} + + +
+ +
+
+ )} +
+ + {/* Authentication Status */} + {status?.installed && ( +
+
+ Authentication + {status.auth?.authenticated ? ( +
+ + + {status.auth.method === 'api_key' ? 'API Key' : 'Browser Login'} + +
+ ) : ( +
+ + Not authenticated +
+ )} +
+ + {!status.auth?.authenticated && ( +
+

+ Run the login command in your terminal, then complete authentication in your + browser: +

+
+ + {status.loginCommand || 'cursor-agent login'} + +
+ +
+ )} +
+ )} + + {/* Action Buttons */} +
+ + + +
+ + {/* Info note */} +

+ You can always configure Cursor later in Settings → Providers +

+
+
+ ); +} + +export default CursorSetupStep; +``` + +### Task 6.2: Update Setup View Steps + +**Status:** `pending` + +**File:** `apps/ui/src/components/views/setup-view.tsx` + +Add the Cursor step to the wizard: + +```tsx +import { CursorSetupStep } from './setup-view/steps/cursor-setup-step'; + +// Add to steps configuration +const SETUP_STEPS = [ + // Existing steps... + { + id: 'claude', + title: 'Claude CLI', + optional: false, + component: ClaudeSetupStep, + }, + // Add Cursor step + { + id: 'cursor', + title: 'Cursor CLI', + optional: true, + component: CursorSetupStep, + }, + // Remaining steps... + { + id: 'project', + title: 'Project', + optional: false, + component: ProjectSetupStep, + }, +]; + +// In the render function, handle optional steps: +function SetupView() { + const [currentStep, setCurrentStep] = useState(0); + const [skippedSteps, setSkippedSteps] = useState>(new Set()); + + const handleSkip = (stepId: string) => { + setSkippedSteps((prev) => new Set([...prev, stepId])); + setCurrentStep((prev) => prev + 1); + }; + + const handleComplete = () => { + setCurrentStep((prev) => prev + 1); + }; + + const step = SETUP_STEPS[currentStep]; + const StepComponent = step.component; + + return ( +
+ {/* Progress indicator */} +
+ {SETUP_STEPS.map((s, i) => ( +
+ ))} +
+ + {/* Step title */} +

+ {step.title} + {step.optional && (Optional)} +

+ + {/* Step component */} + handleSkip(step.id)} /> +
+ ); +} +``` + +### Task 6.3: Add Step Indicator for Optional Steps + +**Status:** `pending` + +Add visual indicator for optional vs required steps in the progress bar. + +--- + +## Verification + +### Test 1: Component Rendering + +1. Start the app with a fresh setup (or clear setup state) +2. Navigate through setup steps +3. Verify Cursor step appears after Claude step +4. Verify "Optional" badge is displayed + +### Test 2: Skip Functionality + +1. Click "Skip for now" on Cursor step +2. Verify step is skipped and progress continues +3. Verify skipped state is persisted (if applicable) + +### Test 3: Installation Detection + +1. With cursor-agent NOT installed: + - Should show "Not installed" status + - Should show install command + - Continue button should be disabled + +2. With cursor-agent installed but not authenticated: + - Should show version number + - Should show "Not authenticated" status + - Should show login instructions + +3. With cursor-agent installed and authenticated: + - Should show green checkmarks + - Continue button should be enabled + +### Test 4: Login Flow + +1. Click "Copy Command & Wait for Login" +2. Verify command is copied to clipboard +3. Run login command in terminal +4. Verify status updates after authentication +5. Verify success toast appears + +### Test 5: Refresh Status + +1. Click refresh button +2. Verify loading state is shown +3. Verify status is re-fetched + +--- + +## Verification Checklist + +Before marking this phase complete: + +- [ ] CursorSetupStep component renders correctly +- [ ] Step appears in setup wizard flow +- [ ] Skip button works and progresses to next step +- [ ] Installation status is correctly detected +- [ ] Authentication status is correctly detected +- [ ] Login command copy works +- [ ] Polling for auth status works +- [ ] Refresh button updates status +- [ ] Error states handled gracefully +- [ ] Progress indicator shows optional step differently + +--- + +## Files Changed + +| File | Action | Description | +| --------------------------------------------------------------------- | ------ | -------------------- | +| `apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx` | Create | Setup step component | +| `apps/ui/src/components/views/setup-view.tsx` | Modify | Add step to wizard | + +--- + +## Design Notes + +- The step is marked as optional with a badge +- Skip button is always available for optional steps +- The login flow is asynchronous with polling +- Status can be manually refreshed +- Error states show clear recovery instructions diff --git a/plan/cursor-cli-integration/phases/phase-7-settings.md b/plan/cursor-cli-integration/phases/phase-7-settings.md new file mode 100644 index 00000000..ecbad2b2 --- /dev/null +++ b/plan/cursor-cli-integration/phases/phase-7-settings.md @@ -0,0 +1,556 @@ +# Phase 7: Settings View Provider Tabs + +**Status:** `pending` +**Dependencies:** Phase 4 (Routes) +**Estimated Effort:** Medium (React components) + +--- + +## Objective + +Create a tabbed interface in Settings for managing different AI providers (Claude and Cursor), with provider-specific configuration options. + +--- + +## Tasks + +### Task 7.1: Create Cursor Settings Tab Component + +**Status:** `pending` + +**File:** `apps/ui/src/components/views/settings-view/providers/cursor-settings-tab.tsx` + +```tsx +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Terminal, CheckCircle2, XCircle, Loader2, RefreshCw, ExternalLink } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/lib/http-api-client'; +import { + CursorModelId, + CursorModelConfig, + CursorCliConfig, + CURSOR_MODEL_MAP, +} from '@automaker/types'; + +interface CursorStatus { + installed: boolean; + version?: string; + authenticated: boolean; + method?: string; +} + +export function CursorSettingsTab() { + const [status, setStatus] = useState(null); + const [config, setConfig] = useState(null); + const [availableModels, setAvailableModels] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const loadData = async () => { + setIsLoading(true); + try { + const [statusData, configData] = await Promise.all([ + api.setup.getCursorStatus(), + api.setup.getCursorConfig(), + ]); + + if (statusData.success) { + setStatus({ + installed: statusData.installed ?? false, + version: statusData.version, + authenticated: statusData.auth?.authenticated ?? false, + method: statusData.auth?.method, + }); + } + + if (configData.success) { + setConfig(configData.config); + setAvailableModels(configData.availableModels || Object.values(CURSOR_MODEL_MAP)); + } + } catch (error) { + console.error('Failed to load Cursor settings:', error); + toast.error('Failed to load Cursor settings'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, []); + + const handleDefaultModelChange = async (model: CursorModelId) => { + if (!config) return; + + setIsSaving(true); + try { + const result = await api.setup.setCursorDefaultModel(model); + + if (result.success) { + setConfig({ ...config, defaultModel: model }); + toast.success('Default model updated'); + } else { + toast.error(result.error || 'Failed to update default model'); + } + } catch (error) { + toast.error('Failed to update default model'); + } finally { + setIsSaving(false); + } + }; + + const handleModelToggle = async (model: CursorModelId, enabled: boolean) => { + if (!config) return; + + const newModels = enabled + ? [...(config.models || []), model] + : (config.models || []).filter((m) => m !== model); + + setIsSaving(true); + try { + const result = await api.setup.setCursorModels(newModels); + + if (result.success) { + setConfig({ ...config, models: newModels }); + } else { + toast.error(result.error || 'Failed to update models'); + } + } catch (error) { + toast.error('Failed to update models'); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Status Card */} + + + + + Cursor CLI Status + + + + {/* Installation */} +
+ Installation + {status?.installed ? ( +
+ + v{status.version} +
+ ) : ( +
+ + Not installed +
+ )} +
+ + {/* Authentication */} +
+ Authentication + {status?.authenticated ? ( +
+ + + {status.method === 'api_key' ? 'API Key' : 'Browser Login'} + +
+ ) : ( +
+ + Not authenticated +
+ )} +
+ + {/* Actions */} +
+ + {!status?.installed && ( + + )} +
+
+
+ + {/* Model Configuration */} + {status?.installed && config && ( + + + Model Configuration + + Configure which Cursor models are available and set the default + + + + {/* Default Model */} +
+ + +
+ + {/* Enabled Models */} +
+ +
+ {availableModels.map((model) => { + const isEnabled = config.models?.includes(model.id) ?? false; + const isAuto = model.id === 'auto'; + + return ( +
+
+ handleModelToggle(model.id, !!checked)} + disabled={isSaving || isAuto} + /> +
+
+ {model.label} + {model.hasThinking && ( + + Thinking + + )} +
+

{model.description}

+
+
+ + {model.tier} + +
+ ); + })} +
+
+
+
+ )} + + {/* Not Installed State */} + {!status?.installed && ( + + + +

Cursor CLI is not installed.

+

Install it to use Cursor models in AutoMaker.

+
+
+ )} +
+ ); +} + +export default CursorSettingsTab; +``` + +### Task 7.2: Create Provider Tabs Container + +**Status:** `pending` + +**File:** `apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx` + +```tsx +import React from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Bot, Terminal } from 'lucide-react'; +import { CursorSettingsTab } from './cursor-settings-tab'; +import { ClaudeSettingsTab } from './claude-settings-tab'; + +interface ProviderTabsProps { + defaultTab?: 'claude' | 'cursor'; +} + +export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { + return ( + + + + + Claude + + + + Cursor + + + + + + + + + + + + ); +} + +export default ProviderTabs; +``` + +### Task 7.3: Create Claude Settings Tab (if not exists) + +**Status:** `pending` + +**File:** `apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx` + +```tsx +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Bot, CheckCircle2, XCircle, Loader2, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/lib/http-api-client'; + +interface ClaudeStatus { + installed: boolean; + version?: string; + authenticated: boolean; + method?: string; +} + +export function ClaudeSettingsTab() { + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const loadStatus = async () => { + setIsLoading(true); + try { + const result = await api.setup.getClaudeStatus(); + + if (result.success) { + setStatus({ + installed: result.installed ?? true, + version: result.version, + authenticated: result.authenticated ?? false, + method: result.method, + }); + } + } catch (error) { + console.error('Failed to load Claude status:', error); + toast.error('Failed to load Claude status'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadStatus(); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + + Claude Status + + Claude is the primary AI provider for AutoMaker + + +
+ SDK Status +
+ + Active +
+
+ +
+ Authentication + {status?.authenticated ? ( +
+ + {status.method} +
+ ) : ( +
+ + Not authenticated +
+ )} +
+ + +
+
+
+ ); +} + +export default ClaudeSettingsTab; +``` + +### Task 7.4: Update Settings View Navigation + +**Status:** `pending` + +**File:** `apps/ui/src/components/views/settings-view/config/navigation.ts` + +Add or update providers section: + +```typescript +export const SETTINGS_NAVIGATION = [ + // Existing sections... + { + id: 'providers', + label: 'AI Providers', + icon: 'bot', + description: 'Configure Claude and Cursor AI providers', + }, + // ... other sections +]; +``` + +### Task 7.5: Integrate Provider Tabs in Settings + +**Status:** `pending` + +Update the settings view to render ProviderTabs for the providers section. + +--- + +## Verification + +### Test 1: Tab Switching + +1. Navigate to Settings → Providers +2. Click on "Claude" tab +3. Verify Claude settings are displayed +4. Click on "Cursor" tab +5. Verify Cursor settings are displayed + +### Test 2: Cursor Status Display + +1. With Cursor CLI installed: verify version is shown +2. With Cursor authenticated: verify green checkmark +3. Without Cursor installed: verify "Not installed" state + +### Test 3: Model Selection + +1. Enable/disable models via checkboxes +2. Verify changes persist after refresh +3. Change default model +4. Verify default is highlighted in selector + +### Test 4: Responsive Design + +1. Test on different screen sizes +2. Verify tabs are usable on mobile +3. Verify model list scrolls properly + +--- + +## Verification Checklist + +Before marking this phase complete: + +- [ ] ProviderTabs component renders correctly +- [ ] Tab switching works smoothly +- [ ] CursorSettingsTab shows correct status +- [ ] ClaudeSettingsTab shows correct status +- [ ] Model checkboxes toggle state +- [ ] Default model selector works +- [ ] Settings persist after page refresh +- [ ] Loading states displayed +- [ ] Error states handled gracefully +- [ ] Settings navigation includes providers + +--- + +## Files Changed + +| File | Action | Description | +| ------------------------------------------------------------------------------ | ------ | ------------- | +| `apps/ui/src/components/views/settings-view/providers/cursor-settings-tab.tsx` | Create | Cursor config | +| `apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx` | Create | Claude config | +| `apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx` | Create | Tab container | +| `apps/ui/src/components/views/settings-view/config/navigation.ts` | Modify | Add section | + +--- + +## Design Notes + +- Tabs use consistent icons (Bot for Claude, Terminal for Cursor) +- Model cards show tier badges (free/pro) +- Thinking models have a "Thinking" badge +- The "auto" model cannot be disabled +- Settings auto-save on change (no explicit save button) diff --git a/plan/cursor-cli-integration/phases/phase-8-profiles.md b/plan/cursor-cli-integration/phases/phase-8-profiles.md new file mode 100644 index 00000000..22173301 --- /dev/null +++ b/plan/cursor-cli-integration/phases/phase-8-profiles.md @@ -0,0 +1,590 @@ +# Phase 8: AI Profiles Integration + +**Status:** `pending` +**Dependencies:** Phase 1 (Types), Phase 7 (Settings) +**Estimated Effort:** Medium (UI + types) + +--- + +## Objective + +Extend the AI Profiles system to support Cursor as a provider, with proper handling of Cursor's embedded thinking mode (via model ID) vs Claude's separate thinking level. + +--- + +## Key Concept: Thinking Mode Handling + +### Claude Approach + +- Separate `thinkingLevel` property: `'none' | 'low' | 'medium' | 'high' | 'ultrathink'` +- Applied to any Claude model + +### Cursor Approach + +- Thinking is **embedded in the model ID** +- Examples: `claude-sonnet-4` (no thinking) vs `claude-sonnet-4-thinking` (with thinking) +- No separate thinking level selector needed for Cursor profiles + +--- + +## Tasks + +### Task 8.1: Update AIProfile Type + +**Status:** `pending` + +**File:** `libs/types/src/settings.ts` + +Update the AIProfile interface: + +```typescript +import { CursorModelId } from './cursor-models'; + +/** + * AI Profile - saved configuration for different use cases + */ +export interface AIProfile { + id: string; + name: string; + description: string; + isBuiltIn: boolean; + icon?: string; + + // Provider selection + provider: ModelProvider; // 'claude' | 'cursor' + + // Claude-specific + model?: AgentModel; // 'opus' | 'sonnet' | 'haiku' + thinkingLevel?: ThinkingLevel; // 'none' | 'low' | 'medium' | 'high' | 'ultrathink' + + // Cursor-specific + cursorModel?: CursorModelId; // 'auto' | 'claude-sonnet-4' | 'gpt-4o' | etc. + // Note: For Cursor, thinking is in the model ID (e.g., 'claude-sonnet-4-thinking') +} + +/** + * Helper to determine if a profile uses thinking mode + */ +export function profileHasThinking(profile: AIProfile): boolean { + if (profile.provider === 'claude') { + return profile.thinkingLevel !== undefined && profile.thinkingLevel !== 'none'; + } + + if (profile.provider === 'cursor') { + const model = profile.cursorModel || 'auto'; + return model.includes('thinking') || model === 'o3-mini'; + } + + return false; +} + +/** + * Get effective model string for execution + */ +export function getProfileModelString(profile: AIProfile): string { + if (profile.provider === 'cursor') { + return `cursor-${profile.cursorModel || 'auto'}`; + } + + // Claude + return profile.model || 'sonnet'; +} +``` + +### Task 8.2: Update Profile Form Component + +**Status:** `pending` + +**File:** `apps/ui/src/components/views/profiles-view/components/profile-form.tsx` + +Add Cursor-specific fields: + +```tsx +import React, { useState } from 'react'; +import { Bot, Terminal } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + AIProfile, + AgentModel, + ModelProvider, + ThinkingLevel, + CursorModelId, + CURSOR_MODEL_MAP, + cursorModelHasThinking, +} from '@automaker/types'; + +interface ProfileFormProps { + profile: AIProfile; + onSave: (profile: AIProfile) => void; + onCancel: () => void; +} + +export function ProfileForm({ profile, onSave, onCancel }: ProfileFormProps) { + const [formData, setFormData] = useState(profile); + + const handleProviderChange = (provider: ModelProvider) => { + setFormData((prev) => ({ + ...prev, + provider, + // Reset provider-specific fields + model: provider === 'claude' ? 'sonnet' : undefined, + thinkingLevel: provider === 'claude' ? 'none' : undefined, + cursorModel: provider === 'cursor' ? 'auto' : undefined, + })); + }; + + return ( +
+ {/* Name & Description */} +
+
+ + setFormData((p) => ({ ...p, name: e.target.value }))} + placeholder="My Profile" + /> +
+
+ +