mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
chore: Add Cursor CLI integration plan and phases
- Introduced a comprehensive integration plan for the Cursor CLI, including detailed phases for implementation. - Created initial markdown files for each phase, outlining objectives, tasks, and verification steps. - Established a global prompt template for starting new sessions with the Cursor CLI. - Added necessary types and configuration for Cursor models and their integration into the AutoMaker architecture. - Implemented routing logic to ensure proper model handling between Cursor and Claude providers. - Developed UI components for setup and settings management related to Cursor integration. - Included extensive testing and validation plans to ensure robust functionality across all scenarios.
This commit is contained in:
201
plan/cursor-cli-integration/phases/phase-0-analysis.md
Normal file
201
plan/cursor-cli-integration/phases/phase-0-analysis.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Phase 0: Analysis & Documentation
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** None
|
||||
**Estimated Effort:** Research only (no code changes)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Understand existing AutoMaker architecture patterns before writing any code. Document findings to ensure consistent implementation.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 0.1: Read Core Provider Files
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Read and understand these files:
|
||||
|
||||
| File | Purpose | Key Patterns |
|
||||
| ----------------------------------------------- | ------------------------ | --------------------------------------- |
|
||||
| `apps/server/src/providers/base-provider.ts` | Abstract base class | `executeQuery()` AsyncGenerator pattern |
|
||||
| `apps/server/src/providers/claude-provider.ts` | Reference implementation | SDK integration, streaming |
|
||||
| `apps/server/src/providers/provider-factory.ts` | Model routing | `getProviderForModel()` pattern |
|
||||
| `apps/server/src/providers/types.ts` | Type definitions | `ProviderMessage`, `ExecuteOptions` |
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
# Files should exist and be readable
|
||||
cat apps/server/src/providers/base-provider.ts | head -50
|
||||
cat apps/server/src/providers/claude-provider.ts | head -100
|
||||
```
|
||||
|
||||
### Task 0.2: Read Service Integration
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Understand how providers are consumed:
|
||||
|
||||
| File | Purpose | Key Patterns |
|
||||
| ----------------------------------------------- | --------------------- | ---------------------------------- |
|
||||
| `apps/server/src/services/agent-service.ts` | Chat sessions | Provider streaming, event emission |
|
||||
| `apps/server/src/services/auto-mode-service.ts` | Autonomous tasks | executeOptions, tool handling |
|
||||
| `apps/server/src/lib/sdk-options.ts` | Configuration factory | Tool presets, max turns |
|
||||
|
||||
### Task 0.3: Read UI Streaming/Logging
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Understand log parsing and display:
|
||||
|
||||
| File | Purpose | Key Patterns |
|
||||
| ------------------------------------------ | ------------------ | ---------------------------- |
|
||||
| `apps/ui/src/lib/log-parser.ts` | Parse agent output | Entry types, tool categories |
|
||||
| `apps/ui/src/components/ui/log-viewer.tsx` | Display logs | Collapsible entries, search |
|
||||
|
||||
### Task 0.4: Read Setup Flow
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Understand setup wizard patterns:
|
||||
|
||||
| File | Purpose | Key Patterns |
|
||||
| --------------------------------------------------- | ------------------ | ------------------------ |
|
||||
| `apps/server/src/routes/setup/index.ts` | Route registration | Handler patterns |
|
||||
| `apps/server/src/routes/setup/get-claude-status.ts` | CLI detection | Installation check logic |
|
||||
| `apps/ui/src/components/views/setup-view.tsx` | Wizard UI | Step components |
|
||||
|
||||
### Task 0.5: Read Types Package
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Understand type definitions:
|
||||
|
||||
| File | Purpose | Key Patterns |
|
||||
| --------------------------------- | -------------- | ---------------------------- |
|
||||
| `libs/types/src/index.ts` | Re-exports | Export patterns |
|
||||
| `libs/types/src/settings.ts` | Settings types | `AIProfile`, `ModelProvider` |
|
||||
| `libs/types/src/model.ts` | Model aliases | `CLAUDE_MODEL_MAP` |
|
||||
| `libs/types/src/model-display.ts` | UI metadata | Display info pattern |
|
||||
|
||||
### Task 0.6: Document Cursor CLI Behavior
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Test and document Cursor CLI behavior:
|
||||
|
||||
```bash
|
||||
# Check installation
|
||||
cursor-agent --version
|
||||
|
||||
# Check auth status (if available)
|
||||
cursor-agent status 2>&1 || echo "No status command"
|
||||
|
||||
# Test stream-json output (dry run)
|
||||
echo "Test prompt" | cursor-agent -p --output-format stream-json --model auto 2>&1 | head -20
|
||||
```
|
||||
|
||||
Document:
|
||||
|
||||
- [ ] Exact event sequence for simple prompt
|
||||
- [ ] Error message formats
|
||||
- [ ] Exit codes for different failure modes
|
||||
- [ ] How tool calls appear in stream
|
||||
|
||||
---
|
||||
|
||||
## Deliverable: Analysis Document
|
||||
|
||||
Create `docs/cursor-integration-analysis.md` with findings:
|
||||
|
||||
```markdown
|
||||
# Cursor CLI Integration Analysis
|
||||
|
||||
## Provider Pattern Summary
|
||||
|
||||
### BaseProvider Interface
|
||||
|
||||
- `executeQuery()` returns `AsyncGenerator<ProviderMessage>`
|
||||
- Messages must match format: { type, message?, result?, error? }
|
||||
- Session IDs propagated through all messages
|
||||
|
||||
### ClaudeProvider Patterns
|
||||
|
||||
- Uses Claude Agent SDK `query()` function
|
||||
- Streaming handled natively by SDK
|
||||
- Yields messages directly from SDK stream
|
||||
|
||||
### Key Interfaces
|
||||
|
||||
[Document: ProviderMessage, ExecuteOptions, InstallationStatus]
|
||||
|
||||
## Cursor CLI Behavior
|
||||
|
||||
### Stream Event Sequence
|
||||
|
||||
1. system/init - session start
|
||||
2. user - input prompt
|
||||
3. assistant - response text
|
||||
4. tool_call/started - tool invocation
|
||||
5. tool_call/completed - tool result
|
||||
6. result/success - final output
|
||||
|
||||
### Event Format Differences
|
||||
|
||||
[Document any transformations needed]
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
- Not authenticated: [error message/code]
|
||||
- Rate limited: [error message/code]
|
||||
- Network error: [error message/code]
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Files to Create
|
||||
|
||||
[List with descriptions]
|
||||
|
||||
### Files to Modify
|
||||
|
||||
[List with specific changes needed]
|
||||
|
||||
## Open Questions
|
||||
|
||||
[Any unresolved issues]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] All provider files read and understood
|
||||
- [ ] Service integration patterns documented
|
||||
- [ ] Log parser patterns understood
|
||||
- [ ] Setup wizard flow mapped
|
||||
- [ ] Types package structure documented
|
||||
- [ ] Cursor CLI behavior tested (if installed)
|
||||
- [ ] Analysis document created in `docs/`
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This phase is **read-only** - no code changes
|
||||
- Document anything unclear for later clarification
|
||||
- Note any differences from the high-level plan provided
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Cursor CLI Output Format](https://cursor.com/docs/cli/reference/output-format)
|
||||
- [Cursor CLI Usage](https://cursor.com/docs/cli/using)
|
||||
- [Cursor CLI GitHub Actions](https://cursor.com/docs/cli/github-actions)
|
||||
443
plan/cursor-cli-integration/phases/phase-1-types.md
Normal file
443
plan/cursor-cli-integration/phases/phase-1-types.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# Phase 1: Core Types & Configuration
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** Phase 0 (Analysis)
|
||||
**Estimated Effort:** Small (type definitions only)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Define all Cursor-specific types and extend existing types to support the new provider.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1.1: Create Cursor Model Definitions
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `libs/types/src/cursor-models.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Cursor CLI Model IDs
|
||||
* Reference: https://cursor.com/docs
|
||||
*/
|
||||
export type CursorModelId =
|
||||
| 'auto' // Auto-select best model
|
||||
| 'claude-sonnet-4' // Claude Sonnet 4
|
||||
| 'claude-sonnet-4-thinking' // Claude Sonnet 4 with extended thinking
|
||||
| 'gpt-4o' // GPT-4o
|
||||
| 'gpt-4o-mini' // GPT-4o Mini
|
||||
| 'gemini-2.5-pro' // Gemini 2.5 Pro
|
||||
| 'o3-mini'; // O3 Mini
|
||||
|
||||
/**
|
||||
* Cursor model metadata
|
||||
*/
|
||||
export interface CursorModelConfig {
|
||||
id: CursorModelId;
|
||||
label: string;
|
||||
description: string;
|
||||
hasThinking: boolean;
|
||||
tier: 'free' | 'pro';
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete model map for Cursor CLI
|
||||
*/
|
||||
export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
|
||||
auto: {
|
||||
id: 'auto',
|
||||
label: 'Auto (Recommended)',
|
||||
description: 'Automatically selects the best model for each task',
|
||||
hasThinking: false,
|
||||
tier: 'free',
|
||||
},
|
||||
'claude-sonnet-4': {
|
||||
id: 'claude-sonnet-4',
|
||||
label: 'Claude Sonnet 4',
|
||||
description: 'Anthropic Claude Sonnet 4 via Cursor',
|
||||
hasThinking: false,
|
||||
tier: 'pro',
|
||||
},
|
||||
'claude-sonnet-4-thinking': {
|
||||
id: 'claude-sonnet-4-thinking',
|
||||
label: 'Claude Sonnet 4 (Thinking)',
|
||||
description: 'Claude Sonnet 4 with extended thinking enabled',
|
||||
hasThinking: true,
|
||||
tier: 'pro',
|
||||
},
|
||||
'gpt-4o': {
|
||||
id: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
description: 'OpenAI GPT-4o via Cursor',
|
||||
hasThinking: false,
|
||||
tier: 'pro',
|
||||
},
|
||||
'gpt-4o-mini': {
|
||||
id: 'gpt-4o-mini',
|
||||
label: 'GPT-4o Mini',
|
||||
description: 'OpenAI GPT-4o Mini (faster, cheaper)',
|
||||
hasThinking: false,
|
||||
tier: 'free',
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
id: 'gemini-2.5-pro',
|
||||
label: 'Gemini 2.5 Pro',
|
||||
description: 'Google Gemini 2.5 Pro via Cursor',
|
||||
hasThinking: false,
|
||||
tier: 'pro',
|
||||
},
|
||||
'o3-mini': {
|
||||
id: 'o3-mini',
|
||||
label: 'O3 Mini',
|
||||
description: 'OpenAI O3 Mini reasoning model',
|
||||
hasThinking: true,
|
||||
tier: 'pro',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper: Check if model has thinking capability
|
||||
*/
|
||||
export function cursorModelHasThinking(modelId: CursorModelId): boolean {
|
||||
return CURSOR_MODEL_MAP[modelId]?.hasThinking ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get display name for model
|
||||
*/
|
||||
export function getCursorModelLabel(modelId: CursorModelId): string {
|
||||
return CURSOR_MODEL_MAP[modelId]?.label ?? modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get all cursor model IDs
|
||||
*/
|
||||
export function getAllCursorModelIds(): CursorModelId[] {
|
||||
return Object.keys(CURSOR_MODEL_MAP) as CursorModelId[];
|
||||
}
|
||||
```
|
||||
|
||||
### Task 1.2: Create Cursor CLI Types
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `libs/types/src/cursor-cli.ts`
|
||||
|
||||
```typescript
|
||||
import { CursorModelId } from './cursor-models';
|
||||
|
||||
/**
|
||||
* Cursor CLI configuration file schema
|
||||
* Stored in: .automaker/cursor-config.json
|
||||
*/
|
||||
export interface CursorCliConfig {
|
||||
defaultModel?: CursorModelId;
|
||||
models?: CursorModelId[]; // Enabled models
|
||||
mcpServers?: string[]; // MCP server configs to load
|
||||
rules?: string[]; // .cursor/rules paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor authentication status
|
||||
*/
|
||||
export interface CursorAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: 'login' | 'api_key' | 'none';
|
||||
hasCredentialsFile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: Reuse existing InstallationStatus from provider.ts
|
||||
* The existing type already has: installed, path, version, method, hasApiKey, authenticated
|
||||
*
|
||||
* Add 'login' to the method union if needed:
|
||||
* method?: 'cli' | 'npm' | 'brew' | 'sdk' | 'login';
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cursor stream-json event types (from CLI output)
|
||||
*/
|
||||
export interface CursorSystemEvent {
|
||||
type: 'system';
|
||||
subtype: 'init';
|
||||
apiKeySource: 'env' | 'flag' | 'login';
|
||||
cwd: string;
|
||||
session_id: string;
|
||||
model: string;
|
||||
permissionMode: string;
|
||||
}
|
||||
|
||||
export interface CursorUserEvent {
|
||||
type: 'user';
|
||||
message: {
|
||||
role: 'user';
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
};
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface CursorAssistantEvent {
|
||||
type: 'assistant';
|
||||
message: {
|
||||
role: 'assistant';
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
};
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface CursorToolCallEvent {
|
||||
type: 'tool_call';
|
||||
subtype: 'started' | 'completed';
|
||||
call_id: string;
|
||||
tool_call: {
|
||||
readToolCall?: {
|
||||
args: { path: string };
|
||||
result?: {
|
||||
success?: {
|
||||
content: string;
|
||||
isEmpty: boolean;
|
||||
exceededLimit: boolean;
|
||||
totalLines: number;
|
||||
totalChars: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
writeToolCall?: {
|
||||
args: { path: string; fileText: string; toolCallId?: string };
|
||||
result?: {
|
||||
success?: {
|
||||
path: string;
|
||||
linesCreated: number;
|
||||
fileSize: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
function?: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface CursorResultEvent {
|
||||
type: 'result';
|
||||
subtype: 'success' | 'error';
|
||||
duration_ms: number;
|
||||
duration_api_ms: number;
|
||||
is_error: boolean;
|
||||
result: string;
|
||||
session_id: string;
|
||||
request_id?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type CursorStreamEvent =
|
||||
| CursorSystemEvent
|
||||
| CursorUserEvent
|
||||
| CursorAssistantEvent
|
||||
| CursorToolCallEvent
|
||||
| CursorResultEvent;
|
||||
```
|
||||
|
||||
### Task 1.3: Extend ModelProvider Type
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `libs/types/src/settings.ts`
|
||||
|
||||
Find and update:
|
||||
|
||||
```typescript
|
||||
// BEFORE:
|
||||
export type ModelProvider = 'claude';
|
||||
|
||||
// AFTER:
|
||||
export type ModelProvider = 'claude' | 'cursor';
|
||||
```
|
||||
|
||||
### Task 1.4: Add Cursor Profile Config Type
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `libs/types/src/settings.ts`
|
||||
|
||||
Add after existing AIProfile interface:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Cursor-specific profile configuration
|
||||
* Note: For Cursor, thinking is embedded in model ID (e.g., 'claude-sonnet-4-thinking')
|
||||
*/
|
||||
export interface CursorProfileConfig {
|
||||
model: CursorModelId;
|
||||
// No separate thinkingLevel needed - embedded in model ID
|
||||
}
|
||||
```
|
||||
|
||||
### Task 1.5: Update ModelOption Interface
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `libs/types/src/model-display.ts`
|
||||
|
||||
Update the hardcoded provider type to use ModelProvider:
|
||||
|
||||
```typescript
|
||||
// BEFORE (line 24):
|
||||
export interface ModelOption {
|
||||
id: AgentModel;
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
provider: 'claude'; // ❌ Hardcoded
|
||||
}
|
||||
|
||||
// AFTER:
|
||||
import { ModelProvider } from './settings.js';
|
||||
|
||||
export interface ModelOption {
|
||||
id: AgentModel | CursorModelId; // Union for both providers
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
provider: ModelProvider; // ✅ Supports both 'claude' and 'cursor'
|
||||
}
|
||||
```
|
||||
|
||||
### Task 1.6: Extend DEFAULT_MODELS
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `libs/types/src/model.ts`
|
||||
|
||||
Add cursor default model:
|
||||
|
||||
```typescript
|
||||
// BEFORE:
|
||||
export const DEFAULT_MODELS = {
|
||||
claude: 'claude-opus-4-5-20251101',
|
||||
} as const;
|
||||
|
||||
// AFTER:
|
||||
export const DEFAULT_MODELS = {
|
||||
claude: 'claude-opus-4-5-20251101',
|
||||
cursor: 'auto', // Cursor's recommended default
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Task 1.7: Update Type Exports
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `libs/types/src/index.ts`
|
||||
|
||||
Add exports:
|
||||
|
||||
```typescript
|
||||
// Cursor types
|
||||
export * from './cursor-models.js';
|
||||
export * from './cursor-cli.js';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test 1: Type Compilation
|
||||
|
||||
```bash
|
||||
cd libs/types
|
||||
pnpm build
|
||||
```
|
||||
|
||||
**Expected:** No compilation errors
|
||||
|
||||
### Test 2: Import Check
|
||||
|
||||
Create a temporary test file:
|
||||
|
||||
```typescript
|
||||
// test-cursor-types.ts
|
||||
import {
|
||||
CursorModelId,
|
||||
CursorModelConfig,
|
||||
CURSOR_MODEL_MAP,
|
||||
cursorModelHasThinking,
|
||||
CursorStreamEvent,
|
||||
CursorCliConfig,
|
||||
ModelProvider,
|
||||
} from '@automaker/types';
|
||||
|
||||
// Should compile without errors
|
||||
const model: CursorModelId = 'claude-sonnet-4';
|
||||
const provider: ModelProvider = 'cursor';
|
||||
const hasThinking = cursorModelHasThinking('claude-sonnet-4-thinking');
|
||||
console.log(model, provider, hasThinking);
|
||||
```
|
||||
|
||||
```bash
|
||||
npx tsc test-cursor-types.ts --noEmit
|
||||
rm test-cursor-types.ts
|
||||
```
|
||||
|
||||
**Expected:** No errors
|
||||
|
||||
### Test 3: Model Map Validity
|
||||
|
||||
```typescript
|
||||
// In Node REPL or test file
|
||||
import { CURSOR_MODEL_MAP, CursorModelId } from '@automaker/types';
|
||||
|
||||
const modelIds = Object.keys(CURSOR_MODEL_MAP) as CursorModelId[];
|
||||
console.log('Models:', modelIds.length);
|
||||
|
||||
// All models should have required fields
|
||||
for (const [id, config] of Object.entries(CURSOR_MODEL_MAP)) {
|
||||
console.assert(config.id === id, `ID mismatch: ${id}`);
|
||||
console.assert(typeof config.label === 'string', `Missing label: ${id}`);
|
||||
console.assert(typeof config.hasThinking === 'boolean', `Missing hasThinking: ${id}`);
|
||||
console.assert(['free', 'pro'].includes(config.tier), `Invalid tier: ${id}`);
|
||||
}
|
||||
console.log('All models valid');
|
||||
```
|
||||
|
||||
**Expected:** All assertions pass
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] `libs/types/src/cursor-models.ts` created with all model definitions
|
||||
- [ ] `libs/types/src/cursor-cli.ts` created with CLI types
|
||||
- [ ] `libs/types/src/settings.ts` extended with `cursor` provider
|
||||
- [ ] `libs/types/src/index.ts` exports new types
|
||||
- [ ] `pnpm build` succeeds in libs/types
|
||||
- [ ] No TypeScript errors in dependent packages
|
||||
- [ ] Model map contains all expected models
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Description |
|
||||
| --------------------------------- | ------ | ----------------------------- |
|
||||
| `libs/types/src/cursor-models.ts` | Create | Model definitions and helpers |
|
||||
| `libs/types/src/cursor-cli.ts` | Create | CLI and stream event types |
|
||||
| `libs/types/src/settings.ts` | Modify | Add `cursor` to ModelProvider |
|
||||
| `libs/types/src/index.ts` | Modify | Export new types |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Model IDs may need updating as Cursor adds/removes models
|
||||
- The `hasThinking` property is critical for UI display
|
||||
- Stream event types must match actual CLI output exactly
|
||||
649
plan/cursor-cli-integration/phases/phase-10-testing.md
Normal file
649
plan/cursor-cli-integration/phases/phase-10-testing.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# Phase 10: Testing & Validation
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** All previous phases
|
||||
**Estimated Effort:** Medium (comprehensive testing)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Create comprehensive tests and perform validation to ensure the Cursor CLI integration works correctly across all scenarios.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 10.1: Unit Tests - Cursor Provider
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/tests/unit/providers/cursor-provider.test.ts`
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { CursorProvider, CursorErrorCode } from '../../../src/providers/cursor-provider';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('CursorProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getName', () => {
|
||||
it('should return "cursor"', () => {
|
||||
const provider = new CursorProvider();
|
||||
expect(provider.getName()).toBe('cursor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInstalled', () => {
|
||||
it('should return true when CLI is found in PATH', async () => {
|
||||
vi.mocked(execSync).mockReturnValue('/usr/local/bin/cursor-agent\n');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
const provider = new CursorProvider();
|
||||
const result = await provider.isInstalled();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when CLI is not found', async () => {
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const provider = new CursorProvider();
|
||||
const result = await provider.isInstalled();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAuth', () => {
|
||||
it('should detect API key authentication', async () => {
|
||||
process.env.CURSOR_API_KEY = 'test-key';
|
||||
|
||||
const provider = new CursorProvider();
|
||||
const result = await provider.checkAuth();
|
||||
|
||||
expect(result.authenticated).toBe(true);
|
||||
expect(result.method).toBe('api_key');
|
||||
|
||||
delete process.env.CURSOR_API_KEY;
|
||||
});
|
||||
|
||||
it('should detect login authentication from credentials file', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ accessToken: 'token' }));
|
||||
|
||||
const provider = new CursorProvider();
|
||||
const result = await provider.checkAuth();
|
||||
|
||||
expect(result.authenticated).toBe(true);
|
||||
expect(result.method).toBe('login');
|
||||
});
|
||||
|
||||
it('should return not authenticated when no credentials', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const provider = new CursorProvider();
|
||||
const result = await provider.checkAuth();
|
||||
|
||||
expect(result.authenticated).toBe(false);
|
||||
expect(result.method).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStreamLine', () => {
|
||||
it('should parse valid JSON event', () => {
|
||||
const provider = new CursorProvider();
|
||||
const line = '{"type":"system","subtype":"init","session_id":"abc"}';
|
||||
|
||||
const result = (provider as any).parseStreamLine(line);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
session_id: 'abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for invalid JSON', () => {
|
||||
const provider = new CursorProvider();
|
||||
const result = (provider as any).parseStreamLine('not json');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty lines', () => {
|
||||
const provider = new CursorProvider();
|
||||
expect((provider as any).parseStreamLine('')).toBeNull();
|
||||
expect((provider as any).parseStreamLine(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapError', () => {
|
||||
it('should map authentication errors', () => {
|
||||
const provider = new CursorProvider();
|
||||
|
||||
const error = (provider as any).mapError('Error: not authenticated', 1);
|
||||
|
||||
expect(error.code).toBe(CursorErrorCode.NOT_AUTHENTICATED);
|
||||
expect(error.recoverable).toBe(true);
|
||||
expect(error.suggestion).toBeDefined();
|
||||
});
|
||||
|
||||
it('should map rate limit errors', () => {
|
||||
const provider = new CursorProvider();
|
||||
|
||||
const error = (provider as any).mapError('Rate limit exceeded', 1);
|
||||
|
||||
expect(error.code).toBe(CursorErrorCode.RATE_LIMITED);
|
||||
expect(error.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map network errors', () => {
|
||||
const provider = new CursorProvider();
|
||||
|
||||
const error = (provider as any).mapError('ECONNREFUSED', 1);
|
||||
|
||||
expect(error.code).toBe(CursorErrorCode.NETWORK_ERROR);
|
||||
expect(error.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should return unknown error for unrecognized messages', () => {
|
||||
const provider = new CursorProvider();
|
||||
|
||||
const error = (provider as any).mapError('Something weird happened', 1);
|
||||
|
||||
expect(error.code).toBe(CursorErrorCode.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('should return all Cursor models', () => {
|
||||
const provider = new CursorProvider();
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
expect(models.every((m) => m.provider === 'cursor')).toBe(true);
|
||||
expect(models.some((m) => m.id.includes('auto'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Task 10.2: Unit Tests - Provider Factory
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/tests/unit/providers/provider-factory.test.ts`
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ProviderFactory } from '../../../src/providers/provider-factory';
|
||||
import { ClaudeProvider } from '../../../src/providers/claude-provider';
|
||||
import { CursorProvider } from '../../../src/providers/cursor-provider';
|
||||
|
||||
describe('ProviderFactory', () => {
|
||||
describe('getProviderNameForModel', () => {
|
||||
it('should route cursor-prefixed models to cursor', () => {
|
||||
expect(ProviderFactory.getProviderNameForModel('cursor-auto')).toBe('cursor');
|
||||
expect(ProviderFactory.getProviderNameForModel('cursor-gpt-4o')).toBe('cursor');
|
||||
expect(ProviderFactory.getProviderNameForModel('cursor-claude-sonnet-4')).toBe('cursor');
|
||||
});
|
||||
|
||||
it('should route claude models to claude', () => {
|
||||
expect(ProviderFactory.getProviderNameForModel('claude-sonnet-4')).toBe('claude');
|
||||
expect(ProviderFactory.getProviderNameForModel('opus')).toBe('claude');
|
||||
expect(ProviderFactory.getProviderNameForModel('sonnet')).toBe('claude');
|
||||
expect(ProviderFactory.getProviderNameForModel('haiku')).toBe('claude');
|
||||
});
|
||||
|
||||
it('should default unknown models to claude', () => {
|
||||
expect(ProviderFactory.getProviderNameForModel('unknown-model')).toBe('claude');
|
||||
expect(ProviderFactory.getProviderNameForModel('random')).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderForModel', () => {
|
||||
it('should return CursorProvider for cursor models', () => {
|
||||
const provider = ProviderFactory.getProviderForModel('cursor-auto');
|
||||
expect(provider).toBeInstanceOf(CursorProvider);
|
||||
expect(provider.getName()).toBe('cursor');
|
||||
});
|
||||
|
||||
it('should return ClaudeProvider for claude models', () => {
|
||||
const provider = ProviderFactory.getProviderForModel('sonnet');
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
expect(provider.getName()).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllProviders', () => {
|
||||
it('should return both providers', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
const names = providers.map((p) => p.getName());
|
||||
|
||||
expect(names).toContain('claude');
|
||||
expect(names).toContain('cursor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderByName', () => {
|
||||
it('should return correct provider by name', () => {
|
||||
expect(ProviderFactory.getProviderByName('cursor')?.getName()).toBe('cursor');
|
||||
expect(ProviderFactory.getProviderByName('claude')?.getName()).toBe('claude');
|
||||
expect(ProviderFactory.getProviderByName('unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllAvailableModels', () => {
|
||||
it('should include models from all providers', () => {
|
||||
const models = ProviderFactory.getAllAvailableModels();
|
||||
|
||||
const cursorModels = models.filter((m) => m.provider === 'cursor');
|
||||
const claudeModels = models.filter((m) => m.provider === 'claude');
|
||||
|
||||
expect(cursorModels.length).toBeGreaterThan(0);
|
||||
expect(claudeModels.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Task 10.3: Unit Tests - Types
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `libs/types/tests/cursor-types.test.ts`
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CURSOR_MODEL_MAP,
|
||||
cursorModelHasThinking,
|
||||
getCursorModelLabel,
|
||||
getAllCursorModelIds,
|
||||
CursorModelId,
|
||||
} from '../src/cursor-models';
|
||||
import { profileHasThinking, getProfileModelString, AIProfile } from '../src/settings';
|
||||
|
||||
describe('Cursor Model Types', () => {
|
||||
describe('CURSOR_MODEL_MAP', () => {
|
||||
it('should have all required models', () => {
|
||||
const requiredModels: CursorModelId[] = [
|
||||
'auto',
|
||||
'claude-sonnet-4',
|
||||
'claude-sonnet-4-thinking',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
];
|
||||
|
||||
for (const model of requiredModels) {
|
||||
expect(CURSOR_MODEL_MAP[model]).toBeDefined();
|
||||
expect(CURSOR_MODEL_MAP[model].id).toBe(model);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid tier values', () => {
|
||||
for (const config of Object.values(CURSOR_MODEL_MAP)) {
|
||||
expect(['free', 'pro']).toContain(config.tier);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursorModelHasThinking', () => {
|
||||
it('should return true for thinking models', () => {
|
||||
expect(cursorModelHasThinking('claude-sonnet-4-thinking')).toBe(true);
|
||||
expect(cursorModelHasThinking('o3-mini')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-thinking models', () => {
|
||||
expect(cursorModelHasThinking('auto')).toBe(false);
|
||||
expect(cursorModelHasThinking('gpt-4o')).toBe(false);
|
||||
expect(cursorModelHasThinking('claude-sonnet-4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCursorModelLabel', () => {
|
||||
it('should return correct labels', () => {
|
||||
expect(getCursorModelLabel('auto')).toBe('Auto (Recommended)');
|
||||
expect(getCursorModelLabel('gpt-4o')).toBe('GPT-4o');
|
||||
});
|
||||
|
||||
it('should return model ID for unknown models', () => {
|
||||
expect(getCursorModelLabel('unknown' as CursorModelId)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Profile Helpers', () => {
|
||||
describe('profileHasThinking', () => {
|
||||
it('should detect Claude thinking levels', () => {
|
||||
const profile: AIProfile = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
provider: 'claude',
|
||||
model: 'sonnet',
|
||||
thinkingLevel: 'high',
|
||||
isBuiltIn: false,
|
||||
};
|
||||
expect(profileHasThinking(profile)).toBe(true);
|
||||
|
||||
profile.thinkingLevel = 'none';
|
||||
expect(profileHasThinking(profile)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect Cursor thinking models', () => {
|
||||
const profile: AIProfile = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
provider: 'cursor',
|
||||
cursorModel: 'claude-sonnet-4-thinking',
|
||||
isBuiltIn: false,
|
||||
};
|
||||
expect(profileHasThinking(profile)).toBe(true);
|
||||
|
||||
profile.cursorModel = 'gpt-4o';
|
||||
expect(profileHasThinking(profile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProfileModelString', () => {
|
||||
it('should format Cursor models correctly', () => {
|
||||
const profile: AIProfile = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
provider: 'cursor',
|
||||
cursorModel: 'gpt-4o',
|
||||
isBuiltIn: false,
|
||||
};
|
||||
expect(getProfileModelString(profile)).toBe('cursor-gpt-4o');
|
||||
});
|
||||
|
||||
it('should format Claude models correctly', () => {
|
||||
const profile: AIProfile = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
provider: 'claude',
|
||||
model: 'sonnet',
|
||||
isBuiltIn: false,
|
||||
};
|
||||
expect(getProfileModelString(profile)).toBe('sonnet');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Task 10.4: Integration Tests
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/tests/integration/cursor-integration.test.ts`
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { CursorProvider } from '../../src/providers/cursor-provider';
|
||||
import { ProviderFactory } from '../../src/providers/provider-factory';
|
||||
|
||||
describe('Cursor Integration (requires cursor-agent)', () => {
|
||||
let provider: CursorProvider;
|
||||
let isInstalled: boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
provider = new CursorProvider();
|
||||
isInstalled = await provider.isInstalled();
|
||||
});
|
||||
|
||||
describe('when cursor-agent is installed', () => {
|
||||
it.skipIf(!isInstalled)('should get version', async () => {
|
||||
const version = await provider.getVersion();
|
||||
expect(version).toBeTruthy();
|
||||
expect(typeof version).toBe('string');
|
||||
});
|
||||
|
||||
it.skipIf(!isInstalled)('should check auth status', async () => {
|
||||
const auth = await provider.checkAuth();
|
||||
expect(auth).toHaveProperty('authenticated');
|
||||
expect(auth).toHaveProperty('method');
|
||||
});
|
||||
|
||||
it.skipIf(!isInstalled)('should detect installation', async () => {
|
||||
const status = await provider.detectInstallation();
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.path).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when cursor-agent is not installed', () => {
|
||||
it.skipIf(isInstalled)('should report not installed', async () => {
|
||||
const status = await provider.detectInstallation();
|
||||
expect(status.installed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Task 10.5: E2E Tests
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/tests/e2e/cursor-setup.spec.ts`
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Cursor Setup Wizard', () => {
|
||||
test('should show Cursor setup step', async ({ page }) => {
|
||||
// Navigate to setup (fresh install)
|
||||
await page.goto('/setup');
|
||||
|
||||
// Wait for Cursor step to appear
|
||||
await expect(page.getByText('Cursor CLI Setup')).toBeVisible();
|
||||
await expect(page.getByText('Optional')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow skipping Cursor setup', async ({ page }) => {
|
||||
await page.goto('/setup');
|
||||
|
||||
// Find and click skip button
|
||||
await page.getByRole('button', { name: 'Skip for now' }).click();
|
||||
|
||||
// Should proceed to next step
|
||||
await expect(page.getByText('Cursor CLI Setup')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should show installation instructions when not installed', async ({ page }) => {
|
||||
await page.goto('/setup');
|
||||
|
||||
// Check for install command
|
||||
await expect(page.getByText('curl https://cursor.com/install')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Cursor Settings', () => {
|
||||
test('should show Cursor tab in settings', async ({ page }) => {
|
||||
await page.goto('/settings/providers');
|
||||
|
||||
// Should have tabs for both providers
|
||||
await expect(page.getByRole('tab', { name: 'Claude' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Cursor' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should switch between provider tabs', async ({ page }) => {
|
||||
await page.goto('/settings/providers');
|
||||
|
||||
// Click Cursor tab
|
||||
await page.getByRole('tab', { name: 'Cursor' }).click();
|
||||
|
||||
// Should show Cursor settings
|
||||
await expect(page.getByText('Cursor CLI Status')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Task 10.6: Manual Testing Checklist
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Create a manual testing checklist:
|
||||
|
||||
```markdown
|
||||
## Manual Testing Checklist
|
||||
|
||||
### Setup Flow
|
||||
|
||||
- [ ] Fresh install shows Cursor step
|
||||
- [ ] Can skip Cursor setup
|
||||
- [ ] Installation status is accurate
|
||||
- [ ] Login flow works (copy command, poll for auth)
|
||||
- [ ] Refresh button updates status
|
||||
|
||||
### Settings
|
||||
|
||||
- [ ] Provider tabs work
|
||||
- [ ] Cursor status shows correctly
|
||||
- [ ] Model selection works
|
||||
- [ ] Default model saves
|
||||
- [ ] Enabled models save
|
||||
|
||||
### Profiles
|
||||
|
||||
- [ ] Can create Cursor profile
|
||||
- [ ] Provider switch resets options
|
||||
- [ ] Cursor models show thinking badge
|
||||
- [ ] Built-in Cursor profiles appear
|
||||
- [ ] Profile cards show provider info
|
||||
|
||||
### Execution
|
||||
|
||||
- [ ] Tasks with Cursor models execute
|
||||
- [ ] Streaming works correctly
|
||||
- [ ] Tool calls are displayed
|
||||
- [ ] Errors show suggestions
|
||||
- [ ] Can abort Cursor tasks
|
||||
|
||||
### Log Viewer
|
||||
|
||||
- [ ] Cursor events parsed correctly
|
||||
- [ ] Tool calls categorized
|
||||
- [ ] File paths highlighted
|
||||
- [ ] Provider badge shown
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- [ ] Switch provider mid-session
|
||||
- [ ] Cursor not installed handling
|
||||
- [ ] Network errors handled
|
||||
- [ ] Rate limiting handled
|
||||
- [ ] Auth expired handling
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test 1: Run All Unit Tests
|
||||
|
||||
```bash
|
||||
pnpm test:unit
|
||||
```
|
||||
|
||||
All tests should pass.
|
||||
|
||||
### Test 2: Run Integration Tests
|
||||
|
||||
```bash
|
||||
pnpm test:integration
|
||||
```
|
||||
|
||||
Tests requiring cursor-agent will be skipped if not installed.
|
||||
|
||||
### Test 3: Run E2E Tests
|
||||
|
||||
```bash
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
Browser tests should pass.
|
||||
|
||||
### Test 4: Type Check
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
No TypeScript errors.
|
||||
|
||||
### Test 5: Lint Check
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
No linting errors.
|
||||
|
||||
### Test 6: Build
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Build should succeed without errors.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] Unit tests pass (cursor-provider)
|
||||
- [ ] Unit tests pass (provider-factory)
|
||||
- [ ] Unit tests pass (types)
|
||||
- [ ] Integration tests pass (or skip if not installed)
|
||||
- [ ] E2E tests pass
|
||||
- [ ] Manual testing checklist completed
|
||||
- [ ] No TypeScript errors
|
||||
- [ ] No linting errors
|
||||
- [ ] Build succeeds
|
||||
- [ ] Documentation updated
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Description |
|
||||
| ----------------------------------------------------------- | ------ | ----------------- |
|
||||
| `apps/server/tests/unit/providers/cursor-provider.test.ts` | Create | Provider tests |
|
||||
| `apps/server/tests/unit/providers/provider-factory.test.ts` | Create | Factory tests |
|
||||
| `libs/types/tests/cursor-types.test.ts` | Create | Type tests |
|
||||
| `apps/server/tests/integration/cursor-integration.test.ts` | Create | Integration tests |
|
||||
| `apps/ui/tests/e2e/cursor-setup.spec.ts` | Create | E2E tests |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Integration tests may be skipped if cursor-agent is not installed
|
||||
- E2E tests should work regardless of cursor-agent installation
|
||||
- Manual testing should cover both installed and not-installed scenarios
|
||||
850
plan/cursor-cli-integration/phases/phase-2-provider.md
Normal file
850
plan/cursor-cli-integration/phases/phase-2-provider.md
Normal file
@@ -0,0 +1,850 @@
|
||||
# Phase 2: Cursor Provider Implementation
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** Phase 1 (Types)
|
||||
**Estimated Effort:** Medium-Large (core implementation)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Implement the main `CursorProvider` class that spawns the cursor-agent CLI and streams responses in the AutoMaker provider format.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 2.1: Create Cursor Provider
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/src/providers/cursor-provider.ts`
|
||||
|
||||
```typescript
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { BaseProvider } from './base-provider';
|
||||
import {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from './types';
|
||||
import {
|
||||
CursorModelId,
|
||||
CursorStreamEvent,
|
||||
CursorSystemEvent,
|
||||
CursorAssistantEvent,
|
||||
CursorToolCallEvent,
|
||||
CursorResultEvent,
|
||||
CURSOR_MODEL_MAP,
|
||||
CursorAuthStatus,
|
||||
} from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('CursorProvider');
|
||||
|
||||
/**
|
||||
* Cursor-specific error codes for detailed error handling
|
||||
*/
|
||||
export enum CursorErrorCode {
|
||||
NOT_INSTALLED = 'CURSOR_NOT_INSTALLED',
|
||||
NOT_AUTHENTICATED = 'CURSOR_NOT_AUTHENTICATED',
|
||||
RATE_LIMITED = 'CURSOR_RATE_LIMITED',
|
||||
MODEL_UNAVAILABLE = 'CURSOR_MODEL_UNAVAILABLE',
|
||||
NETWORK_ERROR = 'CURSOR_NETWORK_ERROR',
|
||||
PROCESS_CRASHED = 'CURSOR_PROCESS_CRASHED',
|
||||
TIMEOUT = 'CURSOR_TIMEOUT',
|
||||
UNKNOWN = 'CURSOR_UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
export interface CursorError extends Error {
|
||||
code: CursorErrorCode;
|
||||
recoverable: boolean;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CursorProvider - Integrates cursor-agent CLI as an AI provider
|
||||
*
|
||||
* Uses the cursor-agent CLI with --output-format stream-json for streaming responses.
|
||||
* Normalizes Cursor events to the AutoMaker ProviderMessage format.
|
||||
*/
|
||||
export class CursorProvider extends BaseProvider {
|
||||
private static CLI_NAME = 'cursor-agent';
|
||||
|
||||
/**
|
||||
* Installation paths based on official cursor-agent install script:
|
||||
*
|
||||
* Linux/macOS:
|
||||
* - Binary: ~/.local/share/cursor-agent/versions/<version>/cursor-agent
|
||||
* - Symlink: ~/.local/bin/cursor-agent -> versions/<version>/cursor-agent
|
||||
*
|
||||
* The install script creates versioned folders like:
|
||||
* ~/.local/share/cursor-agent/versions/2025.12.17-996666f/cursor-agent
|
||||
* And symlinks to ~/.local/bin/cursor-agent
|
||||
*/
|
||||
private static COMMON_PATHS: Record<string, string[]> = {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
||||
'/usr/local/bin/cursor-agent',
|
||||
],
|
||||
darwin: [
|
||||
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
||||
'/usr/local/bin/cursor-agent',
|
||||
],
|
||||
win32: [
|
||||
path.join(os.homedir(), 'AppData/Local/Programs/cursor-agent/cursor-agent.exe'),
|
||||
path.join(os.homedir(), '.local/bin/cursor-agent.exe'),
|
||||
'C:\\Program Files\\cursor-agent\\cursor-agent.exe',
|
||||
],
|
||||
};
|
||||
|
||||
// Version data directory where cursor-agent stores versions
|
||||
private static VERSIONS_DIR = path.join(os.homedir(), '.local/share/cursor-agent/versions');
|
||||
|
||||
private cliPath: string | null = null;
|
||||
private currentProcess: ChildProcess | null = null;
|
||||
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
this.cliPath = config.cliPath || this.findCliPath();
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find cursor-agent CLI in PATH or common installation locations
|
||||
*/
|
||||
private findCliPath(): string | null {
|
||||
// Try 'which' / 'where' first
|
||||
try {
|
||||
const cmd = process.platform === 'win32' ? 'where cursor-agent' : 'which cursor-agent';
|
||||
const result = execSync(cmd, { encoding: 'utf8', timeout: 5000 }).trim().split('\n')[0];
|
||||
if (result && fs.existsSync(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH
|
||||
}
|
||||
|
||||
// Check common installation paths for current platform
|
||||
const platform = process.platform as 'linux' | 'darwin' | 'win32';
|
||||
const platformPaths = CursorProvider.COMMON_PATHS[platform] || [];
|
||||
|
||||
for (const p of platformPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check versions directory for any installed version
|
||||
if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||
try {
|
||||
const versions = fs
|
||||
.readdirSync(CursorProvider.VERSIONS_DIR)
|
||||
.filter((v) => !v.startsWith('.'))
|
||||
.sort()
|
||||
.reverse(); // Most recent first
|
||||
|
||||
for (const version of versions) {
|
||||
const binaryName = platform === 'win32' ? 'cursor-agent.exe' : 'cursor-agent';
|
||||
const versionPath = path.join(CursorProvider.VERSIONS_DIR, version, binaryName);
|
||||
if (fs.existsSync(versionPath)) {
|
||||
return versionPath;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore directory read errors
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Cursor CLI is installed
|
||||
*/
|
||||
async isInstalled(): Promise<boolean> {
|
||||
return this.cliPath !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Cursor CLI version
|
||||
*/
|
||||
async getVersion(): Promise<string | null> {
|
||||
if (!this.cliPath) return null;
|
||||
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*/
|
||||
async checkAuth(): Promise<CursorAuthStatus> {
|
||||
if (!this.cliPath) {
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
// Check for API key in environment
|
||||
if (process.env.CURSOR_API_KEY) {
|
||||
return { authenticated: true, method: 'api_key' };
|
||||
}
|
||||
|
||||
// Check for credentials file (location may vary)
|
||||
const credentialPaths = [
|
||||
path.join(os.homedir(), '.cursor', 'credentials.json'),
|
||||
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
|
||||
];
|
||||
|
||||
for (const credPath of credentialPaths) {
|
||||
if (fs.existsSync(credPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
if (creds.accessToken || creds.token) {
|
||||
return { authenticated: true, method: 'login', hasCredentialsFile: true };
|
||||
}
|
||||
} catch {
|
||||
// Invalid credentials file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try running a simple command to check auth
|
||||
try {
|
||||
execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env },
|
||||
});
|
||||
// If we get here without error, assume authenticated
|
||||
// (actual auth check would need a real API call)
|
||||
return { authenticated: true, method: 'login' };
|
||||
} catch (error: any) {
|
||||
if (error.stderr?.includes('not authenticated') || error.stderr?.includes('log in')) {
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
}
|
||||
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installation status (required by BaseProvider)
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
const installed = await this.isInstalled();
|
||||
const version = installed ? await this.getVersion() : undefined;
|
||||
const auth = await this.checkAuth();
|
||||
|
||||
return {
|
||||
installed,
|
||||
version: version || undefined,
|
||||
path: this.cliPath || undefined,
|
||||
method: 'cli',
|
||||
hasApiKey: !!process.env.CURSOR_API_KEY,
|
||||
authenticated: auth.authenticated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Cursor models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => ({
|
||||
id: `cursor-${id}`,
|
||||
name: config.label,
|
||||
modelString: id,
|
||||
provider: 'cursor',
|
||||
description: config.description,
|
||||
tier: config.tier === 'pro' ? 'premium' : 'basic',
|
||||
supportsTools: true,
|
||||
supportsVision: false, // Cursor CLI may not support vision
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CursorError with details
|
||||
*/
|
||||
private createError(
|
||||
code: CursorErrorCode,
|
||||
message: string,
|
||||
recoverable: boolean = false,
|
||||
suggestion?: string
|
||||
): CursorError {
|
||||
const error = new Error(message) as CursorError;
|
||||
error.code = code;
|
||||
error.recoverable = recoverable;
|
||||
error.suggestion = suggestion;
|
||||
error.name = 'CursorError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map stderr/exit codes to detailed CursorError
|
||||
*/
|
||||
private mapError(stderr: string, exitCode: number | null): CursorError {
|
||||
const lower = stderr.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('not authenticated') ||
|
||||
lower.includes('please log in') ||
|
||||
lower.includes('unauthorized')
|
||||
) {
|
||||
return this.createError(
|
||||
CursorErrorCode.NOT_AUTHENTICATED,
|
||||
'Cursor CLI is not authenticated',
|
||||
true,
|
||||
'Run "cursor-agent login" to authenticate with your browser'
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('429')
|
||||
) {
|
||||
return this.createError(
|
||||
CursorErrorCode.RATE_LIMITED,
|
||||
'Cursor API rate limit exceeded',
|
||||
true,
|
||||
'Wait a few minutes and try again, or upgrade to Cursor Pro'
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('invalid model') ||
|
||||
lower.includes('unknown model')
|
||||
) {
|
||||
return this.createError(
|
||||
CursorErrorCode.MODEL_UNAVAILABLE,
|
||||
'Requested model is not available',
|
||||
true,
|
||||
'Try using "auto" mode or select a different model'
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('network') ||
|
||||
lower.includes('connection') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('timeout')
|
||||
) {
|
||||
return this.createError(
|
||||
CursorErrorCode.NETWORK_ERROR,
|
||||
'Network connection error',
|
||||
true,
|
||||
'Check your internet connection and try again'
|
||||
);
|
||||
}
|
||||
|
||||
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
|
||||
return this.createError(
|
||||
CursorErrorCode.PROCESS_CRASHED,
|
||||
'Cursor agent process was terminated',
|
||||
true,
|
||||
'The process may have run out of memory. Try a simpler task.'
|
||||
);
|
||||
}
|
||||
|
||||
return this.createError(
|
||||
CursorErrorCode.UNKNOWN,
|
||||
stderr || `Cursor agent exited with code ${exitCode}`,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a line of stream-json output
|
||||
*/
|
||||
private parseStreamLine(line: string): CursorStreamEvent | null {
|
||||
if (!line.trim()) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(line) as CursorStreamEvent;
|
||||
} catch {
|
||||
logger.debug('[CursorProvider] Failed to parse stream line:', line);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Cursor event to AutoMaker ProviderMessage format
|
||||
*/
|
||||
private normalizeEvent(event: CursorStreamEvent): ProviderMessage | null {
|
||||
switch (event.type) {
|
||||
case 'system':
|
||||
// System init - we capture session_id but don't yield a message
|
||||
return null;
|
||||
|
||||
case 'user':
|
||||
// User message - already handled by caller
|
||||
return null;
|
||||
|
||||
case 'assistant': {
|
||||
const assistantEvent = event as CursorAssistantEvent;
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: assistantEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: assistantEvent.message.content.map((c) => ({
|
||||
type: 'text' as const,
|
||||
text: c.text,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_call': {
|
||||
const toolEvent = event as CursorToolCallEvent;
|
||||
const toolCall = toolEvent.tool_call;
|
||||
|
||||
// Determine tool name and input
|
||||
let toolName: string;
|
||||
let toolInput: unknown;
|
||||
|
||||
if (toolCall.readToolCall) {
|
||||
toolName = 'Read';
|
||||
toolInput = { file_path: toolCall.readToolCall.args.path };
|
||||
} else if (toolCall.writeToolCall) {
|
||||
toolName = 'Write';
|
||||
toolInput = {
|
||||
file_path: toolCall.writeToolCall.args.path,
|
||||
content: toolCall.writeToolCall.args.fileText,
|
||||
};
|
||||
} else if (toolCall.function) {
|
||||
toolName = toolCall.function.name;
|
||||
try {
|
||||
toolInput = JSON.parse(toolCall.function.arguments || '{}');
|
||||
} catch {
|
||||
toolInput = { raw: toolCall.function.arguments };
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For started events, emit tool_use
|
||||
if (toolEvent.subtype === 'started') {
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: toolName,
|
||||
tool_use_id: toolEvent.call_id,
|
||||
input: toolInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For completed events, emit tool_result
|
||||
if (toolEvent.subtype === 'completed') {
|
||||
let resultContent = '';
|
||||
|
||||
if (toolCall.readToolCall?.result?.success) {
|
||||
resultContent = toolCall.readToolCall.result.success.content;
|
||||
} else if (toolCall.writeToolCall?.result?.success) {
|
||||
resultContent = `Wrote ${toolCall.writeToolCall.result.success.linesCreated} lines to ${toolCall.writeToolCall.result.success.path}`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolEvent.call_id,
|
||||
content: resultContent,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
const resultEvent = event as CursorResultEvent;
|
||||
|
||||
if (resultEvent.is_error) {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: resultEvent.session_id,
|
||||
error: resultEvent.error || resultEvent.result || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: resultEvent.session_id,
|
||||
result: resultEvent.result,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prompt using Cursor CLI with streaming
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
CursorErrorCode.NOT_INSTALLED,
|
||||
'Cursor CLI is not installed',
|
||||
true,
|
||||
'Install with: curl https://cursor.com/install -fsS | bash'
|
||||
);
|
||||
}
|
||||
|
||||
// Extract model from options (strip 'cursor-' prefix if present)
|
||||
let model = options.model || 'auto';
|
||||
if (model.startsWith('cursor-')) {
|
||||
model = model.substring(7);
|
||||
}
|
||||
|
||||
const cwd = options.cwd || process.cwd();
|
||||
|
||||
// Build prompt content
|
||||
let promptText: string;
|
||||
if (typeof options.prompt === 'string') {
|
||||
promptText = options.prompt;
|
||||
} else if (Array.isArray(options.prompt)) {
|
||||
promptText = options.prompt
|
||||
.filter((p) => p.type === 'text' && p.text)
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
} else {
|
||||
throw new Error('Invalid prompt format');
|
||||
}
|
||||
|
||||
// Build CLI arguments
|
||||
const args: string[] = [
|
||||
'-p', // Print mode (non-interactive)
|
||||
'--force', // Allow file modifications
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--stream-partial-output', // Real-time streaming
|
||||
];
|
||||
|
||||
// Add model if not auto
|
||||
if (model !== 'auto') {
|
||||
args.push('--model', model);
|
||||
}
|
||||
|
||||
// Add the prompt
|
||||
args.push(promptText);
|
||||
|
||||
logger.debug(`[CursorProvider] Executing: ${this.cliPath} ${args.slice(0, 6).join(' ')}...`);
|
||||
|
||||
// Use spawnJSONLProcess from @automaker/platform for JSONL streaming
|
||||
// This handles line buffering, timeouts, and abort signals automatically
|
||||
const subprocessOptions: SubprocessOptions = {
|
||||
command: this.cliPath,
|
||||
args,
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
abortController: options.abortController,
|
||||
timeout: 120000, // 2 min timeout for CLI operations (may take longer than default 30s)
|
||||
};
|
||||
|
||||
let sessionId: string | undefined;
|
||||
|
||||
try {
|
||||
// spawnJSONLProcess yields parsed JSON objects, handles errors
|
||||
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
|
||||
const event = rawEvent as CursorStreamEvent;
|
||||
|
||||
// Capture session ID from system init
|
||||
if (event.type === 'system' && (event as CursorSystemEvent).subtype === 'init') {
|
||||
sessionId = event.session_id;
|
||||
}
|
||||
|
||||
// Normalize and yield the event
|
||||
const normalized = this.normalizeEvent(event);
|
||||
if (normalized) {
|
||||
// Ensure session_id is always set
|
||||
if (!normalized.session_id && sessionId) {
|
||||
normalized.session_id = sessionId;
|
||||
}
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Use isAbortError from @automaker/utils for abort detection
|
||||
if (isAbortError(error)) {
|
||||
return; // Clean abort, don't throw
|
||||
}
|
||||
|
||||
// Map CLI errors to CursorError
|
||||
if (error instanceof Error && 'stderr' in error) {
|
||||
throw this.mapError((error as any).stderr || error.message, (error as any).exitCode);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the current execution
|
||||
*/
|
||||
abort(): void {
|
||||
if (this.currentProcess) {
|
||||
this.currentProcess.kill('SIGTERM');
|
||||
this.currentProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is supported
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supported = ['tools', 'text', 'streaming'];
|
||||
return supported.includes(feature);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task 2.2: Create Cursor Config Manager
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/src/providers/cursor-config-manager.ts`
|
||||
|
||||
```typescript
|
||||
import * as path from 'path';
|
||||
import { CursorCliConfig, CursorModelId } from '@automaker/types';
|
||||
import { createLogger, mkdirSafe, existsSafe } from '@automaker/utils';
|
||||
import { getAutomakerDir } from '@automaker/platform';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('CursorConfigManager');
|
||||
|
||||
/**
|
||||
* Manages Cursor CLI configuration
|
||||
* Config location: .automaker/cursor-config.json
|
||||
*/
|
||||
export class CursorConfigManager {
|
||||
private configPath: string;
|
||||
private config: CursorCliConfig;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
// Use getAutomakerDir for consistent path resolution
|
||||
this.configPath = path.join(getAutomakerDir(projectPath), 'cursor-config.json');
|
||||
this.config = this.loadConfig();
|
||||
}
|
||||
|
||||
private loadConfig(): CursorCliConfig {
|
||||
try {
|
||||
if (fs.existsSync(this.configPath)) {
|
||||
const content = fs.readFileSync(this.configPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[CursorConfigManager] Failed to load config:', error);
|
||||
}
|
||||
|
||||
// Return default config
|
||||
return {
|
||||
defaultModel: 'auto',
|
||||
models: ['auto', 'claude-sonnet-4', 'gpt-4o-mini'],
|
||||
};
|
||||
}
|
||||
|
||||
private saveConfig(): void {
|
||||
try {
|
||||
const dir = path.dirname(this.configPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
|
||||
logger.debug('[CursorConfigManager] Config saved');
|
||||
} catch (error) {
|
||||
logger.error('[CursorConfigManager] Failed to save config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): CursorCliConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
getDefaultModel(): CursorModelId {
|
||||
return this.config.defaultModel || 'auto';
|
||||
}
|
||||
|
||||
setDefaultModel(model: CursorModelId): void {
|
||||
this.config.defaultModel = model;
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
getEnabledModels(): CursorModelId[] {
|
||||
return this.config.models || ['auto'];
|
||||
}
|
||||
|
||||
setEnabledModels(models: CursorModelId[]): void {
|
||||
this.config.models = models;
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
addModel(model: CursorModelId): void {
|
||||
if (!this.config.models) {
|
||||
this.config.models = [];
|
||||
}
|
||||
if (!this.config.models.includes(model)) {
|
||||
this.config.models.push(model);
|
||||
this.saveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
removeModel(model: CursorModelId): void {
|
||||
if (this.config.models) {
|
||||
this.config.models = this.config.models.filter((m) => m !== model);
|
||||
this.saveConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test 1: Provider Instantiation
|
||||
|
||||
```typescript
|
||||
// test-cursor-provider.ts
|
||||
import { CursorProvider } from './apps/server/src/providers/cursor-provider';
|
||||
|
||||
const provider = new CursorProvider();
|
||||
console.log('Provider name:', provider.getName()); // Should be 'cursor'
|
||||
|
||||
const status = await provider.detectInstallation();
|
||||
console.log('Installation status:', status);
|
||||
|
||||
const models = provider.getAvailableModels();
|
||||
console.log('Available models:', models.length);
|
||||
```
|
||||
|
||||
### Test 2: CLI Detection (requires cursor-agent installed)
|
||||
|
||||
```bash
|
||||
# Check if cursor-agent is found
|
||||
node -e "
|
||||
const { CursorProvider } = require('./apps/server/dist/providers/cursor-provider');
|
||||
const p = new CursorProvider();
|
||||
p.isInstalled().then(installed => {
|
||||
console.log('Installed:', installed);
|
||||
if (installed) {
|
||||
p.getVersion().then(v => console.log('Version:', v));
|
||||
p.checkAuth().then(a => console.log('Auth:', a));
|
||||
}
|
||||
});
|
||||
"
|
||||
```
|
||||
|
||||
### Test 3: Simple Query (requires cursor-agent authenticated)
|
||||
|
||||
```typescript
|
||||
// test-cursor-query.ts
|
||||
import { CursorProvider } from './apps/server/src/providers/cursor-provider';
|
||||
|
||||
const provider = new CursorProvider();
|
||||
const stream = provider.executeQuery({
|
||||
prompt: 'What is 2 + 2? Reply with just the number.',
|
||||
model: 'auto',
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
for await (const msg of stream) {
|
||||
console.log('Message:', JSON.stringify(msg, null, 2));
|
||||
}
|
||||
```
|
||||
|
||||
### Test 4: Error Handling
|
||||
|
||||
```typescript
|
||||
// Test with invalid model
|
||||
try {
|
||||
const stream = provider.executeQuery({
|
||||
prompt: 'test',
|
||||
model: 'invalid-model-xyz',
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
for await (const msg of stream) {
|
||||
// Should not reach here
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error code:', error.code);
|
||||
console.log('Suggestion:', error.suggestion);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] `cursor-provider.ts` compiles without errors
|
||||
- [ ] `cursor-config-manager.ts` compiles without errors
|
||||
- [ ] Provider returns correct name ('cursor')
|
||||
- [ ] `detectInstallation()` correctly detects CLI
|
||||
- [ ] `getAvailableModels()` returns model definitions
|
||||
- [ ] `executeQuery()` streams messages (if CLI installed)
|
||||
- [ ] Errors are properly mapped to CursorError
|
||||
- [ ] Abort signal terminates process
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Description |
|
||||
| ---------------------------------------------------- | ------ | ----------------- |
|
||||
| `apps/server/src/providers/cursor-provider.ts` | Create | Main provider |
|
||||
| `apps/server/src/providers/cursor-config-manager.ts` | Create | Config management |
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Windows Support**: CLI path detection may need adjustment
|
||||
2. **Vision**: Cursor CLI may not support image inputs
|
||||
3. **Resume**: Session resumption not implemented in Phase 2
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The provider uses `--stream-partial-output` for real-time character streaming
|
||||
- Tool call events are normalized to match Claude SDK format
|
||||
- Session IDs are captured from system init event
|
||||
229
plan/cursor-cli-integration/phases/phase-3-factory.md
Normal file
229
plan/cursor-cli-integration/phases/phase-3-factory.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Phase 3: Provider Factory Integration
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** Phase 2 (Provider)
|
||||
**Estimated Effort:** Small (routing logic only)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Integrate CursorProvider into the ProviderFactory so models are automatically routed to the correct provider.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 3.1: Update Provider Factory
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/src/providers/provider-factory.ts`
|
||||
|
||||
Add Cursor provider import and routing:
|
||||
|
||||
```typescript
|
||||
import { CursorProvider } from './cursor-provider';
|
||||
import { CURSOR_MODEL_MAP } from '@automaker/types';
|
||||
|
||||
export class ProviderFactory {
|
||||
/**
|
||||
* Determine which provider to use for a given model
|
||||
*/
|
||||
static getProviderNameForModel(model: string): 'claude' | 'cursor' {
|
||||
const lowerModel = model.toLowerCase();
|
||||
|
||||
// Check for explicit cursor prefix
|
||||
if (lowerModel.startsWith('cursor-')) {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
// Check if it's a known Cursor model ID
|
||||
const cursorModelId = lowerModel.replace('cursor-', '');
|
||||
if (cursorModelId in CURSOR_MODEL_MAP) {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
// Check for Cursor-specific patterns
|
||||
if (
|
||||
lowerModel === 'auto' ||
|
||||
lowerModel.includes('gpt-') ||
|
||||
lowerModel.includes('gemini-') ||
|
||||
lowerModel === 'o3-mini'
|
||||
) {
|
||||
// These could be Cursor models, but we default to Claude
|
||||
// unless explicitly prefixed with cursor-
|
||||
}
|
||||
|
||||
// Check for Claude model patterns
|
||||
if (
|
||||
lowerModel.startsWith('claude-') ||
|
||||
['opus', 'sonnet', 'haiku'].some((n) => lowerModel.includes(n))
|
||||
) {
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// Default to Claude
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider instance for the given model
|
||||
*/
|
||||
static getProviderForModel(model: string, config?: ProviderConfig): BaseProvider {
|
||||
const providerName = this.getProviderNameForModel(model);
|
||||
|
||||
if (providerName === 'cursor') {
|
||||
return new CursorProvider(config);
|
||||
}
|
||||
|
||||
return new ClaudeProvider(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered providers
|
||||
*/
|
||||
static getAllProviders(): BaseProvider[] {
|
||||
return [new ClaudeProvider(), new CursorProvider()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider by name
|
||||
*/
|
||||
static getProviderByName(name: string): BaseProvider | null {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
switch (lowerName) {
|
||||
case 'claude':
|
||||
return new ClaudeProvider();
|
||||
case 'cursor':
|
||||
return new CursorProvider();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check installation status of all providers
|
||||
*/
|
||||
static async checkAllProviders(): Promise<Record<string, InstallationStatus>> {
|
||||
const providers = this.getAllProviders();
|
||||
const statuses: Record<string, InstallationStatus> = {};
|
||||
|
||||
await Promise.all(
|
||||
providers.map(async (provider) => {
|
||||
const status = await provider.detectInstallation();
|
||||
statuses[provider.getName()] = status;
|
||||
})
|
||||
);
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models from all providers
|
||||
*/
|
||||
static getAllAvailableModels(): ModelDefinition[] {
|
||||
const providers = this.getAllProviders();
|
||||
return providers.flatMap((p) => p.getAvailableModels());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3.2: Export CursorProvider
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/src/providers/index.ts`
|
||||
|
||||
Add export:
|
||||
|
||||
```typescript
|
||||
export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider';
|
||||
export { CursorConfigManager } from './cursor-config-manager';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test 1: Model Routing
|
||||
|
||||
```typescript
|
||||
import { ProviderFactory } from './apps/server/src/providers/provider-factory';
|
||||
|
||||
// Cursor models
|
||||
console.assert(ProviderFactory.getProviderNameForModel('cursor-auto') === 'cursor');
|
||||
console.assert(ProviderFactory.getProviderNameForModel('cursor-gpt-4o') === 'cursor');
|
||||
console.assert(ProviderFactory.getProviderNameForModel('cursor-claude-sonnet-4') === 'cursor');
|
||||
|
||||
// Claude models (default)
|
||||
console.assert(ProviderFactory.getProviderNameForModel('claude-sonnet-4') === 'claude');
|
||||
console.assert(ProviderFactory.getProviderNameForModel('opus') === 'claude');
|
||||
console.assert(ProviderFactory.getProviderNameForModel('sonnet') === 'claude');
|
||||
console.assert(ProviderFactory.getProviderNameForModel('haiku') === 'claude');
|
||||
|
||||
// Unknown models default to Claude
|
||||
console.assert(ProviderFactory.getProviderNameForModel('unknown-model') === 'claude');
|
||||
|
||||
console.log('All routing tests passed!');
|
||||
```
|
||||
|
||||
### Test 2: Provider Instantiation
|
||||
|
||||
```typescript
|
||||
import { ProviderFactory } from './apps/server/src/providers/provider-factory';
|
||||
|
||||
const cursorProvider = ProviderFactory.getProviderForModel('cursor-auto');
|
||||
console.assert(cursorProvider.getName() === 'cursor');
|
||||
|
||||
const claudeProvider = ProviderFactory.getProviderForModel('sonnet');
|
||||
console.assert(claudeProvider.getName() === 'claude');
|
||||
|
||||
console.log('Provider instantiation tests passed!');
|
||||
```
|
||||
|
||||
### Test 3: All Providers Check
|
||||
|
||||
```typescript
|
||||
import { ProviderFactory } from './apps/server/src/providers/provider-factory';
|
||||
|
||||
const statuses = await ProviderFactory.checkAllProviders();
|
||||
console.log('Provider statuses:', statuses);
|
||||
// Should have both 'claude' and 'cursor' keys
|
||||
|
||||
const allModels = ProviderFactory.getAllAvailableModels();
|
||||
console.log('Total models:', allModels.length);
|
||||
// Should include models from both providers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] ProviderFactory routes `cursor-*` models to CursorProvider
|
||||
- [ ] ProviderFactory routes Claude models to ClaudeProvider
|
||||
- [ ] `getAllProviders()` returns both providers
|
||||
- [ ] `getProviderByName('cursor')` returns CursorProvider
|
||||
- [ ] `checkAllProviders()` returns status for both providers
|
||||
- [ ] `getAllAvailableModels()` includes Cursor models
|
||||
- [ ] Existing Claude routing not broken
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Description |
|
||||
| ----------------------------------------------- | ------ | --------------------- |
|
||||
| `apps/server/src/providers/provider-factory.ts` | Modify | Add Cursor routing |
|
||||
| `apps/server/src/providers/index.ts` | Modify | Export CursorProvider |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Model routing uses prefix matching for explicit `cursor-` models
|
||||
- Unknown models default to Claude for backward compatibility
|
||||
- The factory is stateless - new provider instances created per call
|
||||
348
plan/cursor-cli-integration/phases/phase-4-routes.md
Normal file
348
plan/cursor-cli-integration/phases/phase-4-routes.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Phase 4: Setup Routes & Status Endpoints
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** Phase 3 (Factory)
|
||||
**Estimated Effort:** Medium (API endpoints)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Create API endpoints for checking Cursor CLI status and managing configuration.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 4.1: Create Cursor Status Route
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/src/routes/setup/routes/cursor-status.ts`
|
||||
|
||||
```typescript
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { CursorProvider } from '../../../providers/cursor-provider';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('CursorStatusRoute');
|
||||
|
||||
/**
|
||||
* GET /api/setup/cursor-status
|
||||
* Returns Cursor CLI installation and authentication status
|
||||
*/
|
||||
export function createCursorStatusHandler() {
|
||||
return async (req: Request, res: Response) => {
|
||||
try {
|
||||
const provider = new CursorProvider();
|
||||
|
||||
const [installed, version, auth] = await Promise.all([
|
||||
provider.isInstalled(),
|
||||
provider.getVersion(),
|
||||
provider.checkAuth(),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed,
|
||||
version: version || null,
|
||||
path: installed ? (provider as any).cliPath : null,
|
||||
auth: {
|
||||
authenticated: auth.authenticated,
|
||||
method: auth.method,
|
||||
},
|
||||
installCommand: 'curl https://cursor.com/install -fsS | bash',
|
||||
loginCommand: 'cursor-agent login',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[cursor-status] Error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createCursorStatusRoute(): Router {
|
||||
const router = Router();
|
||||
router.get('/cursor-status', createCursorStatusHandler());
|
||||
return router;
|
||||
}
|
||||
```
|
||||
|
||||
### Task 4.2: Create Cursor Config Routes
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/src/routes/setup/routes/cursor-config.ts`
|
||||
|
||||
```typescript
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { CursorConfigManager } from '../../../providers/cursor-config-manager';
|
||||
import { CURSOR_MODEL_MAP, CursorModelId } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('CursorConfigRoute');
|
||||
|
||||
export function createCursorConfigRoutes(dataDir: string): Router {
|
||||
const router = Router();
|
||||
const configManager = new CursorConfigManager(dataDir);
|
||||
|
||||
/**
|
||||
* GET /api/setup/cursor-config
|
||||
* Get current Cursor configuration
|
||||
*/
|
||||
router.get('/cursor-config', (req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
success: true,
|
||||
config: configManager.getConfig(),
|
||||
availableModels: Object.values(CURSOR_MODEL_MAP),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[cursor-config] GET error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/setup/cursor-config/default-model
|
||||
* Set the default Cursor model
|
||||
*/
|
||||
router.post('/cursor-config/default-model', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { model } = req.body;
|
||||
|
||||
if (!model || !(model in CURSOR_MODEL_MAP)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid model ID. Valid models: ${Object.keys(CURSOR_MODEL_MAP).join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
configManager.setDefaultModel(model as CursorModelId);
|
||||
res.json({ success: true, model });
|
||||
} catch (error) {
|
||||
logger.error('[cursor-config] POST default-model error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/setup/cursor-config/models
|
||||
* Set enabled Cursor models
|
||||
*/
|
||||
router.post('/cursor-config/models', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { models } = req.body;
|
||||
|
||||
if (!Array.isArray(models)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Models must be an array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to valid models only
|
||||
const validModels = models.filter((m): m is CursorModelId => m in CURSOR_MODEL_MAP);
|
||||
|
||||
if (validModels.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'No valid models provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
configManager.setEnabledModels(validModels);
|
||||
res.json({ success: true, models: validModels });
|
||||
} catch (error) {
|
||||
logger.error('[cursor-config] POST models error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
```
|
||||
|
||||
### Task 4.3: Register Routes in Setup Index
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/src/routes/setup/index.ts`
|
||||
|
||||
Add to existing router:
|
||||
|
||||
```typescript
|
||||
import { createCursorStatusRoute } from './routes/cursor-status';
|
||||
import { createCursorConfigRoutes } from './routes/cursor-config';
|
||||
|
||||
// In the router setup function:
|
||||
export function createSetupRouter(dataDir: string): Router {
|
||||
const router = Router();
|
||||
|
||||
// Existing routes...
|
||||
router.get('/claude-status', createClaudeStatusHandler());
|
||||
// ...
|
||||
|
||||
// Add Cursor routes
|
||||
router.use(createCursorStatusRoute());
|
||||
router.use(createCursorConfigRoutes(dataDir));
|
||||
|
||||
return router;
|
||||
}
|
||||
```
|
||||
|
||||
### Task 4.4: Update HttpApiClient
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/lib/http-api-client.ts`
|
||||
|
||||
Add Cursor methods to the HttpApiClient setup object:
|
||||
|
||||
```typescript
|
||||
// In HttpApiClient class, extend the setup object:
|
||||
|
||||
setup = {
|
||||
// Existing methods...
|
||||
getClaudeStatus: () => this.get('/api/setup/claude-status'),
|
||||
|
||||
// Add Cursor methods
|
||||
getCursorStatus: () =>
|
||||
this.get<{
|
||||
success: boolean;
|
||||
installed?: boolean;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
};
|
||||
installCommand?: string;
|
||||
loginCommand?: string;
|
||||
error?: string;
|
||||
}>('/api/setup/cursor-status'),
|
||||
|
||||
getCursorConfig: () =>
|
||||
this.get<{
|
||||
success: boolean;
|
||||
config?: CursorCliConfig;
|
||||
availableModels?: CursorModelConfig[];
|
||||
error?: string;
|
||||
}>('/api/setup/cursor-config'),
|
||||
|
||||
setCursorDefaultModel: (model: CursorModelId) =>
|
||||
this.post<{ success: boolean; error?: string }>('/api/setup/cursor-config/default-model', {
|
||||
model,
|
||||
}),
|
||||
|
||||
setCursorModels: (models: CursorModelId[]) =>
|
||||
this.post<{ success: boolean; error?: string }>('/api/setup/cursor-config/models', { models }),
|
||||
};
|
||||
```
|
||||
|
||||
This integrates with the existing HttpApiClient pattern used throughout the UI.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test 1: Status Endpoint
|
||||
|
||||
```bash
|
||||
# Start the server, then:
|
||||
curl http://localhost:3001/api/setup/cursor-status
|
||||
|
||||
# Expected response (if installed):
|
||||
# {
|
||||
# "success": true,
|
||||
# "installed": true,
|
||||
# "version": "0.1.0",
|
||||
# "path": "/home/user/.local/bin/cursor-agent",
|
||||
# "auth": { "authenticated": true, "method": "login" }
|
||||
# }
|
||||
|
||||
# Expected response (if not installed):
|
||||
# {
|
||||
# "success": true,
|
||||
# "installed": false,
|
||||
# "installCommand": "curl https://cursor.com/install -fsS | bash"
|
||||
# }
|
||||
```
|
||||
|
||||
### Test 2: Config Endpoints
|
||||
|
||||
```bash
|
||||
# Get config
|
||||
curl http://localhost:3001/api/setup/cursor-config
|
||||
|
||||
# Set default model
|
||||
curl -X POST http://localhost:3001/api/setup/cursor-config/default-model \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "gpt-4o"}'
|
||||
|
||||
# Set enabled models
|
||||
curl -X POST http://localhost:3001/api/setup/cursor-config/models \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"models": ["auto", "gpt-4o", "claude-sonnet-4"]}'
|
||||
```
|
||||
|
||||
### Test 3: Error Handling
|
||||
|
||||
```bash
|
||||
# Invalid model should return 400
|
||||
curl -X POST http://localhost:3001/api/setup/cursor-config/default-model \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "invalid-model"}'
|
||||
|
||||
# Expected: {"success": false, "error": "Invalid model ID..."}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] `/api/setup/cursor-status` returns installation status
|
||||
- [ ] `/api/setup/cursor-config` returns current config
|
||||
- [ ] `/api/setup/cursor-config/default-model` updates default
|
||||
- [ ] `/api/setup/cursor-config/models` updates enabled models
|
||||
- [ ] Error responses have correct status codes (400, 500)
|
||||
- [ ] Config persists to file after changes
|
||||
- [ ] SetupAPI type updated (if using Electron IPC)
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Description |
|
||||
| ------------------------------------------------------ | ------ | ---------------- |
|
||||
| `apps/server/src/routes/setup/routes/cursor-status.ts` | Create | Status endpoint |
|
||||
| `apps/server/src/routes/setup/routes/cursor-config.ts` | Create | Config endpoints |
|
||||
| `apps/server/src/routes/setup/index.ts` | Modify | Register routes |
|
||||
| `apps/ui/src/lib/electron.ts` | Modify | Add API types |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Config is stored in `.automaker/cursor-config.json`
|
||||
- The status endpoint is optimized for quick checks (parallel calls)
|
||||
- Install/login commands are included in response for UI display
|
||||
374
plan/cursor-cli-integration/phases/phase-5-log-parser.md
Normal file
374
plan/cursor-cli-integration/phases/phase-5-log-parser.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# Phase 5: Log Parser Integration
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** Phase 2 (Provider), Phase 3 (Factory)
|
||||
**Estimated Effort:** Small (parser extension)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Update the log parser to recognize and normalize Cursor CLI stream events for display in the log viewer.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 5.1: Add Cursor Event Type Detection
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/lib/log-parser.ts`
|
||||
|
||||
Add Cursor event detection and normalization:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
CursorStreamEvent,
|
||||
CursorSystemEvent,
|
||||
CursorAssistantEvent,
|
||||
CursorToolCallEvent,
|
||||
CursorResultEvent,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Detect if a parsed JSON object is a Cursor stream event
|
||||
*/
|
||||
function isCursorEvent(obj: any): obj is CursorStreamEvent {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === 'object' &&
|
||||
'type' in obj &&
|
||||
'session_id' in obj &&
|
||||
['system', 'user', 'assistant', 'tool_call', 'result'].includes(obj.type)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Cursor stream event to log entry
|
||||
*/
|
||||
export function normalizeCursorEvent(event: CursorStreamEvent): LogEntry | null {
|
||||
const timestamp = new Date().toISOString();
|
||||
const baseEntry = {
|
||||
id: `cursor-${event.session_id}-${Date.now()}`,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
switch (event.type) {
|
||||
case 'system': {
|
||||
const sysEvent = event as CursorSystemEvent;
|
||||
return {
|
||||
...baseEntry,
|
||||
type: 'info' as LogEntryType,
|
||||
title: 'Session Started',
|
||||
content: `Model: ${sysEvent.model}\nAuth: ${sysEvent.apiKeySource}\nCWD: ${sysEvent.cwd}`,
|
||||
collapsed: true,
|
||||
metadata: {
|
||||
phase: 'init',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'assistant': {
|
||||
const assistEvent = event as CursorAssistantEvent;
|
||||
const text = assistEvent.message.content
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => c.text)
|
||||
.join('');
|
||||
|
||||
if (!text.trim()) return null;
|
||||
|
||||
return {
|
||||
...baseEntry,
|
||||
type: 'info' as LogEntryType,
|
||||
title: 'Assistant',
|
||||
content: text,
|
||||
collapsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_call': {
|
||||
const toolEvent = event as CursorToolCallEvent;
|
||||
return normalizeCursorToolCall(toolEvent, baseEntry);
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
const resultEvent = event as CursorResultEvent;
|
||||
|
||||
if (resultEvent.is_error) {
|
||||
return {
|
||||
...baseEntry,
|
||||
type: 'error' as LogEntryType,
|
||||
title: 'Error',
|
||||
content: resultEvent.error || resultEvent.result || 'Unknown error',
|
||||
collapsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseEntry,
|
||||
type: 'success' as LogEntryType,
|
||||
title: 'Completed',
|
||||
content: `Duration: ${resultEvent.duration_ms}ms`,
|
||||
collapsed: true,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Cursor tool call event
|
||||
*/
|
||||
function normalizeCursorToolCall(
|
||||
event: CursorToolCallEvent,
|
||||
baseEntry: { id: string; timestamp: string }
|
||||
): LogEntry | null {
|
||||
const toolCall = event.tool_call;
|
||||
const isStarted = event.subtype === 'started';
|
||||
const isCompleted = event.subtype === 'completed';
|
||||
|
||||
// Read tool
|
||||
if (toolCall.readToolCall) {
|
||||
const path = toolCall.readToolCall.args.path;
|
||||
const result = toolCall.readToolCall.result?.success;
|
||||
|
||||
return {
|
||||
...baseEntry,
|
||||
id: `${baseEntry.id}-${event.call_id}`,
|
||||
type: 'tool_call' as LogEntryType,
|
||||
title: isStarted ? `Reading ${path}` : `Read ${path}`,
|
||||
content:
|
||||
isCompleted && result
|
||||
? `${result.totalLines} lines, ${result.totalChars} chars`
|
||||
: `Path: ${path}`,
|
||||
collapsed: true,
|
||||
metadata: {
|
||||
toolName: 'Read',
|
||||
toolCategory: 'read' as ToolCategory,
|
||||
filePath: path,
|
||||
summary: isCompleted ? `Read ${result?.totalLines || 0} lines` : `Reading file...`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Write tool
|
||||
if (toolCall.writeToolCall) {
|
||||
const path =
|
||||
toolCall.writeToolCall.args?.path ||
|
||||
toolCall.writeToolCall.result?.success?.path ||
|
||||
'unknown';
|
||||
const result = toolCall.writeToolCall.result?.success;
|
||||
|
||||
return {
|
||||
...baseEntry,
|
||||
id: `${baseEntry.id}-${event.call_id}`,
|
||||
type: 'tool_call' as LogEntryType,
|
||||
title: isStarted ? `Writing ${path}` : `Wrote ${path}`,
|
||||
content:
|
||||
isCompleted && result
|
||||
? `${result.linesCreated} lines, ${result.fileSize} bytes`
|
||||
: `Path: ${path}`,
|
||||
collapsed: true,
|
||||
metadata: {
|
||||
toolName: 'Write',
|
||||
toolCategory: 'write' as ToolCategory,
|
||||
filePath: path,
|
||||
summary: isCompleted ? `Wrote ${result?.linesCreated || 0} lines` : `Writing file...`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Generic function tool
|
||||
if (toolCall.function) {
|
||||
const name = toolCall.function.name;
|
||||
const args = toolCall.function.arguments;
|
||||
|
||||
// Determine category based on tool name
|
||||
let category: ToolCategory = 'other';
|
||||
if (['Read', 'Glob'].includes(name)) category = 'read';
|
||||
if (['Write', 'Edit'].includes(name)) category = 'edit';
|
||||
if (['Bash'].includes(name)) category = 'bash';
|
||||
if (['Grep'].includes(name)) category = 'search';
|
||||
if (['TodoWrite'].includes(name)) category = 'todo';
|
||||
if (['Task'].includes(name)) category = 'task';
|
||||
|
||||
return {
|
||||
...baseEntry,
|
||||
id: `${baseEntry.id}-${event.call_id}`,
|
||||
type: 'tool_call' as LogEntryType,
|
||||
title: `${name} ${isStarted ? 'started' : 'completed'}`,
|
||||
content: args || '',
|
||||
collapsed: true,
|
||||
metadata: {
|
||||
toolName: name,
|
||||
toolCategory: category,
|
||||
summary: `${name} ${event.subtype}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Task 5.2: Update parseLogLine Function
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/lib/log-parser.ts`
|
||||
|
||||
Update the main parsing function to detect Cursor events:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Parse a single log line into a structured entry
|
||||
*/
|
||||
export function parseLogLine(line: string): LogEntry | null {
|
||||
if (!line.trim()) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
|
||||
// Check if it's a Cursor stream event
|
||||
if (isCursorEvent(parsed)) {
|
||||
return normalizeCursorEvent(parsed);
|
||||
}
|
||||
|
||||
// Existing AutoMaker/Claude event parsing...
|
||||
return parseAutoMakerEvent(parsed);
|
||||
} catch {
|
||||
// Non-JSON line - treat as plain text
|
||||
return {
|
||||
id: `text-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
type: 'info',
|
||||
title: 'Output',
|
||||
content: line,
|
||||
timestamp: new Date().toISOString(),
|
||||
collapsed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task 5.3: Add Cursor-Specific Styling (Optional)
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/lib/log-parser.ts`
|
||||
|
||||
Add provider-aware styling:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Get provider-specific styling for log entries
|
||||
*/
|
||||
export function getProviderStyle(entry: LogEntry): { badge?: string; icon?: string } {
|
||||
// Check if entry has Cursor session ID pattern
|
||||
if (entry.id.startsWith('cursor-')) {
|
||||
return {
|
||||
badge: 'Cursor',
|
||||
icon: 'terminal', // Or a Cursor-specific icon
|
||||
};
|
||||
}
|
||||
|
||||
// Default (Claude)
|
||||
return {
|
||||
badge: 'Claude',
|
||||
icon: 'bot',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test 1: Cursor Event Parsing
|
||||
|
||||
```typescript
|
||||
import { parseLogLine, normalizeCursorEvent } from './apps/ui/src/lib/log-parser';
|
||||
|
||||
// Test system init
|
||||
const systemEvent =
|
||||
'{"type":"system","subtype":"init","apiKeySource":"login","cwd":"/project","session_id":"abc-123","model":"Claude 4 Sonnet","permissionMode":"default"}';
|
||||
const systemEntry = parseLogLine(systemEvent);
|
||||
console.assert(systemEntry?.type === 'info', 'System event should be info type');
|
||||
console.assert(systemEntry?.title === 'Session Started', 'System should have correct title');
|
||||
|
||||
// Test assistant message
|
||||
const assistantEvent =
|
||||
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello world"}]},"session_id":"abc-123"}';
|
||||
const assistantEntry = parseLogLine(assistantEvent);
|
||||
console.assert(assistantEntry?.content === 'Hello world', 'Assistant content should match');
|
||||
|
||||
// Test tool call
|
||||
const toolEvent =
|
||||
'{"type":"tool_call","subtype":"started","call_id":"call-1","tool_call":{"readToolCall":{"args":{"path":"test.ts"}}},"session_id":"abc-123"}';
|
||||
const toolEntry = parseLogLine(toolEvent);
|
||||
console.assert(toolEntry?.metadata?.toolName === 'Read', 'Tool name should be Read');
|
||||
console.assert(toolEntry?.metadata?.toolCategory === 'read', 'Category should be read');
|
||||
|
||||
console.log('All Cursor parsing tests passed!');
|
||||
```
|
||||
|
||||
### Test 2: Mixed Event Stream
|
||||
|
||||
```typescript
|
||||
// Simulate a stream with both Claude and Cursor events
|
||||
const events = [
|
||||
// Cursor events
|
||||
'{"type":"system","subtype":"init","session_id":"cur-1","model":"GPT-4o","apiKeySource":"login","cwd":"/project","permissionMode":"default"}',
|
||||
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Reading file..."}]},"session_id":"cur-1"}',
|
||||
'{"type":"tool_call","subtype":"started","call_id":"t1","tool_call":{"readToolCall":{"args":{"path":"README.md"}}},"session_id":"cur-1"}',
|
||||
// Claude-style event (existing format)
|
||||
'{"type":"assistant","content":[{"type":"text","text":"From Claude"}]}',
|
||||
];
|
||||
|
||||
const entries = events.map(parseLogLine).filter(Boolean);
|
||||
console.log('Parsed entries:', entries.length);
|
||||
// Should parse all events correctly
|
||||
```
|
||||
|
||||
### Test 3: Log Viewer Integration
|
||||
|
||||
1. Start the app with a Cursor provider task
|
||||
2. Observe log viewer updates in real-time
|
||||
3. Verify:
|
||||
- Tool calls show correct icons
|
||||
- File paths are highlighted
|
||||
- Collapsed by default where appropriate
|
||||
- Timestamps are displayed
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] `isCursorEvent()` correctly identifies Cursor events
|
||||
- [ ] `normalizeCursorEvent()` handles all event types
|
||||
- [ ] Tool calls are categorized correctly
|
||||
- [ ] File paths extracted for Read/Write tools
|
||||
- [ ] Existing Claude event parsing not broken
|
||||
- [ ] Log viewer displays Cursor events correctly
|
||||
- [ ] No runtime errors with malformed events
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Description |
|
||||
| ------------------------------- | ------ | ------------------------------ |
|
||||
| `apps/ui/src/lib/log-parser.ts` | Modify | Add Cursor event normalization |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Cursor events have `session_id` on all events (unlike Claude SDK)
|
||||
- Tool call events come in pairs: started + completed
|
||||
- The `call_id` is used to correlate started/completed events
|
||||
- Entry IDs include session_id for uniqueness
|
||||
457
plan/cursor-cli-integration/phases/phase-6-setup-wizard.md
Normal file
457
plan/cursor-cli-integration/phases/phase-6-setup-wizard.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# Phase 6: UI Setup Wizard
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** Phase 4 (Routes)
|
||||
**Estimated Effort:** Medium (React component)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Add an optional Cursor CLI setup step to the welcome wizard, allowing users to configure Cursor as an AI provider during initial setup.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 6.1: Create Cursor Setup Step Component
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx`
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { CheckCircle2, XCircle, Loader2, ExternalLink, Terminal, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/http-api-client';
|
||||
|
||||
interface CursorSetupStepProps {
|
||||
onComplete: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
interface CliStatus {
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
};
|
||||
installCommand?: string;
|
||||
loginCommand?: string;
|
||||
}
|
||||
|
||||
export function CursorSetupStep({ onComplete, onSkip }: CursorSetupStepProps) {
|
||||
const [status, setStatus] = useState<CliStatus | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const result = await api.setup.getCursorStatus();
|
||||
|
||||
if (result.success) {
|
||||
setStatus({
|
||||
installed: result.installed ?? false,
|
||||
version: result.version,
|
||||
path: result.path,
|
||||
auth: result.auth,
|
||||
installCommand: result.installCommand,
|
||||
loginCommand: result.loginCommand,
|
||||
});
|
||||
|
||||
if (result.auth?.authenticated) {
|
||||
toast.success('Cursor CLI is ready!');
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to check Cursor status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check Cursor status:', error);
|
||||
toast.error('Failed to check Cursor CLI status');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setIsLoggingIn(true);
|
||||
|
||||
try {
|
||||
// Copy login command to clipboard and show instructions
|
||||
if (status?.loginCommand) {
|
||||
await navigator.clipboard.writeText(status.loginCommand);
|
||||
toast.info('Login command copied! Paste in terminal to authenticate.');
|
||||
}
|
||||
|
||||
// Poll for auth status
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60; // 2 minutes with 2s interval
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
attempts++;
|
||||
|
||||
try {
|
||||
const result = await api.setup.getCursorStatus();
|
||||
|
||||
if (result.auth?.authenticated) {
|
||||
clearInterval(pollInterval);
|
||||
setStatus((prev) => (prev ? { ...prev, auth: result.auth } : null));
|
||||
setIsLoggingIn(false);
|
||||
toast.success('Successfully logged in to Cursor!');
|
||||
}
|
||||
} catch {
|
||||
// Ignore polling errors
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(pollInterval);
|
||||
setIsLoggingIn(false);
|
||||
toast.error('Login timed out. Please try again.');
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
toast.error('Failed to start login process');
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInstallCommand = async () => {
|
||||
if (status?.installCommand) {
|
||||
await navigator.clipboard.writeText(status.installCommand);
|
||||
toast.success('Install command copied to clipboard!');
|
||||
}
|
||||
};
|
||||
|
||||
const isComplete = status?.installed && status?.auth?.authenticated;
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5" />
|
||||
Cursor CLI Setup
|
||||
<Badge variant="outline" className="ml-2">
|
||||
Optional
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure Cursor CLI as an alternative AI provider. You can skip this and use Claude
|
||||
instead, or configure it later in Settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Installation Status */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">CLI Installation</span>
|
||||
{isChecking ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : status?.installed ? (
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs">v{status.version}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs">Not installed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!status?.installed && !isChecking && (
|
||||
<Alert>
|
||||
<AlertDescription className="text-sm space-y-3">
|
||||
<p>Install Cursor CLI to use Cursor models:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted p-2 rounded text-xs font-mono overflow-x-auto">
|
||||
{status?.installCommand || 'curl https://cursor.com/install -fsS | bash'}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyInstallCommand}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="p-0 h-auto"
|
||||
onClick={() => window.open('https://cursor.com/docs/cli', '_blank')}
|
||||
>
|
||||
View installation docs
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Authentication Status */}
|
||||
{status?.installed && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Authentication</span>
|
||||
{status.auth?.authenticated ? (
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs capitalize">
|
||||
{status.auth.method === 'api_key' ? 'API Key' : 'Browser Login'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs">Not authenticated</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!status.auth?.authenticated && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run the login command in your terminal, then complete authentication in your
|
||||
browser:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted p-2 rounded text-xs font-mono">
|
||||
{status.loginCommand || 'cursor-agent login'}
|
||||
</code>
|
||||
</div>
|
||||
<Button onClick={handleLogin} disabled={isLoggingIn} className="w-full">
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Waiting for login...
|
||||
</>
|
||||
) : (
|
||||
'Copy Command & Wait for Login'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<Button variant="outline" onClick={onSkip} className="flex-1">
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
disabled={!isComplete && status?.installed}
|
||||
className="flex-1"
|
||||
>
|
||||
{isComplete ? 'Continue' : 'Complete setup to continue'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={checkStatus}
|
||||
disabled={isChecking}
|
||||
title="Refresh status"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
You can always configure Cursor later in Settings → Providers
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CursorSetupStep;
|
||||
```
|
||||
|
||||
### Task 6.2: Update Setup View Steps
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/components/views/setup-view.tsx`
|
||||
|
||||
Add the Cursor step to the wizard:
|
||||
|
||||
```tsx
|
||||
import { CursorSetupStep } from './setup-view/steps/cursor-setup-step';
|
||||
|
||||
// Add to steps configuration
|
||||
const SETUP_STEPS = [
|
||||
// Existing steps...
|
||||
{
|
||||
id: 'claude',
|
||||
title: 'Claude CLI',
|
||||
optional: false,
|
||||
component: ClaudeSetupStep,
|
||||
},
|
||||
// Add Cursor step
|
||||
{
|
||||
id: 'cursor',
|
||||
title: 'Cursor CLI',
|
||||
optional: true,
|
||||
component: CursorSetupStep,
|
||||
},
|
||||
// Remaining steps...
|
||||
{
|
||||
id: 'project',
|
||||
title: 'Project',
|
||||
optional: false,
|
||||
component: ProjectSetupStep,
|
||||
},
|
||||
];
|
||||
|
||||
// In the render function, handle optional steps:
|
||||
function SetupView() {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [skippedSteps, setSkippedSteps] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleSkip = (stepId: string) => {
|
||||
setSkippedSteps((prev) => new Set([...prev, stepId]));
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const step = SETUP_STEPS[currentStep];
|
||||
const StepComponent = step.component;
|
||||
|
||||
return (
|
||||
<div className="setup-view">
|
||||
{/* Progress indicator */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
{SETUP_STEPS.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
'flex-1 h-2 rounded',
|
||||
i < currentStep
|
||||
? 'bg-green-500'
|
||||
: i === currentStep
|
||||
? 'bg-blue-500'
|
||||
: skippedSteps.has(s.id)
|
||||
? 'bg-gray-300'
|
||||
: 'bg-gray-200'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step title */}
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
{step.title}
|
||||
{step.optional && <span className="text-sm text-muted-foreground ml-2">(Optional)</span>}
|
||||
</h2>
|
||||
|
||||
{/* Step component */}
|
||||
<StepComponent onComplete={handleComplete} onSkip={() => handleSkip(step.id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Task 6.3: Add Step Indicator for Optional Steps
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Add visual indicator for optional vs required steps in the progress bar.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test 1: Component Rendering
|
||||
|
||||
1. Start the app with a fresh setup (or clear setup state)
|
||||
2. Navigate through setup steps
|
||||
3. Verify Cursor step appears after Claude step
|
||||
4. Verify "Optional" badge is displayed
|
||||
|
||||
### Test 2: Skip Functionality
|
||||
|
||||
1. Click "Skip for now" on Cursor step
|
||||
2. Verify step is skipped and progress continues
|
||||
3. Verify skipped state is persisted (if applicable)
|
||||
|
||||
### Test 3: Installation Detection
|
||||
|
||||
1. With cursor-agent NOT installed:
|
||||
- Should show "Not installed" status
|
||||
- Should show install command
|
||||
- Continue button should be disabled
|
||||
|
||||
2. With cursor-agent installed but not authenticated:
|
||||
- Should show version number
|
||||
- Should show "Not authenticated" status
|
||||
- Should show login instructions
|
||||
|
||||
3. With cursor-agent installed and authenticated:
|
||||
- Should show green checkmarks
|
||||
- Continue button should be enabled
|
||||
|
||||
### Test 4: Login Flow
|
||||
|
||||
1. Click "Copy Command & Wait for Login"
|
||||
2. Verify command is copied to clipboard
|
||||
3. Run login command in terminal
|
||||
4. Verify status updates after authentication
|
||||
5. Verify success toast appears
|
||||
|
||||
### Test 5: Refresh Status
|
||||
|
||||
1. Click refresh button
|
||||
2. Verify loading state is shown
|
||||
3. Verify status is re-fetched
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] CursorSetupStep component renders correctly
|
||||
- [ ] Step appears in setup wizard flow
|
||||
- [ ] Skip button works and progresses to next step
|
||||
- [ ] Installation status is correctly detected
|
||||
- [ ] Authentication status is correctly detected
|
||||
- [ ] Login command copy works
|
||||
- [ ] Polling for auth status works
|
||||
- [ ] Refresh button updates status
|
||||
- [ ] Error states handled gracefully
|
||||
- [ ] Progress indicator shows optional step differently
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Description |
|
||||
| --------------------------------------------------------------------- | ------ | -------------------- |
|
||||
| `apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx` | Create | Setup step component |
|
||||
| `apps/ui/src/components/views/setup-view.tsx` | Modify | Add step to wizard |
|
||||
|
||||
---
|
||||
|
||||
## Design Notes
|
||||
|
||||
- The step is marked as optional with a badge
|
||||
- Skip button is always available for optional steps
|
||||
- The login flow is asynchronous with polling
|
||||
- Status can be manually refreshed
|
||||
- Error states show clear recovery instructions
|
||||
556
plan/cursor-cli-integration/phases/phase-7-settings.md
Normal file
556
plan/cursor-cli-integration/phases/phase-7-settings.md
Normal file
@@ -0,0 +1,556 @@
|
||||
# Phase 7: Settings View Provider Tabs
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** Phase 4 (Routes)
|
||||
**Estimated Effort:** Medium (React components)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Create a tabbed interface in Settings for managing different AI providers (Claude and Cursor), with provider-specific configuration options.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 7.1: Create Cursor Settings Tab Component
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/components/views/settings-view/providers/cursor-settings-tab.tsx`
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Terminal, CheckCircle2, XCircle, Loader2, RefreshCw, ExternalLink } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/http-api-client';
|
||||
import {
|
||||
CursorModelId,
|
||||
CursorModelConfig,
|
||||
CursorCliConfig,
|
||||
CURSOR_MODEL_MAP,
|
||||
} from '@automaker/types';
|
||||
|
||||
interface CursorStatus {
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
authenticated: boolean;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export function CursorSettingsTab() {
|
||||
const [status, setStatus] = useState<CursorStatus | null>(null);
|
||||
const [config, setConfig] = useState<CursorCliConfig | null>(null);
|
||||
const [availableModels, setAvailableModels] = useState<CursorModelConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [statusData, configData] = await Promise.all([
|
||||
api.setup.getCursorStatus(),
|
||||
api.setup.getCursorConfig(),
|
||||
]);
|
||||
|
||||
if (statusData.success) {
|
||||
setStatus({
|
||||
installed: statusData.installed ?? false,
|
||||
version: statusData.version,
|
||||
authenticated: statusData.auth?.authenticated ?? false,
|
||||
method: statusData.auth?.method,
|
||||
});
|
||||
}
|
||||
|
||||
if (configData.success) {
|
||||
setConfig(configData.config);
|
||||
setAvailableModels(configData.availableModels || Object.values(CURSOR_MODEL_MAP));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Cursor settings:', error);
|
||||
toast.error('Failed to load Cursor settings');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleDefaultModelChange = async (model: CursorModelId) => {
|
||||
if (!config) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await api.setup.setCursorDefaultModel(model);
|
||||
|
||||
if (result.success) {
|
||||
setConfig({ ...config, defaultModel: model });
|
||||
toast.success('Default model updated');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to update default model');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update default model');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelToggle = async (model: CursorModelId, enabled: boolean) => {
|
||||
if (!config) return;
|
||||
|
||||
const newModels = enabled
|
||||
? [...(config.models || []), model]
|
||||
: (config.models || []).filter((m) => m !== model);
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await api.setup.setCursorModels(newModels);
|
||||
|
||||
if (result.success) {
|
||||
setConfig({ ...config, models: newModels });
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to update models');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update models');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Terminal className="w-5 h-5" />
|
||||
Cursor CLI Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Installation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Installation</span>
|
||||
{status?.installed ? (
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs font-mono">v{status.version}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs">Not installed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Authentication</span>
|
||||
{status?.authenticated ? (
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs capitalize">
|
||||
{status.method === 'api_key' ? 'API Key' : 'Browser Login'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs">Not authenticated</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={loadData}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh Status
|
||||
</Button>
|
||||
{!status?.installed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://cursor.com/docs/cli', '_blank')}
|
||||
>
|
||||
Installation Guide
|
||||
<ExternalLink className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Configuration */}
|
||||
{status?.installed && config && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Model Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure which Cursor models are available and set the default
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Default Model */}
|
||||
<div className="space-y-2">
|
||||
<Label>Default Model</Label>
|
||||
<Select
|
||||
value={config.defaultModel || 'auto'}
|
||||
onValueChange={(v) => handleDefaultModelChange(v as CursorModelId)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(config.models || ['auto']).map((modelId) => {
|
||||
const model = CURSOR_MODEL_MAP[modelId];
|
||||
if (!model) return null;
|
||||
return (
|
||||
<SelectItem key={modelId} value={modelId}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{model.label}</span>
|
||||
{model.hasThinking && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Thinking
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Enabled Models */}
|
||||
<div className="space-y-3">
|
||||
<Label>Available Models</Label>
|
||||
<div className="grid gap-3">
|
||||
{availableModels.map((model) => {
|
||||
const isEnabled = config.models?.includes(model.id) ?? false;
|
||||
const isAuto = model.id === 'auto';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => handleModelToggle(model.id, !!checked)}
|
||||
disabled={isSaving || isAuto}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{model.label}</span>
|
||||
{model.hasThinking && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Thinking
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{model.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={model.tier === 'free' ? 'default' : 'secondary'}>
|
||||
{model.tier}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Not Installed State */}
|
||||
{!status?.installed && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
<Terminal className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Cursor CLI is not installed.</p>
|
||||
<p className="text-sm mt-2">Install it to use Cursor models in AutoMaker.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CursorSettingsTab;
|
||||
```
|
||||
|
||||
### Task 7.2: Create Provider Tabs Container
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx`
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Bot, Terminal } from 'lucide-react';
|
||||
import { CursorSettingsTab } from './cursor-settings-tab';
|
||||
import { ClaudeSettingsTab } from './claude-settings-tab';
|
||||
|
||||
interface ProviderTabsProps {
|
||||
defaultTab?: 'claude' | 'cursor';
|
||||
}
|
||||
|
||||
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="claude" className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4" />
|
||||
Claude
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cursor" className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Cursor
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="claude">
|
||||
<ClaudeSettingsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cursor">
|
||||
<CursorSettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProviderTabs;
|
||||
```
|
||||
|
||||
### Task 7.3: Create Claude Settings Tab (if not exists)
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx`
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bot, CheckCircle2, XCircle, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/http-api-client';
|
||||
|
||||
interface ClaudeStatus {
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
authenticated: boolean;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export function ClaudeSettingsTab() {
|
||||
const [status, setStatus] = useState<ClaudeStatus | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadStatus = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await api.setup.getClaudeStatus();
|
||||
|
||||
if (result.success) {
|
||||
setStatus({
|
||||
installed: result.installed ?? true,
|
||||
version: result.version,
|
||||
authenticated: result.authenticated ?? false,
|
||||
method: result.method,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Claude status:', error);
|
||||
toast.error('Failed to load Claude status');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Bot className="w-5 h-5" />
|
||||
Claude Status
|
||||
</CardTitle>
|
||||
<CardDescription>Claude is the primary AI provider for AutoMaker</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">SDK Status</span>
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Authentication</span>
|
||||
{status?.authenticated ? (
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs capitalize">{status.method}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs">Not authenticated</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={loadStatus}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh Status
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaudeSettingsTab;
|
||||
```
|
||||
|
||||
### Task 7.4: Update Settings View Navigation
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/components/views/settings-view/config/navigation.ts`
|
||||
|
||||
Add or update providers section:
|
||||
|
||||
```typescript
|
||||
export const SETTINGS_NAVIGATION = [
|
||||
// Existing sections...
|
||||
{
|
||||
id: 'providers',
|
||||
label: 'AI Providers',
|
||||
icon: 'bot',
|
||||
description: 'Configure Claude and Cursor AI providers',
|
||||
},
|
||||
// ... other sections
|
||||
];
|
||||
```
|
||||
|
||||
### Task 7.5: Integrate Provider Tabs in Settings
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Update the settings view to render ProviderTabs for the providers section.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test 1: Tab Switching
|
||||
|
||||
1. Navigate to Settings → Providers
|
||||
2. Click on "Claude" tab
|
||||
3. Verify Claude settings are displayed
|
||||
4. Click on "Cursor" tab
|
||||
5. Verify Cursor settings are displayed
|
||||
|
||||
### Test 2: Cursor Status Display
|
||||
|
||||
1. With Cursor CLI installed: verify version is shown
|
||||
2. With Cursor authenticated: verify green checkmark
|
||||
3. Without Cursor installed: verify "Not installed" state
|
||||
|
||||
### Test 3: Model Selection
|
||||
|
||||
1. Enable/disable models via checkboxes
|
||||
2. Verify changes persist after refresh
|
||||
3. Change default model
|
||||
4. Verify default is highlighted in selector
|
||||
|
||||
### Test 4: Responsive Design
|
||||
|
||||
1. Test on different screen sizes
|
||||
2. Verify tabs are usable on mobile
|
||||
3. Verify model list scrolls properly
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] ProviderTabs component renders correctly
|
||||
- [ ] Tab switching works smoothly
|
||||
- [ ] CursorSettingsTab shows correct status
|
||||
- [ ] ClaudeSettingsTab shows correct status
|
||||
- [ ] Model checkboxes toggle state
|
||||
- [ ] Default model selector works
|
||||
- [ ] Settings persist after page refresh
|
||||
- [ ] Loading states displayed
|
||||
- [ ] Error states handled gracefully
|
||||
- [ ] Settings navigation includes providers
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Description |
|
||||
| ------------------------------------------------------------------------------ | ------ | ------------- |
|
||||
| `apps/ui/src/components/views/settings-view/providers/cursor-settings-tab.tsx` | Create | Cursor config |
|
||||
| `apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx` | Create | Claude config |
|
||||
| `apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx` | Create | Tab container |
|
||||
| `apps/ui/src/components/views/settings-view/config/navigation.ts` | Modify | Add section |
|
||||
|
||||
---
|
||||
|
||||
## Design Notes
|
||||
|
||||
- Tabs use consistent icons (Bot for Claude, Terminal for Cursor)
|
||||
- Model cards show tier badges (free/pro)
|
||||
- Thinking models have a "Thinking" badge
|
||||
- The "auto" model cannot be disabled
|
||||
- Settings auto-save on change (no explicit save button)
|
||||
590
plan/cursor-cli-integration/phases/phase-8-profiles.md
Normal file
590
plan/cursor-cli-integration/phases/phase-8-profiles.md
Normal file
@@ -0,0 +1,590 @@
|
||||
# Phase 8: AI Profiles Integration
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** Phase 1 (Types), Phase 7 (Settings)
|
||||
**Estimated Effort:** Medium (UI + types)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Extend the AI Profiles system to support Cursor as a provider, with proper handling of Cursor's embedded thinking mode (via model ID) vs Claude's separate thinking level.
|
||||
|
||||
---
|
||||
|
||||
## Key Concept: Thinking Mode Handling
|
||||
|
||||
### Claude Approach
|
||||
|
||||
- Separate `thinkingLevel` property: `'none' | 'low' | 'medium' | 'high' | 'ultrathink'`
|
||||
- Applied to any Claude model
|
||||
|
||||
### Cursor Approach
|
||||
|
||||
- Thinking is **embedded in the model ID**
|
||||
- Examples: `claude-sonnet-4` (no thinking) vs `claude-sonnet-4-thinking` (with thinking)
|
||||
- No separate thinking level selector needed for Cursor profiles
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 8.1: Update AIProfile Type
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `libs/types/src/settings.ts`
|
||||
|
||||
Update the AIProfile interface:
|
||||
|
||||
```typescript
|
||||
import { CursorModelId } from './cursor-models';
|
||||
|
||||
/**
|
||||
* AI Profile - saved configuration for different use cases
|
||||
*/
|
||||
export interface AIProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isBuiltIn: boolean;
|
||||
icon?: string;
|
||||
|
||||
// Provider selection
|
||||
provider: ModelProvider; // 'claude' | 'cursor'
|
||||
|
||||
// Claude-specific
|
||||
model?: AgentModel; // 'opus' | 'sonnet' | 'haiku'
|
||||
thinkingLevel?: ThinkingLevel; // 'none' | 'low' | 'medium' | 'high' | 'ultrathink'
|
||||
|
||||
// Cursor-specific
|
||||
cursorModel?: CursorModelId; // 'auto' | 'claude-sonnet-4' | 'gpt-4o' | etc.
|
||||
// Note: For Cursor, thinking is in the model ID (e.g., 'claude-sonnet-4-thinking')
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine if a profile uses thinking mode
|
||||
*/
|
||||
export function profileHasThinking(profile: AIProfile): boolean {
|
||||
if (profile.provider === 'claude') {
|
||||
return profile.thinkingLevel !== undefined && profile.thinkingLevel !== 'none';
|
||||
}
|
||||
|
||||
if (profile.provider === 'cursor') {
|
||||
const model = profile.cursorModel || 'auto';
|
||||
return model.includes('thinking') || model === 'o3-mini';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective model string for execution
|
||||
*/
|
||||
export function getProfileModelString(profile: AIProfile): string {
|
||||
if (profile.provider === 'cursor') {
|
||||
return `cursor-${profile.cursorModel || 'auto'}`;
|
||||
}
|
||||
|
||||
// Claude
|
||||
return profile.model || 'sonnet';
|
||||
}
|
||||
```
|
||||
|
||||
### Task 8.2: Update Profile Form Component
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/components/views/profiles-view/components/profile-form.tsx`
|
||||
|
||||
Add Cursor-specific fields:
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import { Bot, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AIProfile,
|
||||
AgentModel,
|
||||
ModelProvider,
|
||||
ThinkingLevel,
|
||||
CursorModelId,
|
||||
CURSOR_MODEL_MAP,
|
||||
cursorModelHasThinking,
|
||||
} from '@automaker/types';
|
||||
|
||||
interface ProfileFormProps {
|
||||
profile: AIProfile;
|
||||
onSave: (profile: AIProfile) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ProfileForm({ profile, onSave, onCancel }: ProfileFormProps) {
|
||||
const [formData, setFormData] = useState<AIProfile>(profile);
|
||||
|
||||
const handleProviderChange = (provider: ModelProvider) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
// Reset provider-specific fields
|
||||
model: provider === 'claude' ? 'sonnet' : undefined,
|
||||
thinkingLevel: provider === 'claude' ? 'none' : undefined,
|
||||
cursorModel: provider === 'cursor' ? 'auto' : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name & Description */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Profile Name</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="My Profile"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
placeholder="Describe when to use this profile..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider</Label>
|
||||
<Select value={formData.provider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4" />
|
||||
Claude (Anthropic)
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="cursor">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Claude-specific settings */}
|
||||
{formData.provider === 'claude' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Select
|
||||
value={formData.model || 'sonnet'}
|
||||
onValueChange={(v) => setFormData((p) => ({ ...p, model: v as AgentModel }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="haiku">Haiku (Fast)</SelectItem>
|
||||
<SelectItem value="sonnet">Sonnet (Balanced)</SelectItem>
|
||||
<SelectItem value="opus">Opus (Powerful)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Thinking Level</Label>
|
||||
<Select
|
||||
value={formData.thinkingLevel || 'none'}
|
||||
onValueChange={(v) =>
|
||||
setFormData((p) => ({ ...p, thinkingLevel: v as ThinkingLevel }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="ultrathink">Ultra</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cursor-specific settings */}
|
||||
{formData.provider === 'cursor' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Cursor Model</Label>
|
||||
<Select
|
||||
value={formData.cursorModel || 'auto'}
|
||||
onValueChange={(v) => setFormData((p) => ({ ...p, cursorModel: v as CursorModelId }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{config.label}</span>
|
||||
{config.hasThinking && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Thinking
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={config.tier === 'free' ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{config.tier}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Info about thinking models */}
|
||||
{formData.cursorModel && cursorModelHasThinking(formData.cursorModel) && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
This model has built-in extended thinking capabilities.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Save Profile</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Task 8.3: Update Profile Card Display
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/components/views/profiles-view/components/profile-card.tsx`
|
||||
|
||||
Show provider-specific info:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Bot, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { AIProfile, CURSOR_MODEL_MAP, profileHasThinking } from '@automaker/types';
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: AIProfile;
|
||||
onEdit: (profile: AIProfile) => void;
|
||||
onDelete: (profile: AIProfile) => void;
|
||||
}
|
||||
|
||||
export function ProfileCard({ profile, onEdit, onDelete }: ProfileCardProps) {
|
||||
const hasThinking = profileHasThinking(profile);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{profile.provider === 'cursor' ? (
|
||||
<Terminal className="w-4 h-4" />
|
||||
) : (
|
||||
<Bot className="w-4 h-4" />
|
||||
)}
|
||||
{profile.name}
|
||||
</CardTitle>
|
||||
{profile.isBuiltIn && <Badge variant="secondary">Built-in</Badge>}
|
||||
</div>
|
||||
<CardDescription>{profile.description}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Provider badge */}
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{profile.provider}
|
||||
</Badge>
|
||||
|
||||
{/* Model badge */}
|
||||
<Badge variant="outline">
|
||||
{profile.provider === 'cursor'
|
||||
? CURSOR_MODEL_MAP[profile.cursorModel || 'auto']?.label || profile.cursorModel
|
||||
: profile.model}
|
||||
</Badge>
|
||||
|
||||
{/* Thinking badge */}
|
||||
{hasThinking && <Badge variant="default">Thinking</Badge>}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{!profile.isBuiltIn && (
|
||||
<CardFooter className="pt-0">
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(profile)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => onDelete(profile)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Task 8.4: Add Default Cursor Profiles
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/components/views/profiles-view/constants.ts`
|
||||
|
||||
Add built-in Cursor profiles:
|
||||
|
||||
```typescript
|
||||
import { AIProfile } from '@automaker/types';
|
||||
|
||||
export const DEFAULT_PROFILES: AIProfile[] = [
|
||||
// Existing Claude profiles...
|
||||
{
|
||||
id: 'claude-default',
|
||||
name: 'Claude Default',
|
||||
description: 'Balanced Claude Sonnet model',
|
||||
provider: 'claude',
|
||||
model: 'sonnet',
|
||||
thinkingLevel: 'none',
|
||||
isBuiltIn: true,
|
||||
icon: 'bot',
|
||||
},
|
||||
// ... other Claude profiles
|
||||
|
||||
// Cursor profiles
|
||||
{
|
||||
id: 'cursor-auto',
|
||||
name: 'Cursor Auto',
|
||||
description: 'Let Cursor choose the best model automatically',
|
||||
provider: 'cursor',
|
||||
cursorModel: 'auto',
|
||||
isBuiltIn: true,
|
||||
icon: 'terminal',
|
||||
},
|
||||
{
|
||||
id: 'cursor-fast',
|
||||
name: 'Cursor Fast',
|
||||
description: 'Quick responses with GPT-4o Mini',
|
||||
provider: 'cursor',
|
||||
cursorModel: 'gpt-4o-mini',
|
||||
isBuiltIn: true,
|
||||
icon: 'zap',
|
||||
},
|
||||
{
|
||||
id: 'cursor-thinking',
|
||||
name: 'Cursor Thinking',
|
||||
description: 'Claude Sonnet 4 with extended thinking for complex tasks',
|
||||
provider: 'cursor',
|
||||
cursorModel: 'claude-sonnet-4-thinking',
|
||||
isBuiltIn: true,
|
||||
icon: 'brain',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### Task 8.5: Update Profile Validation
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Add validation for profile data:
|
||||
|
||||
```typescript
|
||||
import { AIProfile, CURSOR_MODEL_MAP } from '@automaker/types';
|
||||
|
||||
export function validateProfile(profile: AIProfile): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!profile.name?.trim()) {
|
||||
errors.push('Profile name is required');
|
||||
}
|
||||
|
||||
if (!['claude', 'cursor'].includes(profile.provider)) {
|
||||
errors.push('Invalid provider');
|
||||
}
|
||||
|
||||
if (profile.provider === 'claude') {
|
||||
if (!profile.model) {
|
||||
errors.push('Claude model is required');
|
||||
}
|
||||
}
|
||||
|
||||
if (profile.provider === 'cursor') {
|
||||
if (profile.cursorModel && !(profile.cursorModel in CURSOR_MODEL_MAP)) {
|
||||
errors.push('Invalid Cursor model');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test 1: Profile Creation with Cursor
|
||||
|
||||
1. Navigate to Profiles view
|
||||
2. Click "Create Profile"
|
||||
3. Select "Cursor CLI" as provider
|
||||
4. Select a Cursor model
|
||||
5. Save the profile
|
||||
6. Verify it appears in the list with correct badges
|
||||
|
||||
### Test 2: Thinking Mode Detection
|
||||
|
||||
```typescript
|
||||
import { profileHasThinking } from '@automaker/types';
|
||||
|
||||
// Claude with thinking
|
||||
const claudeThinking: AIProfile = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
provider: 'claude',
|
||||
model: 'sonnet',
|
||||
thinkingLevel: 'high',
|
||||
isBuiltIn: false,
|
||||
};
|
||||
console.assert(profileHasThinking(claudeThinking) === true);
|
||||
|
||||
// Claude without thinking
|
||||
const claudeNoThinking: AIProfile = {
|
||||
id: '2',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
provider: 'claude',
|
||||
model: 'sonnet',
|
||||
thinkingLevel: 'none',
|
||||
isBuiltIn: false,
|
||||
};
|
||||
console.assert(profileHasThinking(claudeNoThinking) === false);
|
||||
|
||||
// Cursor with thinking model
|
||||
const cursorThinking: AIProfile = {
|
||||
id: '3',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
provider: 'cursor',
|
||||
cursorModel: 'claude-sonnet-4-thinking',
|
||||
isBuiltIn: false,
|
||||
};
|
||||
console.assert(profileHasThinking(cursorThinking) === true);
|
||||
|
||||
// Cursor without thinking
|
||||
const cursorNoThinking: AIProfile = {
|
||||
id: '4',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
provider: 'cursor',
|
||||
cursorModel: 'gpt-4o',
|
||||
isBuiltIn: false,
|
||||
};
|
||||
console.assert(profileHasThinking(cursorNoThinking) === false);
|
||||
|
||||
console.log('All thinking detection tests passed!');
|
||||
```
|
||||
|
||||
### Test 3: Provider Switching
|
||||
|
||||
1. Create a new profile
|
||||
2. Select Claude as provider
|
||||
3. Configure Claude options
|
||||
4. Switch to Cursor
|
||||
5. Verify Claude options are hidden
|
||||
6. Verify Cursor options are shown
|
||||
7. Previous selections should be cleared
|
||||
|
||||
### Test 4: Built-in Profiles
|
||||
|
||||
1. Navigate to Profiles view
|
||||
2. Verify Cursor built-in profiles appear
|
||||
3. Verify they cannot be edited/deleted
|
||||
4. Verify they show correct badges
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] AIProfile type extended with Cursor fields
|
||||
- [ ] `profileHasThinking()` works for both providers
|
||||
- [ ] Profile form shows provider selector
|
||||
- [ ] Claude options shown only for Claude provider
|
||||
- [ ] Cursor options shown only for Cursor provider
|
||||
- [ ] Cursor models show thinking badge where applicable
|
||||
- [ ] Built-in Cursor profiles added
|
||||
- [ ] Profile cards display provider info
|
||||
- [ ] Profile validation works
|
||||
- [ ] Profiles persist correctly
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Description |
|
||||
| ------------------------------------------------------------------------ | ------ | ------------------------------ |
|
||||
| `libs/types/src/settings.ts` | Modify | Add Cursor fields to AIProfile |
|
||||
| `apps/ui/src/components/views/profiles-view/components/profile-form.tsx` | Modify | Add Cursor UI |
|
||||
| `apps/ui/src/components/views/profiles-view/components/profile-card.tsx` | Modify | Show provider info |
|
||||
| `apps/ui/src/components/views/profiles-view/constants.ts` | Modify | Add Cursor profiles |
|
||||
|
||||
---
|
||||
|
||||
## Design Notes
|
||||
|
||||
- Provider selection is the first choice in profile form
|
||||
- Switching providers resets model-specific options
|
||||
- Cursor thinking is determined by model ID, not separate field
|
||||
- Built-in profiles provide good starting points
|
||||
- Profile cards show provider icon and model badges
|
||||
451
plan/cursor-cli-integration/phases/phase-9-execution.md
Normal file
451
plan/cursor-cli-integration/phases/phase-9-execution.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# Phase 9: Task Execution Integration
|
||||
|
||||
**Status:** `pending`
|
||||
**Dependencies:** Phase 3 (Factory), Phase 8 (Profiles)
|
||||
**Estimated Effort:** Medium (service updates)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Update the task execution flow (agent-service, auto-mode-service) to use the ProviderFactory for model routing, ensuring Cursor models are executed via CursorProvider.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 9.1: Update Agent Service
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/src/services/agent-service.ts`
|
||||
|
||||
Update to use ProviderFactory:
|
||||
|
||||
```typescript
|
||||
import { ProviderFactory } from '../providers/provider-factory';
|
||||
import { getProfileModelString, profileHasThinking } from '@automaker/types';
|
||||
|
||||
export class AgentService {
|
||||
// ...existing code...
|
||||
|
||||
/**
|
||||
* Execute a chat message using the appropriate provider
|
||||
*/
|
||||
async executeChat(sessionId: string, message: string, options: ChatOptions = {}): Promise<void> {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
// Determine effective model
|
||||
const profile = options.profile;
|
||||
let effectiveModel: string;
|
||||
|
||||
if (profile) {
|
||||
effectiveModel = getProfileModelString(profile);
|
||||
} else {
|
||||
effectiveModel = options.model || session.model || 'sonnet';
|
||||
}
|
||||
|
||||
// Get provider for this model
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel, {
|
||||
cwd: session.workDir,
|
||||
});
|
||||
|
||||
const providerName = provider.getName();
|
||||
this.logger.debug(`[AgentService] Using ${providerName} provider for model ${effectiveModel}`);
|
||||
|
||||
// Build execution options
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: message,
|
||||
model: effectiveModel,
|
||||
cwd: session.workDir,
|
||||
systemPrompt: this.buildSystemPrompt(session, options),
|
||||
maxTurns: options.maxTurns || 100,
|
||||
allowedTools: options.allowedTools || TOOL_PRESETS.chat,
|
||||
abortController: session.abortController,
|
||||
conversationHistory: session.conversationHistory,
|
||||
sdkSessionId: session.sdkSessionId,
|
||||
};
|
||||
|
||||
// Add thinking level for Claude
|
||||
if (providerName === 'claude' && profile?.thinkingLevel) {
|
||||
executeOptions.thinkingLevel = profile.thinkingLevel;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stream from provider
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Capture session ID
|
||||
if (msg.session_id && !session.sdkSessionId) {
|
||||
session.sdkSessionId = msg.session_id;
|
||||
}
|
||||
|
||||
// Process message and emit events
|
||||
this.processProviderMessage(sessionId, msg);
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleProviderError(sessionId, error, providerName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a provider message and emit appropriate events
|
||||
*/
|
||||
private processProviderMessage(sessionId: string, msg: ProviderMessage): void {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'stream',
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'tool_use',
|
||||
tool: {
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
id: block.tool_use_id,
|
||||
},
|
||||
});
|
||||
} else if (block.type === 'tool_result') {
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'tool_result',
|
||||
toolId: block.tool_use_id,
|
||||
content: block.content,
|
||||
});
|
||||
} else if (block.type === 'thinking' && block.thinking) {
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'thinking',
|
||||
content: block.thinking,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result') {
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'complete',
|
||||
content: msg.result || '',
|
||||
});
|
||||
} else if (msg.type === 'error') {
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'error',
|
||||
error: msg.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle provider-specific errors
|
||||
*/
|
||||
private handleProviderError(sessionId: string, error: any, providerName: string): void {
|
||||
let errorMessage = error.message || 'Unknown error';
|
||||
let suggestion = error.suggestion;
|
||||
|
||||
// Add provider context
|
||||
if (providerName === 'cursor' && error.code) {
|
||||
switch (error.code) {
|
||||
case 'CURSOR_NOT_AUTHENTICATED':
|
||||
suggestion = 'Run "cursor-agent login" in your terminal';
|
||||
break;
|
||||
case 'CURSOR_RATE_LIMITED':
|
||||
suggestion = 'Wait a few minutes or upgrade to Cursor Pro';
|
||||
break;
|
||||
case 'CURSOR_NOT_INSTALLED':
|
||||
suggestion = 'Install Cursor CLI: curl https://cursor.com/install -fsS | bash';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'error',
|
||||
error: errorMessage,
|
||||
suggestion,
|
||||
provider: providerName,
|
||||
});
|
||||
|
||||
this.logger.error(`[AgentService] ${providerName} error:`, error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task 9.2: Update Auto Mode Service
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/server/src/services/auto-mode-service.ts`
|
||||
|
||||
Update the `runAgent` method:
|
||||
|
||||
```typescript
|
||||
import { ProviderFactory } from '../providers/provider-factory';
|
||||
import { getProfileModelString } from '@automaker/types';
|
||||
|
||||
export class AutoModeService {
|
||||
// ...existing code...
|
||||
|
||||
/**
|
||||
* Run the agent for a task
|
||||
*/
|
||||
private async runAgent(task: Task, options: AutoModeOptions): Promise<AgentResult> {
|
||||
const { workDir, profile, maxTurns } = options;
|
||||
|
||||
// Determine model from profile or task
|
||||
let model: string;
|
||||
if (profile) {
|
||||
model = getProfileModelString(profile);
|
||||
} else {
|
||||
model = task.model || 'sonnet';
|
||||
}
|
||||
|
||||
// Get provider
|
||||
const provider = ProviderFactory.getProviderForModel(model, { cwd: workDir });
|
||||
const providerName = provider.getName();
|
||||
|
||||
this.logger.info(`[AutoMode] Running with ${providerName} provider, model: ${model}`);
|
||||
|
||||
// Build execution options
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: this.buildPrompt(task),
|
||||
model,
|
||||
cwd: workDir,
|
||||
systemPrompt: options.systemPrompt,
|
||||
maxTurns: maxTurns || MAX_TURNS.extended,
|
||||
allowedTools: options.allowedTools || TOOL_PRESETS.fullAccess,
|
||||
abortController: options.abortController,
|
||||
};
|
||||
|
||||
let responseText = '';
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
try {
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Emit progress events
|
||||
this.emitProgress(task.id, msg, providerName);
|
||||
|
||||
// Collect response
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text || '';
|
||||
} else if (block.type === 'tool_use') {
|
||||
toolCalls.push({
|
||||
id: block.tool_use_id,
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: responseText,
|
||||
toolCalls,
|
||||
provider: providerName,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
suggestion: error.suggestion,
|
||||
provider: providerName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit progress event for UI updates
|
||||
*/
|
||||
private emitProgress(taskId: string, msg: ProviderMessage, provider: string): void {
|
||||
// Emit event for log viewer and progress tracking
|
||||
this.events.emit('auto-mode:event', {
|
||||
taskId,
|
||||
provider,
|
||||
message: msg,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task 9.3: Update Model Selector in Board View
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
**File:** `apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx`
|
||||
|
||||
Add Cursor models to selection:
|
||||
|
||||
```tsx
|
||||
import { CURSOR_MODEL_MAP, CursorModelId } from '@automaker/types';
|
||||
|
||||
interface ModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
provider: 'claude' | 'cursor';
|
||||
hasThinking?: boolean;
|
||||
}
|
||||
|
||||
const MODEL_OPTIONS: ModelOption[] = [
|
||||
// Claude models
|
||||
{ id: 'haiku', label: 'Claude Haiku', provider: 'claude' },
|
||||
{ id: 'sonnet', label: 'Claude Sonnet', provider: 'claude' },
|
||||
{ id: 'opus', label: 'Claude Opus', provider: 'claude' },
|
||||
|
||||
// Cursor models
|
||||
...Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => ({
|
||||
id: `cursor-${id}`,
|
||||
label: `Cursor: ${config.label}`,
|
||||
provider: 'cursor' as const,
|
||||
hasThinking: config.hasThinking,
|
||||
})),
|
||||
];
|
||||
|
||||
// In the dialog form:
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Claude</SelectLabel>
|
||||
{MODEL_OPTIONS.filter((m) => m.provider === 'claude').map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Cursor</SelectLabel>
|
||||
{MODEL_OPTIONS.filter((m) => m.provider === 'cursor').map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{model.label}
|
||||
{model.hasThinking && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Thinking
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>;
|
||||
```
|
||||
|
||||
### Task 9.4: Update Feature Execution with Provider Tracking
|
||||
|
||||
**Status:** `pending`
|
||||
|
||||
Track which provider executed each feature for UI display:
|
||||
|
||||
```typescript
|
||||
interface FeatureExecution {
|
||||
id: string;
|
||||
featureId: string;
|
||||
model: string;
|
||||
provider: 'claude' | 'cursor';
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Store provider info in execution results
|
||||
const execution: FeatureExecution = {
|
||||
id: generateId(),
|
||||
featureId: feature.id,
|
||||
model: effectiveModel,
|
||||
provider: ProviderFactory.getProviderNameForModel(effectiveModel),
|
||||
startTime: Date.now(),
|
||||
status: 'running',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test 1: Claude Model Execution
|
||||
|
||||
1. Create a task with a Claude model (e.g., `sonnet`)
|
||||
2. Execute the task
|
||||
3. Verify ClaudeProvider is used
|
||||
4. Verify output streams correctly
|
||||
5. Verify tool calls work
|
||||
|
||||
### Test 2: Cursor Model Execution
|
||||
|
||||
1. Create a task with a Cursor model (e.g., `cursor-auto`)
|
||||
2. Execute the task
|
||||
3. Verify CursorProvider is used
|
||||
4. Verify output streams correctly
|
||||
5. Verify tool calls work
|
||||
|
||||
### Test 3: Profile-Based Execution
|
||||
|
||||
1. Create a Cursor profile
|
||||
2. Use that profile for a task
|
||||
3. Verify correct provider is selected
|
||||
4. Verify profile settings are applied
|
||||
|
||||
### Test 4: Error Handling
|
||||
|
||||
1. Use Cursor model without CLI installed
|
||||
2. Verify appropriate error message
|
||||
3. Verify suggestion is shown
|
||||
4. Verify execution can be retried
|
||||
|
||||
### Test 5: Mixed Provider Session
|
||||
|
||||
1. Run a task with Claude
|
||||
2. Run another task with Cursor
|
||||
3. Verify both execute correctly
|
||||
4. Verify logs show correct provider info
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking this phase complete:
|
||||
|
||||
- [ ] AgentService uses ProviderFactory
|
||||
- [ ] AutoModeService uses ProviderFactory
|
||||
- [ ] Claude models route to ClaudeProvider
|
||||
- [ ] Cursor models route to CursorProvider
|
||||
- [ ] Profile model string conversion works
|
||||
- [ ] Provider errors include suggestions
|
||||
- [ ] Progress events include provider info
|
||||
- [ ] Model selector includes Cursor models
|
||||
- [ ] Execution results track provider
|
||||
- [ ] Log viewer shows provider context
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Description |
|
||||
| ------------------------------------------------------------------------ | ------ | ------------------- |
|
||||
| `apps/server/src/services/agent-service.ts` | Modify | Use ProviderFactory |
|
||||
| `apps/server/src/services/auto-mode-service.ts` | Modify | Use ProviderFactory |
|
||||
| `apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx` | Modify | Add Cursor models |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Provider selection happens at execution time, not configuration time
|
||||
- Session state may span provider switches
|
||||
- Error handling is provider-aware
|
||||
- Progress events include provider for UI grouping
|
||||
Reference in New Issue
Block a user