mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
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.
This commit is contained in:
89
plan/cursor-cli-integration/PHASE_PROMPT.md
Normal file
89
plan/cursor-cli-integration/PHASE_PROMPT.md
Normal file
@@ -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.
|
||||||
395
plan/cursor-cli-integration/README.md
Normal file
395
plan/cursor-cli-integration/README.md
Normal file
@@ -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<ProviderMessage>
|
||||||
|
├── detectInstallation(): Promise<InstallationStatus>
|
||||||
|
└── 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/<version>/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 |
|
||||||
201
plan/cursor-cli-integration/phases/phase-0-analysis.md
Normal file
201
plan/cursor-cli-integration/phases/phase-0-analysis.md
Normal file
@@ -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<ProviderMessage>`
|
||||||
|
- 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)
|
||||||
443
plan/cursor-cli-integration/phases/phase-1-types.md
Normal file
443
plan/cursor-cli-integration/phases/phase-1-types.md
Normal file
@@ -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<CursorModelId, CursorModelConfig> = {
|
||||||
|
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
|
||||||
649
plan/cursor-cli-integration/phases/phase-10-testing.md
Normal file
649
plan/cursor-cli-integration/phases/phase-10-testing.md
Normal file
@@ -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
|
||||||
850
plan/cursor-cli-integration/phases/phase-2-provider.md
Normal file
850
plan/cursor-cli-integration/phases/phase-2-provider.md
Normal file
@@ -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/<version>/cursor-agent
|
||||||
|
* - Symlink: ~/.local/bin/cursor-agent -> versions/<version>/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<string, string[]> = {
|
||||||
|
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<boolean> {
|
||||||
|
return this.cliPath !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Cursor CLI version
|
||||||
|
*/
|
||||||
|
async getVersion(): Promise<string | null> {
|
||||||
|
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<CursorAuthStatus> {
|
||||||
|
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<InstallationStatus> {
|
||||||
|
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<ProviderMessage> {
|
||||||
|
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
|
||||||
229
plan/cursor-cli-integration/phases/phase-3-factory.md
Normal file
229
plan/cursor-cli-integration/phases/phase-3-factory.md
Normal file
@@ -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<Record<string, InstallationStatus>> {
|
||||||
|
const providers = this.getAllProviders();
|
||||||
|
const statuses: Record<string, InstallationStatus> = {};
|
||||||
|
|
||||||
|
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
|
||||||
348
plan/cursor-cli-integration/phases/phase-4-routes.md
Normal file
348
plan/cursor-cli-integration/phases/phase-4-routes.md
Normal file
@@ -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
|
||||||
374
plan/cursor-cli-integration/phases/phase-5-log-parser.md
Normal file
374
plan/cursor-cli-integration/phases/phase-5-log-parser.md
Normal file
@@ -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
|
||||||
457
plan/cursor-cli-integration/phases/phase-6-setup-wizard.md
Normal file
457
plan/cursor-cli-integration/phases/phase-6-setup-wizard.md
Normal file
@@ -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<CliStatus | null>(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 (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-5 h-5" />
|
||||||
|
Cursor CLI Setup
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
Optional
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure Cursor CLI as an alternative AI provider. You can skip this and use Claude
|
||||||
|
instead, or configure it later in Settings.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Installation Status */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">CLI Installation</span>
|
||||||
|
{isChecking ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||||
|
) : status?.installed ? (
|
||||||
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span className="text-xs">v{status.version}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<span className="text-xs">Not installed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!status?.installed && !isChecking && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription className="text-sm space-y-3">
|
||||||
|
<p>Install Cursor CLI to use Cursor models:</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-muted p-2 rounded text-xs font-mono overflow-x-auto">
|
||||||
|
{status?.installCommand || 'curl https://cursor.com/install -fsS | bash'}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCopyInstallCommand}>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="p-0 h-auto"
|
||||||
|
onClick={() => window.open('https://cursor.com/docs/cli', '_blank')}
|
||||||
|
>
|
||||||
|
View installation docs
|
||||||
|
<ExternalLink className="w-3 h-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Authentication Status */}
|
||||||
|
{status?.installed && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Authentication</span>
|
||||||
|
{status.auth?.authenticated ? (
|
||||||
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span className="text-xs capitalize">
|
||||||
|
{status.auth.method === 'api_key' ? 'API Key' : 'Browser Login'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<span className="text-xs">Not authenticated</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!status.auth?.authenticated && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Run the login command in your terminal, then complete authentication in your
|
||||||
|
browser:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-muted p-2 rounded text-xs font-mono">
|
||||||
|
{status.loginCommand || 'cursor-agent login'}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleLogin} disabled={isLoggingIn} className="w-full">
|
||||||
|
{isLoggingIn ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Waiting for login...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Copy Command & Wait for Login'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t">
|
||||||
|
<Button variant="outline" onClick={onSkip} className="flex-1">
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onComplete}
|
||||||
|
disabled={!isComplete && status?.installed}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isComplete ? 'Continue' : 'Complete setup to continue'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={checkStatus}
|
||||||
|
disabled={isChecking}
|
||||||
|
title="Refresh status"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info note */}
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
You can always configure Cursor later in Settings → Providers
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Set<string>>(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 (
|
||||||
|
<div className="setup-view">
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
{SETUP_STEPS.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 h-2 rounded',
|
||||||
|
i < currentStep
|
||||||
|
? 'bg-green-500'
|
||||||
|
: i === currentStep
|
||||||
|
? 'bg-blue-500'
|
||||||
|
: skippedSteps.has(s.id)
|
||||||
|
? 'bg-gray-300'
|
||||||
|
: 'bg-gray-200'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step title */}
|
||||||
|
<h2 className="text-xl font-semibold mb-4">
|
||||||
|
{step.title}
|
||||||
|
{step.optional && <span className="text-sm text-muted-foreground ml-2">(Optional)</span>}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Step component */}
|
||||||
|
<StepComponent onComplete={handleComplete} onSkip={() => handleSkip(step.id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
556
plan/cursor-cli-integration/phases/phase-7-settings.md
Normal file
556
plan/cursor-cli-integration/phases/phase-7-settings.md
Normal file
@@ -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<CursorStatus | null>(null);
|
||||||
|
const [config, setConfig] = useState<CursorCliConfig | null>(null);
|
||||||
|
const [availableModels, setAvailableModels] = useState<CursorModelConfig[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Terminal className="w-5 h-5" />
|
||||||
|
Cursor CLI Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Installation */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">Installation</span>
|
||||||
|
{status?.installed ? (
|
||||||
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-mono">v{status.version}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<span className="text-xs">Not installed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Authentication */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">Authentication</span>
|
||||||
|
{status?.authenticated ? (
|
||||||
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span className="text-xs capitalize">
|
||||||
|
{status.method === 'api_key' ? 'API Key' : 'Browser Login'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<span className="text-xs">Not authenticated</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={loadData}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh Status
|
||||||
|
</Button>
|
||||||
|
{!status?.installed && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open('https://cursor.com/docs/cli', '_blank')}
|
||||||
|
>
|
||||||
|
Installation Guide
|
||||||
|
<ExternalLink className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Model Configuration */}
|
||||||
|
{status?.installed && config && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Model Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure which Cursor models are available and set the default
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Default Model */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Default Model</Label>
|
||||||
|
<Select
|
||||||
|
value={config.defaultModel || 'auto'}
|
||||||
|
onValueChange={(v) => handleDefaultModelChange(v as CursorModelId)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(config.models || ['auto']).map((modelId) => {
|
||||||
|
const model = CURSOR_MODEL_MAP[modelId];
|
||||||
|
if (!model) return null;
|
||||||
|
return (
|
||||||
|
<SelectItem key={modelId} value={modelId}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{model.label}</span>
|
||||||
|
{model.hasThinking && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Thinking
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enabled Models */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Available Models</Label>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{availableModels.map((model) => {
|
||||||
|
const isEnabled = config.models?.includes(model.id) ?? false;
|
||||||
|
const isAuto = model.id === 'auto';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={isEnabled}
|
||||||
|
onCheckedChange={(checked) => handleModelToggle(model.id, !!checked)}
|
||||||
|
disabled={isSaving || isAuto}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{model.label}</span>
|
||||||
|
{model.hasThinking && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Thinking
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{model.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={model.tier === 'free' ? 'default' : 'secondary'}>
|
||||||
|
{model.tier}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not Installed State */}
|
||||||
|
{!status?.installed && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
<Terminal className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>Cursor CLI is not installed.</p>
|
||||||
|
<p className="text-sm mt-2">Install it to use Cursor models in AutoMaker.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Tabs defaultValue={defaultTab} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
|
<TabsTrigger value="claude" className="flex items-center gap-2">
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
Claude
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="cursor" className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4" />
|
||||||
|
Cursor
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="claude">
|
||||||
|
<ClaudeSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="cursor">
|
||||||
|
<CursorSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ClaudeStatus | null>(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 (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
Claude Status
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Claude is the primary AI provider for AutoMaker</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">SDK Status</span>
|
||||||
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span className="text-xs">Active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">Authentication</span>
|
||||||
|
{status?.authenticated ? (
|
||||||
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span className="text-xs capitalize">{status.method}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<span className="text-xs">Not authenticated</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={loadStatus}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh Status
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
590
plan/cursor-cli-integration/phases/phase-8-profiles.md
Normal file
590
plan/cursor-cli-integration/phases/phase-8-profiles.md
Normal file
@@ -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<AIProfile>(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 (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Name & Description */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Profile Name</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="My Profile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||||
|
placeholder="Describe when to use this profile..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>AI Provider</Label>
|
||||||
|
<Select value={formData.provider} onValueChange={handleProviderChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="claude">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
Claude (Anthropic)
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="cursor">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4" />
|
||||||
|
Cursor CLI
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Claude-specific settings */}
|
||||||
|
{formData.provider === 'claude' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Model</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.model || 'sonnet'}
|
||||||
|
onValueChange={(v) => setFormData((p) => ({ ...p, model: v as AgentModel }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="haiku">Haiku (Fast)</SelectItem>
|
||||||
|
<SelectItem value="sonnet">Sonnet (Balanced)</SelectItem>
|
||||||
|
<SelectItem value="opus">Opus (Powerful)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Thinking Level</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.thinkingLevel || 'none'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormData((p) => ({ ...p, thinkingLevel: v as ThinkingLevel }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
<SelectItem value="ultrathink">Ultra</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cursor-specific settings */}
|
||||||
|
{formData.provider === 'cursor' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Cursor Model</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.cursorModel || 'auto'}
|
||||||
|
onValueChange={(v) => setFormData((p) => ({ ...p, cursorModel: v as CursorModelId }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => (
|
||||||
|
<SelectItem key={id} value={id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{config.label}</span>
|
||||||
|
{config.hasThinking && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Thinking
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant={config.tier === 'free' ? 'default' : 'secondary'}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{config.tier}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Info about thinking models */}
|
||||||
|
{formData.cursorModel && cursorModelHasThinking(formData.cursorModel) && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
This model has built-in extended thinking capabilities.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Save Profile</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8.3: Update Profile Card Display
|
||||||
|
|
||||||
|
**Status:** `pending`
|
||||||
|
|
||||||
|
**File:** `apps/ui/src/components/views/profiles-view/components/profile-card.tsx`
|
||||||
|
|
||||||
|
Show provider-specific info:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { Bot, Terminal } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { AIProfile, CURSOR_MODEL_MAP, profileHasThinking } from '@automaker/types';
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
profile: AIProfile;
|
||||||
|
onEdit: (profile: AIProfile) => void;
|
||||||
|
onDelete: (profile: AIProfile) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({ profile, onEdit, onDelete }: ProfileCardProps) {
|
||||||
|
const hasThinking = profileHasThinking(profile);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
{profile.provider === 'cursor' ? (
|
||||||
|
<Terminal className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{profile.name}
|
||||||
|
</CardTitle>
|
||||||
|
{profile.isBuiltIn && <Badge variant="secondary">Built-in</Badge>}
|
||||||
|
</div>
|
||||||
|
<CardDescription>{profile.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* Provider badge */}
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{profile.provider}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Model badge */}
|
||||||
|
<Badge variant="outline">
|
||||||
|
{profile.provider === 'cursor'
|
||||||
|
? CURSOR_MODEL_MAP[profile.cursorModel || 'auto']?.label || profile.cursorModel
|
||||||
|
: profile.model}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Thinking badge */}
|
||||||
|
{hasThinking && <Badge variant="default">Thinking</Badge>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{!profile.isBuiltIn && (
|
||||||
|
<CardFooter className="pt-0">
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onEdit(profile)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onDelete(profile)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8.4: Add Default Cursor Profiles
|
||||||
|
|
||||||
|
**Status:** `pending`
|
||||||
|
|
||||||
|
**File:** `apps/ui/src/components/views/profiles-view/constants.ts`
|
||||||
|
|
||||||
|
Add built-in Cursor profiles:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AIProfile } from '@automaker/types';
|
||||||
|
|
||||||
|
export const DEFAULT_PROFILES: AIProfile[] = [
|
||||||
|
// Existing Claude profiles...
|
||||||
|
{
|
||||||
|
id: 'claude-default',
|
||||||
|
name: 'Claude Default',
|
||||||
|
description: 'Balanced Claude Sonnet model',
|
||||||
|
provider: 'claude',
|
||||||
|
model: 'sonnet',
|
||||||
|
thinkingLevel: 'none',
|
||||||
|
isBuiltIn: true,
|
||||||
|
icon: 'bot',
|
||||||
|
},
|
||||||
|
// ... other Claude profiles
|
||||||
|
|
||||||
|
// Cursor profiles
|
||||||
|
{
|
||||||
|
id: 'cursor-auto',
|
||||||
|
name: 'Cursor Auto',
|
||||||
|
description: 'Let Cursor choose the best model automatically',
|
||||||
|
provider: 'cursor',
|
||||||
|
cursorModel: 'auto',
|
||||||
|
isBuiltIn: true,
|
||||||
|
icon: 'terminal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cursor-fast',
|
||||||
|
name: 'Cursor Fast',
|
||||||
|
description: 'Quick responses with GPT-4o Mini',
|
||||||
|
provider: 'cursor',
|
||||||
|
cursorModel: 'gpt-4o-mini',
|
||||||
|
isBuiltIn: true,
|
||||||
|
icon: 'zap',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cursor-thinking',
|
||||||
|
name: 'Cursor Thinking',
|
||||||
|
description: 'Claude Sonnet 4 with extended thinking for complex tasks',
|
||||||
|
provider: 'cursor',
|
||||||
|
cursorModel: 'claude-sonnet-4-thinking',
|
||||||
|
isBuiltIn: true,
|
||||||
|
icon: 'brain',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8.5: Update Profile Validation
|
||||||
|
|
||||||
|
**Status:** `pending`
|
||||||
|
|
||||||
|
Add validation for profile data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AIProfile, CURSOR_MODEL_MAP } from '@automaker/types';
|
||||||
|
|
||||||
|
export function validateProfile(profile: AIProfile): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!profile.name?.trim()) {
|
||||||
|
errors.push('Profile name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['claude', 'cursor'].includes(profile.provider)) {
|
||||||
|
errors.push('Invalid provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.provider === 'claude') {
|
||||||
|
if (!profile.model) {
|
||||||
|
errors.push('Claude model is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.provider === 'cursor') {
|
||||||
|
if (profile.cursorModel && !(profile.cursorModel in CURSOR_MODEL_MAP)) {
|
||||||
|
errors.push('Invalid Cursor model');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Test 1: Profile Creation with Cursor
|
||||||
|
|
||||||
|
1. Navigate to Profiles view
|
||||||
|
2. Click "Create Profile"
|
||||||
|
3. Select "Cursor CLI" as provider
|
||||||
|
4. Select a Cursor model
|
||||||
|
5. Save the profile
|
||||||
|
6. Verify it appears in the list with correct badges
|
||||||
|
|
||||||
|
### Test 2: Thinking Mode Detection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { profileHasThinking } from '@automaker/types';
|
||||||
|
|
||||||
|
// Claude with thinking
|
||||||
|
const claudeThinking: AIProfile = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test',
|
||||||
|
description: '',
|
||||||
|
provider: 'claude',
|
||||||
|
model: 'sonnet',
|
||||||
|
thinkingLevel: 'high',
|
||||||
|
isBuiltIn: false,
|
||||||
|
};
|
||||||
|
console.assert(profileHasThinking(claudeThinking) === true);
|
||||||
|
|
||||||
|
// Claude without thinking
|
||||||
|
const claudeNoThinking: AIProfile = {
|
||||||
|
id: '2',
|
||||||
|
name: 'Test',
|
||||||
|
description: '',
|
||||||
|
provider: 'claude',
|
||||||
|
model: 'sonnet',
|
||||||
|
thinkingLevel: 'none',
|
||||||
|
isBuiltIn: false,
|
||||||
|
};
|
||||||
|
console.assert(profileHasThinking(claudeNoThinking) === false);
|
||||||
|
|
||||||
|
// Cursor with thinking model
|
||||||
|
const cursorThinking: AIProfile = {
|
||||||
|
id: '3',
|
||||||
|
name: 'Test',
|
||||||
|
description: '',
|
||||||
|
provider: 'cursor',
|
||||||
|
cursorModel: 'claude-sonnet-4-thinking',
|
||||||
|
isBuiltIn: false,
|
||||||
|
};
|
||||||
|
console.assert(profileHasThinking(cursorThinking) === true);
|
||||||
|
|
||||||
|
// Cursor without thinking
|
||||||
|
const cursorNoThinking: AIProfile = {
|
||||||
|
id: '4',
|
||||||
|
name: 'Test',
|
||||||
|
description: '',
|
||||||
|
provider: 'cursor',
|
||||||
|
cursorModel: 'gpt-4o',
|
||||||
|
isBuiltIn: false,
|
||||||
|
};
|
||||||
|
console.assert(profileHasThinking(cursorNoThinking) === false);
|
||||||
|
|
||||||
|
console.log('All thinking detection tests passed!');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3: Provider Switching
|
||||||
|
|
||||||
|
1. Create a new profile
|
||||||
|
2. Select Claude as provider
|
||||||
|
3. Configure Claude options
|
||||||
|
4. Switch to Cursor
|
||||||
|
5. Verify Claude options are hidden
|
||||||
|
6. Verify Cursor options are shown
|
||||||
|
7. Previous selections should be cleared
|
||||||
|
|
||||||
|
### Test 4: Built-in Profiles
|
||||||
|
|
||||||
|
1. Navigate to Profiles view
|
||||||
|
2. Verify Cursor built-in profiles appear
|
||||||
|
3. Verify they cannot be edited/deleted
|
||||||
|
4. Verify they show correct badges
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
Before marking this phase complete:
|
||||||
|
|
||||||
|
- [ ] AIProfile type extended with Cursor fields
|
||||||
|
- [ ] `profileHasThinking()` works for both providers
|
||||||
|
- [ ] Profile form shows provider selector
|
||||||
|
- [ ] Claude options shown only for Claude provider
|
||||||
|
- [ ] Cursor options shown only for Cursor provider
|
||||||
|
- [ ] Cursor models show thinking badge where applicable
|
||||||
|
- [ ] Built-in Cursor profiles added
|
||||||
|
- [ ] Profile cards display provider info
|
||||||
|
- [ ] Profile validation works
|
||||||
|
- [ ] Profiles persist correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Action | Description |
|
||||||
|
| ------------------------------------------------------------------------ | ------ | ------------------------------ |
|
||||||
|
| `libs/types/src/settings.ts` | Modify | Add Cursor fields to AIProfile |
|
||||||
|
| `apps/ui/src/components/views/profiles-view/components/profile-form.tsx` | Modify | Add Cursor UI |
|
||||||
|
| `apps/ui/src/components/views/profiles-view/components/profile-card.tsx` | Modify | Show provider info |
|
||||||
|
| `apps/ui/src/components/views/profiles-view/constants.ts` | Modify | Add Cursor profiles |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Notes
|
||||||
|
|
||||||
|
- Provider selection is the first choice in profile form
|
||||||
|
- Switching providers resets model-specific options
|
||||||
|
- Cursor thinking is determined by model ID, not separate field
|
||||||
|
- Built-in profiles provide good starting points
|
||||||
|
- Profile cards show provider icon and model badges
|
||||||
451
plan/cursor-cli-integration/phases/phase-9-execution.md
Normal file
451
plan/cursor-cli-integration/phases/phase-9-execution.md
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
# Phase 9: Task Execution Integration
|
||||||
|
|
||||||
|
**Status:** `pending`
|
||||||
|
**Dependencies:** Phase 3 (Factory), Phase 8 (Profiles)
|
||||||
|
**Estimated Effort:** Medium (service updates)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Update the task execution flow (agent-service, auto-mode-service) to use the ProviderFactory for model routing, ensuring Cursor models are executed via CursorProvider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Task 9.1: Update Agent Service
|
||||||
|
|
||||||
|
**Status:** `pending`
|
||||||
|
|
||||||
|
**File:** `apps/server/src/services/agent-service.ts`
|
||||||
|
|
||||||
|
Update to use ProviderFactory:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ProviderFactory } from '../providers/provider-factory';
|
||||||
|
import { getProfileModelString, profileHasThinking } from '@automaker/types';
|
||||||
|
|
||||||
|
export class AgentService {
|
||||||
|
// ...existing code...
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a chat message using the appropriate provider
|
||||||
|
*/
|
||||||
|
async executeChat(sessionId: string, message: string, options: ChatOptions = {}): Promise<void> {
|
||||||
|
const session = this.getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session ${sessionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine effective model
|
||||||
|
const profile = options.profile;
|
||||||
|
let effectiveModel: string;
|
||||||
|
|
||||||
|
if (profile) {
|
||||||
|
effectiveModel = getProfileModelString(profile);
|
||||||
|
} else {
|
||||||
|
effectiveModel = options.model || session.model || 'sonnet';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get provider for this model
|
||||||
|
const provider = ProviderFactory.getProviderForModel(effectiveModel, {
|
||||||
|
cwd: session.workDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerName = provider.getName();
|
||||||
|
this.logger.debug(`[AgentService] Using ${providerName} provider for model ${effectiveModel}`);
|
||||||
|
|
||||||
|
// Build execution options
|
||||||
|
const executeOptions: ExecuteOptions = {
|
||||||
|
prompt: message,
|
||||||
|
model: effectiveModel,
|
||||||
|
cwd: session.workDir,
|
||||||
|
systemPrompt: this.buildSystemPrompt(session, options),
|
||||||
|
maxTurns: options.maxTurns || 100,
|
||||||
|
allowedTools: options.allowedTools || TOOL_PRESETS.chat,
|
||||||
|
abortController: session.abortController,
|
||||||
|
conversationHistory: session.conversationHistory,
|
||||||
|
sdkSessionId: session.sdkSessionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add thinking level for Claude
|
||||||
|
if (providerName === 'claude' && profile?.thinkingLevel) {
|
||||||
|
executeOptions.thinkingLevel = profile.thinkingLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Stream from provider
|
||||||
|
const stream = provider.executeQuery(executeOptions);
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
// Capture session ID
|
||||||
|
if (msg.session_id && !session.sdkSessionId) {
|
||||||
|
session.sdkSessionId = msg.session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process message and emit events
|
||||||
|
this.processProviderMessage(sessionId, msg);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleProviderError(sessionId, error, providerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a provider message and emit appropriate events
|
||||||
|
*/
|
||||||
|
private processProviderMessage(sessionId: string, msg: ProviderMessage): void {
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
this.emitAgentEvent(sessionId, {
|
||||||
|
type: 'stream',
|
||||||
|
content: block.text,
|
||||||
|
});
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
this.emitAgentEvent(sessionId, {
|
||||||
|
type: 'tool_use',
|
||||||
|
tool: {
|
||||||
|
name: block.name,
|
||||||
|
input: block.input,
|
||||||
|
id: block.tool_use_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (block.type === 'tool_result') {
|
||||||
|
this.emitAgentEvent(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
toolId: block.tool_use_id,
|
||||||
|
content: block.content,
|
||||||
|
});
|
||||||
|
} else if (block.type === 'thinking' && block.thinking) {
|
||||||
|
this.emitAgentEvent(sessionId, {
|
||||||
|
type: 'thinking',
|
||||||
|
content: block.thinking,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'result') {
|
||||||
|
this.emitAgentEvent(sessionId, {
|
||||||
|
type: 'complete',
|
||||||
|
content: msg.result || '',
|
||||||
|
});
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
this.emitAgentEvent(sessionId, {
|
||||||
|
type: 'error',
|
||||||
|
error: msg.error || 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle provider-specific errors
|
||||||
|
*/
|
||||||
|
private handleProviderError(sessionId: string, error: any, providerName: string): void {
|
||||||
|
let errorMessage = error.message || 'Unknown error';
|
||||||
|
let suggestion = error.suggestion;
|
||||||
|
|
||||||
|
// Add provider context
|
||||||
|
if (providerName === 'cursor' && error.code) {
|
||||||
|
switch (error.code) {
|
||||||
|
case 'CURSOR_NOT_AUTHENTICATED':
|
||||||
|
suggestion = 'Run "cursor-agent login" in your terminal';
|
||||||
|
break;
|
||||||
|
case 'CURSOR_RATE_LIMITED':
|
||||||
|
suggestion = 'Wait a few minutes or upgrade to Cursor Pro';
|
||||||
|
break;
|
||||||
|
case 'CURSOR_NOT_INSTALLED':
|
||||||
|
suggestion = 'Install Cursor CLI: curl https://cursor.com/install -fsS | bash';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitAgentEvent(sessionId, {
|
||||||
|
type: 'error',
|
||||||
|
error: errorMessage,
|
||||||
|
suggestion,
|
||||||
|
provider: providerName,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.error(`[AgentService] ${providerName} error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 9.2: Update Auto Mode Service
|
||||||
|
|
||||||
|
**Status:** `pending`
|
||||||
|
|
||||||
|
**File:** `apps/server/src/services/auto-mode-service.ts`
|
||||||
|
|
||||||
|
Update the `runAgent` method:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ProviderFactory } from '../providers/provider-factory';
|
||||||
|
import { getProfileModelString } from '@automaker/types';
|
||||||
|
|
||||||
|
export class AutoModeService {
|
||||||
|
// ...existing code...
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the agent for a task
|
||||||
|
*/
|
||||||
|
private async runAgent(task: Task, options: AutoModeOptions): Promise<AgentResult> {
|
||||||
|
const { workDir, profile, maxTurns } = options;
|
||||||
|
|
||||||
|
// Determine model from profile or task
|
||||||
|
let model: string;
|
||||||
|
if (profile) {
|
||||||
|
model = getProfileModelString(profile);
|
||||||
|
} else {
|
||||||
|
model = task.model || 'sonnet';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get provider
|
||||||
|
const provider = ProviderFactory.getProviderForModel(model, { cwd: workDir });
|
||||||
|
const providerName = provider.getName();
|
||||||
|
|
||||||
|
this.logger.info(`[AutoMode] Running with ${providerName} provider, model: ${model}`);
|
||||||
|
|
||||||
|
// Build execution options
|
||||||
|
const executeOptions: ExecuteOptions = {
|
||||||
|
prompt: this.buildPrompt(task),
|
||||||
|
model,
|
||||||
|
cwd: workDir,
|
||||||
|
systemPrompt: options.systemPrompt,
|
||||||
|
maxTurns: maxTurns || MAX_TURNS.extended,
|
||||||
|
allowedTools: options.allowedTools || TOOL_PRESETS.fullAccess,
|
||||||
|
abortController: options.abortController,
|
||||||
|
};
|
||||||
|
|
||||||
|
let responseText = '';
|
||||||
|
const toolCalls: ToolCall[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = provider.executeQuery(executeOptions);
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
// Emit progress events
|
||||||
|
this.emitProgress(task.id, msg, providerName);
|
||||||
|
|
||||||
|
// Collect response
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
responseText += block.text || '';
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
toolCalls.push({
|
||||||
|
id: block.tool_use_id,
|
||||||
|
name: block.name,
|
||||||
|
input: block.input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
response: responseText,
|
||||||
|
toolCalls,
|
||||||
|
provider: providerName,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
suggestion: error.suggestion,
|
||||||
|
provider: providerName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit progress event for UI updates
|
||||||
|
*/
|
||||||
|
private emitProgress(taskId: string, msg: ProviderMessage, provider: string): void {
|
||||||
|
// Emit event for log viewer and progress tracking
|
||||||
|
this.events.emit('auto-mode:event', {
|
||||||
|
taskId,
|
||||||
|
provider,
|
||||||
|
message: msg,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 9.3: Update Model Selector in Board View
|
||||||
|
|
||||||
|
**Status:** `pending`
|
||||||
|
|
||||||
|
**File:** `apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx`
|
||||||
|
|
||||||
|
Add Cursor models to selection:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CURSOR_MODEL_MAP, CursorModelId } from '@automaker/types';
|
||||||
|
|
||||||
|
interface ModelOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
provider: 'claude' | 'cursor';
|
||||||
|
hasThinking?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_OPTIONS: ModelOption[] = [
|
||||||
|
// Claude models
|
||||||
|
{ id: 'haiku', label: 'Claude Haiku', provider: 'claude' },
|
||||||
|
{ id: 'sonnet', label: 'Claude Sonnet', provider: 'claude' },
|
||||||
|
{ id: 'opus', label: 'Claude Opus', provider: 'claude' },
|
||||||
|
|
||||||
|
// Cursor models
|
||||||
|
...Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => ({
|
||||||
|
id: `cursor-${id}`,
|
||||||
|
label: `Cursor: ${config.label}`,
|
||||||
|
provider: 'cursor' as const,
|
||||||
|
hasThinking: config.hasThinking,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// In the dialog form:
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Model</Label>
|
||||||
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Claude</SelectLabel>
|
||||||
|
{MODEL_OPTIONS.filter((m) => m.provider === 'claude').map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{model.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Cursor</SelectLabel>
|
||||||
|
{MODEL_OPTIONS.filter((m) => m.provider === 'cursor').map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{model.label}
|
||||||
|
{model.hasThinking && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Thinking
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 9.4: Update Feature Execution with Provider Tracking
|
||||||
|
|
||||||
|
**Status:** `pending`
|
||||||
|
|
||||||
|
Track which provider executed each feature for UI display:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FeatureExecution {
|
||||||
|
id: string;
|
||||||
|
featureId: string;
|
||||||
|
model: string;
|
||||||
|
provider: 'claude' | 'cursor';
|
||||||
|
startTime: number;
|
||||||
|
endTime?: number;
|
||||||
|
status: 'running' | 'completed' | 'failed';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store provider info in execution results
|
||||||
|
const execution: FeatureExecution = {
|
||||||
|
id: generateId(),
|
||||||
|
featureId: feature.id,
|
||||||
|
model: effectiveModel,
|
||||||
|
provider: ProviderFactory.getProviderNameForModel(effectiveModel),
|
||||||
|
startTime: Date.now(),
|
||||||
|
status: 'running',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Test 1: Claude Model Execution
|
||||||
|
|
||||||
|
1. Create a task with a Claude model (e.g., `sonnet`)
|
||||||
|
2. Execute the task
|
||||||
|
3. Verify ClaudeProvider is used
|
||||||
|
4. Verify output streams correctly
|
||||||
|
5. Verify tool calls work
|
||||||
|
|
||||||
|
### Test 2: Cursor Model Execution
|
||||||
|
|
||||||
|
1. Create a task with a Cursor model (e.g., `cursor-auto`)
|
||||||
|
2. Execute the task
|
||||||
|
3. Verify CursorProvider is used
|
||||||
|
4. Verify output streams correctly
|
||||||
|
5. Verify tool calls work
|
||||||
|
|
||||||
|
### Test 3: Profile-Based Execution
|
||||||
|
|
||||||
|
1. Create a Cursor profile
|
||||||
|
2. Use that profile for a task
|
||||||
|
3. Verify correct provider is selected
|
||||||
|
4. Verify profile settings are applied
|
||||||
|
|
||||||
|
### Test 4: Error Handling
|
||||||
|
|
||||||
|
1. Use Cursor model without CLI installed
|
||||||
|
2. Verify appropriate error message
|
||||||
|
3. Verify suggestion is shown
|
||||||
|
4. Verify execution can be retried
|
||||||
|
|
||||||
|
### Test 5: Mixed Provider Session
|
||||||
|
|
||||||
|
1. Run a task with Claude
|
||||||
|
2. Run another task with Cursor
|
||||||
|
3. Verify both execute correctly
|
||||||
|
4. Verify logs show correct provider info
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
Before marking this phase complete:
|
||||||
|
|
||||||
|
- [ ] AgentService uses ProviderFactory
|
||||||
|
- [ ] AutoModeService uses ProviderFactory
|
||||||
|
- [ ] Claude models route to ClaudeProvider
|
||||||
|
- [ ] Cursor models route to CursorProvider
|
||||||
|
- [ ] Profile model string conversion works
|
||||||
|
- [ ] Provider errors include suggestions
|
||||||
|
- [ ] Progress events include provider info
|
||||||
|
- [ ] Model selector includes Cursor models
|
||||||
|
- [ ] Execution results track provider
|
||||||
|
- [ ] Log viewer shows provider context
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Action | Description |
|
||||||
|
| ------------------------------------------------------------------------ | ------ | ------------------- |
|
||||||
|
| `apps/server/src/services/agent-service.ts` | Modify | Use ProviderFactory |
|
||||||
|
| `apps/server/src/services/auto-mode-service.ts` | Modify | Use ProviderFactory |
|
||||||
|
| `apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx` | Modify | Add Cursor models |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Provider selection happens at execution time, not configuration time
|
||||||
|
- Session state may span provider switches
|
||||||
|
- Error handling is provider-aware
|
||||||
|
- Progress events include provider for UI grouping
|
||||||
Reference in New Issue
Block a user