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:
Kacper
2025-12-27 23:50:17 +01:00
parent b60e8f0392
commit 81f35ad6aa
13 changed files with 5632 additions and 0 deletions

View 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)

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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)

View 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

View 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