Merge pull request #403 from j5ik2o/feature/cursor-agent-cli-provider-spec

feat: cursor-agent対応
This commit is contained in:
Junichi Kato 2026-02-27 01:12:17 +09:00 committed by GitHub
parent 0186cee1d1
commit 204843f498
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1329 additions and 58 deletions

View File

@ -19,7 +19,7 @@
| `--repo <owner/repo>` | リポジトリを指定PR 作成用) |
| `--create-worktree <yes\|no>` | worktree 確認プロンプトをスキップ |
| `-q, --quiet` | 最小出力モード: AI 出力を抑制CI 向け) |
| `--provider <name>` | エージェント provider を上書きclaude\|codex\|opencode\|mock |
| `--provider <name>` | エージェント provider を上書きclaude\|codex\|opencode\|cursor\|mock |
| `--model <name>` | エージェントモデルを上書き |
| `--config <path>` | グローバル設定ファイルのパス(デフォルト: `~/.takt/config.yaml` |

View File

@ -19,7 +19,7 @@ This document provides a complete reference for all TAKT CLI commands and option
| `--repo <owner/repo>` | Specify repository (for PR creation) |
| `--create-worktree <yes\|no>` | Skip worktree confirmation prompt |
| `-q, --quiet` | Minimal output mode: suppress AI output (for CI) |
| `--provider <name>` | Override agent provider (claude\|codex\|opencode\|mock) |
| `--provider <name>` | Override agent provider (claude\|codex\|opencode\|cursor\|mock) |
| `--model <name>` | Override agent model |
| `--config <path>` | Path to global config file (default: `~/.takt/config.yaml`) |

View File

@ -13,7 +13,7 @@
language: en # UI 言語: 'en' または 'ja'
default_piece: default # 新規プロジェクトのデフォルト piece
log_level: info # ログレベル: debug, info, warn, error
provider: claude # デフォルト provider: claude, codex, または opencode
provider: claude # デフォルト provider: claude, codex, opencode, または cursor
model: sonnet # デフォルトモデル省略可、provider にそのまま渡される)
branch_name_strategy: romaji # ブランチ名生成方式: 'romaji'(高速)または 'ai'(低速)
prevent_sleep: false # 実行中に macOS のアイドルスリープを防止caffeinate
@ -56,10 +56,11 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
# default_permission_mode: edit
# API キー設定(省略可)
# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY で上書き可能
# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY / TAKT_CURSOR_API_KEY で上書き可能
# anthropic_api_key: sk-ant-... # ClaudeAnthropic
# openai_api_key: sk-... # CodexOpenAI
# opencode_api_key: ... # OpenCode 用
# cursor_api_key: ... # Cursor Agent 用(省略時は login セッションにフォールバック)
# Codex CLI パス上書き(省略可)
# Codex SDK が使用する Codex CLI バイナリを上書き(実行可能ファイルの絶対パスが必要)
@ -88,7 +89,7 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
| `language` | `"en"` \| `"ja"` | `"en"` | UI 言語 |
| `default_piece` | string | `"default"` | 新規プロジェクトのデフォルト piece |
| `log_level` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | ログレベル |
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` | `"claude"` | デフォルト AI provider |
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"cursor"` | `"claude"` | デフォルト AI provider |
| `model` | string | - | デフォルトモデル名provider にそのまま渡される) |
| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | ブランチ名生成方式 |
| `prevent_sleep` | boolean | `false` | macOS アイドルスリープ防止caffeinate |
@ -108,6 +109,7 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
| `anthropic_api_key` | string | - | Claude 用 Anthropic API キー |
| `openai_api_key` | string | - | Codex 用 OpenAI API キー |
| `opencode_api_key` | string | - | OpenCode API キー |
| `cursor_api_key` | string | - | Cursor API キー(省略時は login セッションへフォールバック) |
| `codex_cli_path` | string | - | Codex CLI バイナリパス上書き(絶対パス) |
| `enable_builtin_pieces` | boolean | `true` | ビルトイン piece の有効化 |
| `disabled_builtins` | string[] | `[]` | 無効化する特定のビルトイン piece |
@ -149,7 +151,7 @@ concurrency: 2 # このプロジェクトでの takt run 並列
| フィールド | 型 | デフォルト | 説明 |
|-----------|------|---------|------|
| `piece` | string | `"default"` | このプロジェクトの現在の piece 名 |
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"mock"` | - | provider 上書き |
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"cursor"` \| `"mock"` | - | provider 上書き |
| `model` | string | - | モデル名の上書きprovider にそのまま渡される) |
| `auto_pr` | boolean | - | worktree 実行後に PR を自動作成 |
| `verbose` | boolean | - | 詳細出力モード |
@ -162,7 +164,7 @@ concurrency: 2 # このプロジェクトでの takt run 並列
## API キー設定
TAKT は3つの provider をサポートしており、それぞれに API キーが必要です。API キーは環境変数または `~/.takt/config.yaml` で設定できます。
TAKT は4つの provider をサポートしています。Claude/Codex/OpenCode は API キーを使い、Cursor は API キーまたは `cursor-agent login` セッションで認証できます。
### 環境変数(推奨)
@ -175,6 +177,9 @@ export TAKT_OPENAI_API_KEY=sk-...
# OpenCode 用
export TAKT_OPENCODE_API_KEY=...
# Cursor Agent 用cursor-agent login 済みなら省略可)
export TAKT_CURSOR_API_KEY=...
```
### 設定ファイル
@ -184,6 +189,7 @@ export TAKT_OPENCODE_API_KEY=...
anthropic_api_key: sk-ant-... # Claude 用
openai_api_key: sk-... # Codex 用
opencode_api_key: ... # OpenCode 用
cursor_api_key: ... # Cursor Agent 用(省略可)
```
### 優先順位
@ -195,12 +201,14 @@ opencode_api_key: ... # OpenCode 用
| Claude (Anthropic) | `TAKT_ANTHROPIC_API_KEY` | `anthropic_api_key` |
| Codex (OpenAI) | `TAKT_OPENAI_API_KEY` | `openai_api_key` |
| OpenCode | `TAKT_OPENCODE_API_KEY` | `opencode_api_key` |
| Cursor Agent | `TAKT_CURSOR_API_KEY` | `cursor_api_key` |
### セキュリティ
- `config.yaml` に API キーを記載する場合、このファイルを Git にコミットしないよう注意してください。
- 環境変数の使用を検討してください。
- 必要に応じて `~/.takt/config.yaml` をグローバル `.gitignore` に追加してください。
- Cursor provider は `cursor-agent login` が済んでいれば API キーなしでも動作できます。
- API キーを設定すれば、対応する CLI ツールClaude Code、Codex、OpenCodeのインストールは不要です。TAKT が対応する API を直接呼び出します。
### Codex CLI パス上書き
@ -224,7 +232,7 @@ codex_cli_path: /usr/local/bin/codex
1. **Piece movement の `model`** - piece YAML の movement 定義で指定
2. **グローバル設定の `model`** - `~/.takt/config.yaml` のデフォルトモデル
3. **Provider デフォルト** - provider のビルトインデフォルトにフォールバックClaude: `sonnet`、Codex: `codex`、OpenCode: provider デフォルト)
3. **Provider デフォルト** - provider のビルトインデフォルトにフォールバックClaude: `sonnet`、Codex: `codex`、OpenCode: provider デフォルト、Cursor: CLI デフォルト
### Provider 固有のモデルに関する注意
@ -234,6 +242,8 @@ codex_cli_path: /usr/local/bin/codex
**OpenCode** は `provider/model` 形式のモデル(例: `opencode/big-pickle`が必要です。OpenCode provider でモデルを省略すると設定エラーになります。
**Cursor Agent** は `model``cursor-agent --model <model>` にそのまま渡します。省略時は Cursor CLI のデフォルトが使用されます。
### 設定例
```yaml
@ -261,11 +271,11 @@ Provider プロファイルを使用すると、各 provider にデフォルト
TAKT は provider 非依存の3つのパーミッションモードを使用します。
| モード | 説明 | Claude | Codex | OpenCode |
|--------|------|--------|-------|----------|
| `readonly` | 読み取り専用、ファイル変更不可 | `default` | `read-only` | `read-only` |
| `edit` | 確認付きでファイル編集を許可 | `acceptEdits` | `workspace-write` | `workspace-write` |
| `full` | すべてのパーミッションチェックをバイパス | `bypassPermissions` | `danger-full-access` | `danger-full-access` |
| モード | 説明 | Claude | Codex | OpenCode | Cursor Agent |
|--------|------|--------|-------|----------|--------------|
| `readonly` | 読み取り専用、ファイル変更不可 | `default` | `read-only` | `read-only` | デフォルトフラグ(`--force` なし) |
| `edit` | 確認付きでファイル編集を許可 | `acceptEdits` | `workspace-write` | `workspace-write` | デフォルトフラグ(`--force` なし) |
| `full` | すべてのパーミッションチェックをバイパス | `bypassPermissions` | `danger-full-access` | `danger-full-access` | `--force` |
### 設定方法

View File

@ -13,7 +13,7 @@ Configure TAKT defaults in `~/.takt/config.yaml`. This file is created automatic
language: en # UI language: 'en' or 'ja'
default_piece: default # Default piece for new projects
log_level: info # Log level: debug, info, warn, error
provider: claude # Default provider: claude, codex, or opencode
provider: claude # Default provider: claude, codex, opencode, or cursor
model: sonnet # Default model (optional, passed to provider as-is)
branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow)
prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate)
@ -56,10 +56,11 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10,
# default_permission_mode: edit
# API Key configuration (optional)
# Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY
# Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY / TAKT_CURSOR_API_KEY
# anthropic_api_key: sk-ant-... # For Claude (Anthropic)
# openai_api_key: sk-... # For Codex (OpenAI)
# opencode_api_key: ... # For OpenCode
# cursor_api_key: ... # For Cursor Agent (optional; login session fallback is also supported)
# Codex CLI path override (optional)
# Override the Codex CLI binary used by the Codex SDK (must be an absolute path to an executable file)
@ -88,7 +89,7 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10,
| `language` | `"en"` \| `"ja"` | `"en"` | UI language |
| `default_piece` | string | `"default"` | Default piece for new projects |
| `log_level` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | Log level |
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` | `"claude"` | Default AI provider |
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"cursor"` | `"claude"` | Default AI provider |
| `model` | string | - | Default model name (passed to provider as-is) |
| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | Branch name generation strategy |
| `prevent_sleep` | boolean | `false` | Prevent macOS idle sleep (caffeinate) |
@ -108,6 +109,7 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10,
| `anthropic_api_key` | string | - | Anthropic API key for Claude |
| `openai_api_key` | string | - | OpenAI API key for Codex |
| `opencode_api_key` | string | - | OpenCode API key |
| `cursor_api_key` | string | - | Cursor API key (optional; login session fallback supported) |
| `codex_cli_path` | string | - | Codex CLI binary path override (absolute) |
| `enable_builtin_pieces` | boolean | `true` | Enable builtin pieces |
| `disabled_builtins` | string[] | `[]` | Specific builtin pieces to disable |
@ -149,7 +151,7 @@ concurrency: 2 # Parallel task count for takt run in this project
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `piece` | string | `"default"` | Current piece name for this project |
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"mock"` | - | Override provider |
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"cursor"` \| `"mock"` | - | Override provider |
| `model` | string | - | Override model name (passed to provider as-is) |
| `auto_pr` | boolean | - | Auto-create PR after worktree execution |
| `verbose` | boolean | - | Verbose output mode |
@ -162,7 +164,7 @@ Project config values override global config when both are set.
## API Key Configuration
TAKT supports three providers, each with its own API key. API keys can be configured via environment variables or `~/.takt/config.yaml`.
TAKT supports four providers. Claude/Codex/OpenCode use API keys, and Cursor can use either API key or existing `cursor-agent login` session.
### Environment Variables (Recommended)
@ -175,6 +177,9 @@ export TAKT_OPENAI_API_KEY=sk-...
# For OpenCode
export TAKT_OPENCODE_API_KEY=...
# For Cursor Agent (optional if cursor-agent login session exists)
export TAKT_CURSOR_API_KEY=...
```
### Config File
@ -184,6 +189,7 @@ export TAKT_OPENCODE_API_KEY=...
anthropic_api_key: sk-ant-... # For Claude
openai_api_key: sk-... # For Codex
opencode_api_key: ... # For OpenCode
cursor_api_key: ... # For Cursor Agent (optional)
```
### Priority
@ -195,12 +201,14 @@ Environment variables take precedence over `config.yaml` settings.
| Claude (Anthropic) | `TAKT_ANTHROPIC_API_KEY` | `anthropic_api_key` |
| Codex (OpenAI) | `TAKT_OPENAI_API_KEY` | `openai_api_key` |
| OpenCode | `TAKT_OPENCODE_API_KEY` | `opencode_api_key` |
| Cursor Agent | `TAKT_CURSOR_API_KEY` | `cursor_api_key` |
### Security
- If you write API keys in `config.yaml`, be careful not to commit this file to Git.
- Consider using environment variables instead.
- Add `~/.takt/config.yaml` to your global `.gitignore` if needed.
- Cursor provider can run without API key when `cursor-agent login` is already configured.
- If you set an API key, installing the corresponding CLI tool (Claude Code, Codex, OpenCode) is not necessary. TAKT directly calls the respective API.
### Codex CLI Path Override
@ -224,7 +232,7 @@ The model used for each movement is resolved with the following priority order (
1. **Piece movement `model`** - Specified in the movement definition in piece YAML
2. **Global config `model`** - Default model in `~/.takt/config.yaml`
3. **Provider default** - Falls back to the provider's built-in default (Claude: `sonnet`, Codex: `codex`, OpenCode: provider default)
3. **Provider default** - Falls back to the provider's built-in default (Claude: `sonnet`, Codex: `codex`, OpenCode: provider default, Cursor: CLI default)
### Provider-specific Model Notes
@ -234,6 +242,8 @@ The model used for each movement is resolved with the following priority order (
**OpenCode** requires a model in `provider/model` format (e.g., `opencode/big-pickle`). Omitting the model for the OpenCode provider will result in a configuration error.
**Cursor Agent** forwards `model` directly to `cursor-agent --model <model>`. If omitted, Cursor CLI default is used.
### Example
```yaml
@ -261,11 +271,11 @@ Provider profiles allow you to set default permission modes and per-movement per
TAKT uses three provider-independent permission modes:
| Mode | Description | Claude | Codex | OpenCode |
|------|-------------|--------|-------|----------|
| `readonly` | Read-only access, no file modifications | `default` | `read-only` | `read-only` |
| `edit` | Allow file edits with confirmation | `acceptEdits` | `workspace-write` | `workspace-write` |
| `full` | Bypass all permission checks | `bypassPermissions` | `danger-full-access` | `danger-full-access` |
| Mode | Description | Claude | Codex | OpenCode | Cursor Agent |
|------|-------------|--------|-------|----------|--------------|
| `readonly` | Read-only access, no file modifications | `default` | `read-only` | `read-only` | default flags (no `--force`) |
| `edit` | Allow file edits with confirmation | `acceptEdits` | `workspace-write` | `workspace-write` | default flags (no `--force`) |
| `full` | Bypass all permission checks | `bypassPermissions` | `danger-full-access` | `danger-full-access` | `--force` |
### Configuration

View File

@ -5,7 +5,8 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
## 前提条件
- `gh` CLI が利用可能で、対象GitHubアカウントでログイン済みであること。
- `takt-testing` リポジトリが対象アカウントに存在することE2Eがクローンして使用
- 必要に応じて `TAKT_E2E_PROVIDER` を設定すること(例: `claude` / `codex` / `opencode`)。
- 必要に応じて `TAKT_E2E_PROVIDER` を設定すること(例: `claude` / `codex` / `cursor` / `opencode`)。
- `TAKT_E2E_PROVIDER=cursor` の場合は `cursor-agent` CLI が利用可能で、認証済みであること。
- `TAKT_E2E_PROVIDER=opencode` の場合は `TAKT_E2E_MODEL` が必須(例: `opencode/big-pickle`)。
- 実行時間が長いテストがあるため、タイムアウトに注意すること。
- E2Eは `e2e/helpers/test-repo.ts``gh` でリポジトリをクローンし、テンポラリディレクトリで実行する。
@ -28,10 +29,12 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
- `npm run test:e2e:provider`: `claude``codex` の両方で実行。
- `npm run test:e2e:provider:claude`: `TAKT_E2E_PROVIDER=claude` で実行。
- `npm run test:e2e:provider:codex`: `TAKT_E2E_PROVIDER=codex` で実行。
- `npm run test:e2e:provider:cursor`: `TAKT_AUTO_PR=false TAKT_E2E_PROVIDER=cursor` で実行Cursor専用スイート: `add-and-run` / `worktree`)。
- `npm run test:e2e:provider:opencode`: `TAKT_E2E_PROVIDER=opencode` で実行(`TAKT_E2E_MODEL` 必須)。
- `npm run test:e2e:all`: `mock` + `provider` を通しで実行。
- `npm run test:e2e:claude`: `test:e2e:provider:claude` の別名。
- `npm run test:e2e:codex`: `test:e2e:provider:codex` の別名。
- `npm run test:e2e:cursor`: `test:e2e:provider:cursor` の別名。
- `npm run test:e2e:opencode`: `test:e2e:provider:opencode` の別名。
- `npx vitest run e2e/specs/add-and-run.e2e.ts`: 単体実行の例。

View File

@ -8,3 +8,6 @@ notification_sound_events:
piece_abort: false
run_complete: true
run_abort: false
provider_profiles:
cursor:
default_permission_mode: full

View File

@ -21,9 +21,11 @@
"test:e2e:provider:claude": "TAKT_E2E_PROVIDER=claude vitest run --config vitest.config.e2e.provider.ts --reporter=verbose",
"test:e2e:provider:codex": "TAKT_E2E_PROVIDER=codex vitest run --config vitest.config.e2e.provider.ts --reporter=verbose",
"test:e2e:provider:opencode": "TAKT_E2E_PROVIDER=opencode vitest run --config vitest.config.e2e.provider.ts --reporter=verbose",
"test:e2e:provider:cursor": "TAKT_AUTO_PR=false TAKT_E2E_PROVIDER=cursor vitest run --config vitest.config.e2e.cursor.ts --reporter=verbose",
"test:e2e:claude": "npm run test:e2e:provider:claude",
"test:e2e:codex": "npm run test:e2e:provider:codex",
"test:e2e:opencode": "npm run test:e2e:provider:opencode",
"test:e2e:cursor": "npm run test:e2e:provider:cursor",
"lint": "eslint src/",
"check:release": "npm run build && npm run lint && npm run test && npm run test:e2e; code=$?; if [ \"$code\" -eq 0 ]; then msg='check:release passed'; else msg=\"check:release failed (exit=$code)\"; fi; if command -v osascript >/dev/null 2>&1; then osascript -e \"display notification \\\"$msg\\\" with title \\\"takt\\\" subtitle \\\"Release Check\\\"\" >/dev/null 2>&1 || true; fi; echo \"[takt] $msg\"; exit $code",
"prepublishOnly": "npm run lint && npm run build && npm run test"

View File

@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { program } from '../app/cli/program.js';
describe('CLI --provider option', () => {
it('should include cursor in provider help text', () => {
const providerOption = program.options.find((option) => option.long === '--provider');
expect(providerOption).toBeDefined();
expect(providerOption?.description).toContain('cursor');
});
});

View File

@ -80,4 +80,13 @@ describe('config env overrides', () => {
retention_days: 14,
});
});
it('should apply cursor API key override for global config', () => {
process.env.TAKT_CURSOR_API_KEY = 'cursor-key-from-env';
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.cursor_api_key).toBe('cursor-key-from-env');
});
});

View File

@ -0,0 +1,176 @@
/**
* Tests for Cursor Agent CLI client
*/
import { EventEmitter } from 'node:events';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock('node:child_process', () => ({
spawn: mockSpawn,
}));
import { callCursor } from '../infra/cursor/client.js';
type SpawnScenario = {
stdout?: string;
stderr?: string;
code?: number | null;
signal?: NodeJS.Signals | null;
error?: Partial<NodeJS.ErrnoException> & { message: string };
};
type MockChildProcess = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
function createMockChildProcess(): MockChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = vi.fn(() => true);
return child;
}
function mockSpawnWithScenario(scenario: SpawnScenario): void {
mockSpawn.mockImplementation((_cmd: string, _args: string[], _options: object) => {
const child = createMockChildProcess();
queueMicrotask(() => {
if (scenario.stdout) {
child.stdout.emit('data', Buffer.from(scenario.stdout, 'utf-8'));
}
if (scenario.stderr) {
child.stderr.emit('data', Buffer.from(scenario.stderr, 'utf-8'));
}
if (scenario.error) {
const error = Object.assign(new Error(scenario.error.message), scenario.error);
child.emit('error', error);
return;
}
child.emit('close', scenario.code ?? 0, scenario.signal ?? null);
});
return child;
});
}
describe('callCursor', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.CURSOR_API_KEY;
});
it('should invoke cursor-agent with required args and map model/session/permission', async () => {
mockSpawnWithScenario({
stdout: JSON.stringify({ content: 'done', sessionId: 'sess-new' }),
code: 0,
});
const result = await callCursor('coder', 'implement feature', {
cwd: '/repo',
model: 'cursor/gpt-5',
sessionId: 'sess-prev',
permissionMode: 'full',
cursorApiKey: 'cursor-key',
});
expect(result.status).toBe('done');
expect(result.content).toBe('done');
expect(result.sessionId).toBe('sess-new');
expect(mockSpawn).toHaveBeenCalledTimes(1);
const [command, args, options] = mockSpawn.mock.calls[0] as [string, string[], { env?: NodeJS.ProcessEnv; stdio?: unknown }];
expect(command).toBe('cursor-agent');
expect(args).toEqual([
'-p',
'--output-format',
'json',
'--workspace',
'/repo',
'--model',
'cursor/gpt-5',
'--resume',
'sess-prev',
'--force',
'implement feature',
]);
expect(options.env?.CURSOR_API_KEY).toBe('cursor-key');
expect(options.stdio).toEqual(['ignore', 'pipe', 'pipe']);
});
it('should not inject CURSOR_API_KEY when cursorApiKey is undefined', async () => {
mockSpawnWithScenario({
stdout: JSON.stringify({ content: 'done' }),
code: 0,
});
const result = await callCursor('coder', 'implement feature', {
cwd: '/repo',
permissionMode: 'edit',
});
expect(result.status).toBe('done');
const [, args, options] = mockSpawn.mock.calls[0] as [string, string[], { env?: NodeJS.ProcessEnv }];
expect(args).not.toContain('--force');
expect(options.env).toBe(process.env);
});
it('should return structured error when cursor-agent binary is not found', async () => {
mockSpawnWithScenario({
error: { code: 'ENOENT', message: 'spawn cursor-agent ENOENT' },
});
const result = await callCursor('coder', 'implement feature', { cwd: '/repo' });
expect(result.status).toBe('error');
expect(result.content).toContain('cursor-agent binary not found');
});
it('should classify authentication errors', async () => {
mockSpawnWithScenario({
code: 1,
stderr: 'Authentication required. Please login.',
});
const result = await callCursor('coder', 'implement feature', { cwd: '/repo' });
expect(result.status).toBe('error');
expect(result.content).toContain('cursor-agent login');
expect(result.content).toContain('TAKT_CURSOR_API_KEY');
});
it('should classify non-zero exits', async () => {
mockSpawnWithScenario({
code: 2,
stderr: 'unexpected failure',
});
const result = await callCursor('coder', 'implement feature', { cwd: '/repo' });
expect(result.status).toBe('error');
expect(result.content).toContain('code 2');
expect(result.content).toContain('unexpected failure');
});
it('should return parse error when stdout is not valid JSON', async () => {
mockSpawnWithScenario({
stdout: 'not-json',
code: 0,
});
const result = await callCursor('coder', 'implement feature', { cwd: '/repo' });
expect(result.status).toBe('error');
expect(result.content).toContain('Failed to parse cursor-agent JSON output');
});
});

View File

@ -0,0 +1,143 @@
/**
* Tests for Cursor provider implementation
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
mockCallCursor,
mockCallCursorCustom,
} = vi.hoisted(() => ({
mockCallCursor: vi.fn(),
mockCallCursorCustom: vi.fn(),
}));
const { mockResolveCursorApiKey } = vi.hoisted(() => ({
mockResolveCursorApiKey: vi.fn(() => undefined),
}));
vi.mock('../infra/cursor/index.js', () => ({
callCursor: mockCallCursor,
callCursorCustom: mockCallCursorCustom,
}));
vi.mock('../infra/config/index.js', () => ({
resolveCursorApiKey: mockResolveCursorApiKey,
}));
import { CursorProvider } from '../infra/providers/cursor.js';
import { ProviderRegistry } from '../infra/providers/index.js';
function doneResponse(persona: string) {
return {
persona,
status: 'done' as const,
content: 'ok',
timestamp: new Date(),
};
}
describe('CursorProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveCursorApiKey.mockReturnValue(undefined);
});
it('should throw when claudeAgent is specified', () => {
const provider = new CursorProvider();
expect(() => provider.setup({
name: 'test',
claudeAgent: 'some-agent',
})).toThrow('Claude Code agent calls are not supported by the Cursor provider');
});
it('should throw when claudeSkill is specified', () => {
const provider = new CursorProvider();
expect(() => provider.setup({
name: 'test',
claudeSkill: 'some-skill',
})).toThrow('Claude Code skill calls are not supported by the Cursor provider');
});
it('should pass model/session/permission and resolved cursor key to callCursor', async () => {
mockResolveCursorApiKey.mockReturnValue('resolved-key');
mockCallCursor.mockResolvedValue(doneResponse('coder'));
const provider = new CursorProvider();
const agent = provider.setup({ name: 'coder' });
await agent.call('implement', {
cwd: '/tmp/work',
model: 'cursor/gpt-5',
sessionId: 'sess-1',
permissionMode: 'full',
});
expect(mockCallCursor).toHaveBeenCalledWith(
'coder',
'implement',
expect.objectContaining({
cwd: '/tmp/work',
model: 'cursor/gpt-5',
sessionId: 'sess-1',
permissionMode: 'full',
cursorApiKey: 'resolved-key',
}),
);
});
it('should prefer explicit cursorApiKey over resolver', async () => {
mockResolveCursorApiKey.mockReturnValue('resolved-key');
mockCallCursor.mockResolvedValue(doneResponse('coder'));
const provider = new CursorProvider();
const agent = provider.setup({ name: 'coder' });
await agent.call('implement', {
cwd: '/tmp/work',
cursorApiKey: 'explicit-key',
});
expect(mockCallCursor).toHaveBeenCalledWith(
'coder',
'implement',
expect.objectContaining({
cursorApiKey: 'explicit-key',
}),
);
});
it('should delegate to callCursorCustom when systemPrompt is specified', async () => {
mockCallCursorCustom.mockResolvedValue(doneResponse('reviewer'));
const provider = new CursorProvider();
const agent = provider.setup({
name: 'reviewer',
systemPrompt: 'You are a strict reviewer.',
});
await agent.call('review this', {
cwd: '/tmp/work',
});
expect(mockCallCursorCustom).toHaveBeenCalledWith(
'reviewer',
'review this',
'You are a strict reviewer.',
expect.objectContaining({ cwd: '/tmp/work' }),
);
});
});
describe('ProviderRegistry with Cursor', () => {
it('should return Cursor provider from registry', () => {
ProviderRegistry.resetInstance();
const registry = ProviderRegistry.getInstance();
const provider = registry.get('cursor');
expect(provider).toBeDefined();
expect(provider).toBeInstanceOf(CursorProvider);
});
});

View File

@ -8,7 +8,8 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, rmSync } from 'node:fs';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
// --- Mock setup (must be before imports that use these modules) ---
@ -142,6 +143,44 @@ describe('PieceEngine Integration: Blocked Handling', () => {
expect(state.userInputs).toContain('User provided clarification');
});
it('should refresh previous response snapshot when Phase 1 returns blocked', async () => {
const config = buildDefaultPieceConfig();
const onUserInput = vi.fn().mockResolvedValueOnce(null);
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onUserInput });
mockRunAgentSequence([
makeResponse({ persona: 'plan', status: 'done', content: 'Plan done' }),
makeResponse({ persona: 'implement', status: 'blocked', content: 'Need clarification' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' }, // plan -> implement
]);
const state = await engine.run();
expect(state.status).toBe('aborted');
expect(state.lastOutput?.status).toBe('blocked');
expect(state.previousResponseSourcePath).toMatch(
/^\.takt\/runs\/test-report-dir\/context\/previous_responses\/implement\.1\.\d{8}T\d{6}Z\.md$/,
);
const snapshotPath = join(tmpDir, state.previousResponseSourcePath!);
const latestPath = join(
tmpDir,
'.takt',
'runs',
'test-report-dir',
'context',
'previous_responses',
'latest.md',
);
expect(readFileSync(snapshotPath, 'utf-8')).toBe('Need clarification');
expect(readFileSync(latestPath, 'utf-8')).toBe('Need clarification');
expect(onUserInput).toHaveBeenCalledOnce();
});
it('should abort immediately when movement returns error status', async () => {
const config = buildDefaultPieceConfig();
const onUserInput = vi.fn().mockResolvedValueOnce('should not be called');

View File

@ -37,6 +37,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
import { PieceEngine } from '../core/piece/index.js';
import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
import { runReportPhase } from '../core/piece/phase-runner.js';
import {
makeResponse,
makeMovement,
@ -113,7 +114,42 @@ describe('PieceEngine Integration: Error Handling', () => {
});
// =====================================================
// 3. Loop detection
// 3. Interrupted status routing
// =====================================================
describe('Interrupted status', () => {
it('should continue with normal rule routing and skip report phase when movement returns interrupted', async () => {
const config = buildDefaultPieceConfig({
initialMovement: 'plan',
movements: [
makeMovement('plan', {
outputContracts: [{ name: '01-plan.md', format: '# Plan' }],
rules: [makeRule('continue', 'COMPLETE')],
}),
],
});
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', status: 'interrupted', content: 'Partial response' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
]);
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const state = await engine.run();
expect(state.status).toBe('completed');
expect(abortFn).not.toHaveBeenCalled();
expect(runReportPhase).not.toHaveBeenCalled();
});
});
// =====================================================
// 4. Loop detection
// =====================================================
describe('Loop detection', () => {
it('should abort when loop detected with action: abort', async () => {
@ -153,7 +189,7 @@ describe('PieceEngine Integration: Error Handling', () => {
});
// =====================================================
// 4. Iteration limit
// 5. Iteration limit
// =====================================================
describe('Iteration limit', () => {
it('should abort when max iterations reached without onIterationLimit callback', async () => {

View File

@ -1,7 +1,7 @@
/**
* Tests for API key authentication feature
*
* Tests the resolution logic for Anthropic and OpenAI API keys:
* Tests the resolution logic for Anthropic/OpenAI/OpenCode/Cursor API keys:
* - Environment variable priority over config.yaml
* - Config.yaml fallback when env var is not set
* - Undefined when neither is set
@ -46,7 +46,16 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => {
});
// Import after mocking
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, resolveCodexCliPath, resolveOpencodeApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
const {
loadGlobalConfig,
saveGlobalConfig,
resolveAnthropicApiKey,
resolveOpenaiApiKey,
resolveCodexCliPath,
resolveOpencodeApiKey,
resolveCursorApiKey,
invalidateGlobalConfigCache,
} = await import('../infra/config/global/globalConfig.js');
describe('GlobalConfigSchema API key fields', () => {
it('should accept config without API keys', () => {
@ -82,6 +91,14 @@ describe('GlobalConfigSchema API key fields', () => {
expect(result.anthropic_api_key).toBe('sk-ant-key');
expect(result.openai_api_key).toBe('sk-openai-key');
});
it('should accept config with cursor_api_key', () => {
const result = GlobalConfigSchema.parse({
language: 'en',
cursor_api_key: 'cursor-key',
});
expect(result.cursor_api_key).toBe('cursor-key');
});
});
describe('GlobalConfig load/save with API keys', () => {
@ -101,12 +118,14 @@ describe('GlobalConfig load/save with API keys', () => {
'provider: claude',
'anthropic_api_key: sk-ant-from-yaml',
'openai_api_key: sk-openai-from-yaml',
'cursor_api_key: cursor-from-yaml',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const config = loadGlobalConfig();
expect(config.anthropicApiKey).toBe('sk-ant-from-yaml');
expect(config.openaiApiKey).toBe('sk-openai-from-yaml');
expect(config.cursorApiKey).toBe('cursor-from-yaml');
});
it('should load config without API keys', () => {
@ -134,11 +153,13 @@ describe('GlobalConfig load/save with API keys', () => {
const config = loadGlobalConfig();
config.anthropicApiKey = 'sk-ant-saved';
config.openaiApiKey = 'sk-openai-saved';
config.cursorApiKey = 'cursor-saved';
saveGlobalConfig(config);
const reloaded = loadGlobalConfig();
expect(reloaded.anthropicApiKey).toBe('sk-ant-saved');
expect(reloaded.openaiApiKey).toBe('sk-openai-saved');
expect(reloaded.cursorApiKey).toBe('cursor-saved');
});
it('should not persist API keys when not set', () => {
@ -155,6 +176,7 @@ describe('GlobalConfig load/save with API keys', () => {
const content = readFileSync(configPath, 'utf-8');
expect(content).not.toContain('anthropic_api_key');
expect(content).not.toContain('openai_api_key');
expect(content).not.toContain('cursor_api_key');
});
});
@ -450,3 +472,62 @@ describe('resolveOpencodeApiKey', () => {
expect(key).toBeUndefined();
});
});
describe('resolveCursorApiKey', () => {
const originalEnv = process.env['TAKT_CURSOR_API_KEY'];
beforeEach(() => {
invalidateGlobalConfigCache();
mkdirSync(taktDir, { recursive: true });
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env['TAKT_CURSOR_API_KEY'] = originalEnv;
} else {
delete process.env['TAKT_CURSOR_API_KEY'];
}
rmSync(testDir, { recursive: true, force: true });
});
it('should return env var when set', () => {
process.env['TAKT_CURSOR_API_KEY'] = 'cursor-from-env';
const yaml = [
'language: en',
'log_level: info',
'provider: cursor',
'cursor_api_key: cursor-from-yaml',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const key = resolveCursorApiKey();
expect(key).toBe('cursor-from-env');
});
it('should fall back to config when env var is not set', () => {
delete process.env['TAKT_CURSOR_API_KEY'];
const yaml = [
'language: en',
'log_level: info',
'provider: cursor',
'cursor_api_key: cursor-from-yaml',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const key = resolveCursorApiKey();
expect(key).toBe('cursor-from-yaml');
});
it('should return undefined when neither env var nor config is set', () => {
delete process.env['TAKT_CURSOR_API_KEY'];
const yaml = [
'language: en',
'log_level: info',
'provider: cursor',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const key = resolveCursorApiKey();
expect(key).toBeUndefined();
});
});

View File

@ -30,11 +30,23 @@ describe('Schemas accept opencode provider', () => {
expect(result.opencode_api_key).toBe('test-key-123');
});
it('should accept cursor_api_key in GlobalConfigSchema', () => {
const result = GlobalConfigSchema.parse({
cursor_api_key: 'cursor-key-123',
});
expect(result.cursor_api_key).toBe('cursor-key-123');
});
it('should accept opencode in ProjectConfigSchema', () => {
const result = ProjectConfigSchema.parse({ provider: 'opencode' });
expect(result.provider).toBe('opencode');
});
it('should accept cursor in ProjectConfigSchema', () => {
const result = ProjectConfigSchema.parse({ provider: 'cursor' });
expect(result.provider).toBe('cursor');
});
it('should accept concurrency in ProjectConfigSchema', () => {
const result = ProjectConfigSchema.parse({ concurrency: 3 });
expect(result.concurrency).toBe(3);
@ -71,6 +83,14 @@ describe('Schemas accept opencode provider', () => {
expect(result.provider).toBe('opencode');
});
it('should accept cursor in PieceMovementRawSchema', () => {
const result = PieceMovementRawSchema.parse({
name: 'test-movement',
provider: 'cursor',
});
expect(result.provider).toBe('cursor');
});
it('should accept opencode in ParallelSubMovementRawSchema', () => {
const result = ParallelSubMovementRawSchema.parse({
name: 'sub-1',
@ -79,8 +99,16 @@ describe('Schemas accept opencode provider', () => {
expect(result.provider).toBe('opencode');
});
it('should still accept existing providers (claude, codex, mock)', () => {
for (const provider of ['claude', 'codex', 'mock']) {
it('should accept cursor in ParallelSubMovementRawSchema', () => {
const result = ParallelSubMovementRawSchema.parse({
name: 'sub-1',
provider: 'cursor',
});
expect(result.provider).toBe('cursor');
});
it('should still accept existing providers (claude, codex, opencode, cursor, mock)', () => {
for (const provider of ['claude', 'codex', 'opencode', 'cursor', 'mock']) {
const result = GlobalConfigSchema.parse({ provider });
expect(result.provider).toBe(provider);
}

View File

@ -119,6 +119,16 @@ describe('resolveMovementProviderModel', () => {
expect(result.provider).toBe('claude');
expect(result.model).toBe('o3-mini');
});
it('should resolve cursor provider from personaProviders', () => {
const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
provider: 'claude',
personaProviders: { coder: { provider: 'cursor' } },
});
expect(result.provider).toBe('cursor');
});
});
describe('resolveAgentProviderModel', () => {

View File

@ -8,7 +8,8 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, rmSync } from 'node:fs';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
// --- Mock setup (must be before imports that use these modules) ---
@ -221,4 +222,66 @@ describe('PieceEngine Integration: Report Phase Blocked Handling', () => {
expect.objectContaining({ status: 'blocked', content: blockedContent }),
);
});
it('should skip report phase when phase 1 returns error', async () => {
const config = buildConfigWithReport();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan done' }),
makeResponse({
persona: 'implement',
status: 'error',
content: 'Cursor Agent CLI exited with code 1: Workspace Trust Required',
error: 'Cursor Agent CLI exited with code 1: Workspace Trust Required',
}),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
]);
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const state = await engine.run();
expect(state.status).toBe('aborted');
expect(runReportPhase).not.toHaveBeenCalled();
expect(abortFn).toHaveBeenCalledOnce();
const reason = abortFn.mock.calls[0]?.[1] as string;
expect(reason).toContain('Movement "implement" failed');
expect(reason).toContain('Workspace Trust Required');
});
it('should skip report phase when phase 1 returns blocked and persist blocked snapshot', async () => {
const config = buildConfigWithReport();
const onUserInput = vi.fn().mockResolvedValueOnce(null);
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onUserInput });
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: 'Plan done' }),
makeResponse({
persona: 'implement',
status: 'blocked',
content: 'Need clarification before report',
}),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' }, // plan -> implement
]);
const state = await engine.run();
expect(state.status).toBe('aborted');
expect(onUserInput).toHaveBeenCalledOnce();
expect(runReportPhase).not.toHaveBeenCalled();
expect(state.previousResponseSourcePath).toMatch(
/^\.takt\/runs\/test-report-dir\/context\/previous_responses\/implement\.1\.\d{8}T\d{6}Z\.md$/,
);
const snapshotPath = join(tmpDir, state.previousResponseSourcePath!);
expect(readFileSync(snapshotPath, 'utf-8')).toBe('Need clarification before report');
});
});

View File

@ -13,9 +13,9 @@ export interface RunAgentOptions {
abortSignal?: AbortSignal;
sessionId?: string;
model?: string;
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
stepModel?: string;
stepProvider?: 'claude' | 'codex' | 'opencode' | 'mock';
stepProvider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
personaPath?: string;
allowedTools?: string[];
mcpServers?: Record<string, McpServerConfig>;

View File

@ -46,7 +46,7 @@ program
.option('--auto-pr', 'Create PR after successful execution')
.option('--draft', 'Create PR as draft (requires --auto-pr or auto_pr config)')
.option('--repo <owner/repo>', 'Repository (defaults to current)')
.option('--provider <name>', 'Override agent provider (claude|codex|opencode|mock)')
.option('--provider <name>', 'Override agent provider (claude|codex|opencode|cursor|mock)')
.option('--model <name>', 'Override agent model')
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')

View File

@ -6,7 +6,7 @@ import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.
import type { ProviderPermissionProfiles } from './provider-profiles.js';
export interface PersonaProviderEntry {
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
model?: string;
}
@ -70,7 +70,7 @@ export interface NotificationSoundEventsConfig {
export interface PersistedGlobalConfig {
language: Language;
logLevel: 'debug' | 'info' | 'warn' | 'error';
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
model?: string;
observability?: ObservabilityConfig;
analytics?: AnalyticsConfig;
@ -92,6 +92,8 @@ export interface PersistedGlobalConfig {
codexCliPath?: string;
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencodeApiKey?: string;
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
cursorApiKey?: string;
/** Pipeline execution settings */
pipeline?: PipelineConfig;
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
@ -133,7 +135,7 @@ export interface PersistedGlobalConfig {
/** Project-level configuration */
export interface ProjectConfig {
piece?: string;
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
model?: string;
providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles */

View File

@ -135,7 +135,7 @@ export interface PieceMovement {
/** Resolved absolute path to persona prompt file (set by loader) */
personaPath?: string;
/** Provider override for this movement */
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
/** Model override for this movement */
model?: string;
/** Required minimum permission mode for tool execution in this movement */

View File

@ -5,7 +5,7 @@
import type { PermissionMode } from './status.js';
/** Supported providers for profile-based permission resolution. */
export type ProviderProfileName = 'claude' | 'codex' | 'opencode' | 'mock';
export type ProviderProfileName = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
/** Permission profile for a single provider. */
export interface ProviderPermissionProfile {

View File

@ -79,7 +79,7 @@ export const MovementProviderOptionsSchema = z.object({
}).optional();
/** Provider key schema for profile maps */
export const ProviderProfileNameSchema = z.enum(['claude', 'codex', 'opencode', 'mock']);
export const ProviderProfileNameSchema = z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']);
/** Provider permission profile schema */
export const ProviderPermissionProfileSchema = z.object({
@ -92,6 +92,7 @@ export const ProviderPermissionProfilesSchema = z.object({
claude: ProviderPermissionProfileSchema.optional(),
codex: ProviderPermissionProfileSchema.optional(),
opencode: ProviderPermissionProfileSchema.optional(),
cursor: ProviderPermissionProfileSchema.optional(),
mock: ProviderPermissionProfileSchema.optional(),
}).optional();
@ -246,7 +247,7 @@ export const ParallelSubMovementRawSchema = z.object({
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
allowed_tools: z.array(z.string()).optional(),
mcp_servers: McpServersSchema,
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(),
model: z.string().optional(),
/** Removed legacy field (no backward compatibility) */
permission_mode: z.never().optional(),
@ -279,7 +280,7 @@ export const PieceMovementRawSchema = z.object({
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
allowed_tools: z.array(z.string()).optional(),
mcp_servers: McpServersSchema,
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(),
model: z.string().optional(),
/** Removed legacy field (no backward compatibility) */
permission_mode: z.never().optional(),
@ -368,7 +369,7 @@ export const PieceConfigRawSchema = z.object({
});
export const PersonaProviderEntrySchema = z.object({
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(),
model: z.string().optional(),
});
@ -424,7 +425,7 @@ export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfi
export const GlobalConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'),
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional().default('claude'),
model: z.string().optional(),
observability: ObservabilityConfigSchema.optional(),
analytics: AnalyticsConfigSchema.optional(),
@ -446,6 +447,8 @@ export const GlobalConfigSchema = z.object({
codex_cli_path: z.string().optional(),
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencode_api_key: z.string().optional(),
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
cursor_api_key: z.string().optional(),
/** Pipeline execution settings */
pipeline: PipelineConfigSchema.optional(),
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
@ -456,7 +459,7 @@ export const GlobalConfigSchema = z.object({
piece_categories_file: z.string().optional(),
/** Per-persona provider and model overrides. */
persona_providers: z.record(z.string(), z.union([
z.enum(['claude', 'codex', 'opencode', 'mock']),
z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']),
PersonaProviderEntrySchema,
])).optional(),
/** Global provider-specific options (lowest priority) */
@ -496,7 +499,7 @@ export const GlobalConfigSchema = z.object({
/** Project config schema */
export const ProjectConfigSchema = z.object({
piece: z.string().optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(),
model: z.string().optional(),
provider_options: MovementProviderOptionsSchema,
provider_profiles: ProviderPermissionProfilesSchema,

View File

@ -28,7 +28,7 @@ export interface DecomposeTaskOptions {
personaPath?: string;
language?: Language;
model?: string;
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
}
export interface MorePartsResponse {

View File

@ -186,6 +186,11 @@ export class MovementExecutor {
updatePersonaSession: (persona: string, sessionId: string | undefined) => void,
): Promise<AgentResponse> {
let nextResponse = response;
if (nextResponse.status === 'error' || nextResponse.status === 'blocked') {
return nextResponse;
}
const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(
state,
nextResponse.content,
@ -195,7 +200,8 @@ export class MovementExecutor {
);
// Phase 2: report output (resume same session, Write only)
if (step.outputContracts && step.outputContracts.length > 0) {
// Report generation is only valid after a completed Phase 1 response.
if (nextResponse.status === 'done' && step.outputContracts && step.outputContracts.length > 0) {
const reportResult = await runReportPhase(step, movementIteration, phaseCtx);
if (reportResult?.blocked) {
nextResponse = { ...nextResponse, status: 'blocked', content: reportResult.response.content };
@ -275,6 +281,23 @@ export class MovementExecutor {
let response = await executeAgent(step.persona, instruction, agentOptions);
updatePersonaSession(sessionKey, response.sessionId);
this.deps.onPhaseComplete?.(step, 1, 'execute', response.content, response.status, response.error);
// Provider failures should abort immediately.
if (response.status === 'error') {
state.movementOutputs.set(step.name, response);
state.lastOutput = response;
return { response, instruction };
}
// Blocked responses should be handled by PieceEngine's blocked flow.
// Persist snapshot so re-execution receives the latest blocked context.
if (response.status === 'blocked') {
state.movementOutputs.set(step.name, response);
state.lastOutput = response;
this.persistPreviousResponseSnapshot(state, step.name, movementIteration, response.content);
return { response, instruction };
}
response = await this.applyPostExecutionPhases(
step,
state,

View File

@ -13,6 +13,7 @@ export const DEFAULT_PROVIDER_PERMISSION_PROFILES: ProviderPermissionProfiles =
claude: { defaultPermissionMode: 'edit' },
codex: { defaultPermissionMode: 'edit' },
opencode: { defaultPermissionMode: 'edit' },
cursor: { defaultPermissionMode: 'edit' },
mock: { defaultPermissionMode: 'edit' },
};

View File

@ -11,7 +11,7 @@ import type { PersonaProviderEntry } from '../models/persisted-global-config.js'
import type { ProviderPermissionProfiles } from '../models/provider-profiles.js';
import type { MovementProviderOptions } from '../models/piece-types.js';
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
export type ProviderOptionsSource = 'env' | 'project' | 'global' | 'default';
export interface StreamInitEventData {

View File

@ -95,6 +95,7 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'openai_api_key', type: 'string' },
{ path: 'codex_cli_path', type: 'string' },
{ path: 'opencode_api_key', type: 'string' },
{ path: 'cursor_api_key', type: 'string' },
{ path: 'pipeline', type: 'json' },
{ path: 'pipeline.default_branch_prefix', type: 'string' },
{ path: 'pipeline.commit_message_template', type: 'string' },

View File

@ -192,6 +192,7 @@ export class GlobalConfigManager {
openaiApiKey: parsed.openai_api_key,
codexCliPath: parsed.codex_cli_path,
opencodeApiKey: parsed.opencode_api_key,
cursorApiKey: parsed.cursor_api_key,
pipeline: parsed.pipeline ? {
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
commitMessageTemplate: parsed.pipeline.commit_message_template,
@ -280,6 +281,9 @@ export class GlobalConfigManager {
if (config.opencodeApiKey) {
raw.opencode_api_key = config.opencodeApiKey;
}
if (config.cursorApiKey) {
raw.cursor_api_key = config.cursorApiKey;
}
if (config.pipeline) {
const pipelineRaw: Record<string, unknown> = {};
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
@ -410,7 +414,7 @@ export function setLanguage(language: Language): void {
saveGlobalConfig(config);
}
export function setProvider(provider: 'claude' | 'codex' | 'opencode'): void {
export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor'): void {
const config = loadGlobalConfig();
config.provider = provider;
saveGlobalConfig(config);
@ -485,3 +489,19 @@ export function resolveOpencodeApiKey(): string | undefined {
return undefined;
}
}
/**
* Resolve the Cursor API key.
* Priority: TAKT_CURSOR_API_KEY env var > config.yaml > undefined (cursor-agent login fallback)
*/
export function resolveCursorApiKey(): string | undefined {
const envKey = process.env[envVarNameFromPath('cursor_api_key')];
if (envKey) return envKey;
try {
const config = loadGlobalConfig();
return config.cursorApiKey;
} catch {
return undefined;
}
}

View File

@ -16,6 +16,7 @@ export {
resolveOpenaiApiKey,
resolveCodexCliPath,
resolveOpencodeApiKey,
resolveCursorApiKey,
} from './globalConfig.js';
export {

View File

@ -56,11 +56,12 @@ export async function promptLanguageSelection(): Promise<Language> {
* Prompt user to select provider for resources.
* Exits process if cancelled (initial setup is required).
*/
export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode'> {
const options: { label: string; value: 'claude' | 'codex' | 'opencode' }[] = [
export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode' | 'cursor'> {
const options: { label: string; value: 'claude' | 'codex' | 'opencode' | 'cursor' }[] = [
{ label: 'Claude Code', value: 'claude' },
{ label: 'Codex', value: 'codex' },
{ label: 'OpenCode', value: 'opencode' },
{ label: 'Cursor Agent', value: 'cursor' },
];
const result = await selectOptionWithDefault(

View File

@ -11,7 +11,7 @@ export interface ProjectLocalConfig {
/** Current piece name */
piece?: string;
/** Provider selection for agent runtime */
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
/** Model selection for agent runtime */
model?: string;
/** Auto-create PR after worktree execution */

497
src/infra/cursor/client.ts Normal file
View File

@ -0,0 +1,497 @@
/**
* Cursor Agent CLI integration for agent interactions
*/
import { spawn } from 'node:child_process';
import type { AgentResponse } from '../../core/models/index.js';
import { getErrorMessage } from '../../shared/utils/index.js';
import type { CursorCallOptions } from './types.js';
export type { CursorCallOptions } from './types.js';
const CURSOR_COMMAND = 'cursor-agent';
const CURSOR_ABORTED_MESSAGE = 'Cursor execution aborted';
const CURSOR_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
const CURSOR_FORCE_KILL_DELAY_MS_DEFAULT = 1_000;
const CURSOR_ERROR_DETAIL_MAX_LENGTH = 400;
function resolveForceKillDelayMs(): number {
const raw = process.env.TAKT_CURSOR_FORCE_KILL_DELAY_MS;
if (!raw) {
return CURSOR_FORCE_KILL_DELAY_MS_DEFAULT;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return CURSOR_FORCE_KILL_DELAY_MS_DEFAULT;
}
return parsed;
}
type CursorExecResult = {
stdout: string;
stderr: string;
};
type CursorExecError = Error & {
code?: string | number;
stdout?: string;
stderr?: string;
signal?: NodeJS.Signals | null;
};
function buildPrompt(prompt: string, systemPrompt?: string): string {
if (!systemPrompt) {
return prompt;
}
return `${systemPrompt}\n\n${prompt}`;
}
function buildArgs(prompt: string, options: CursorCallOptions): string[] {
const args = ['-p', '--output-format', 'json', '--workspace', options.cwd];
if (options.model) {
args.push('--model', options.model);
}
if (options.sessionId) {
args.push('--resume', options.sessionId);
}
if (options.permissionMode === 'full') {
args.push('--force');
}
args.push(buildPrompt(prompt, options.systemPrompt));
return args;
}
function buildEnv(cursorApiKey?: string): NodeJS.ProcessEnv {
if (!cursorApiKey) {
return process.env;
}
return {
...process.env,
CURSOR_API_KEY: cursorApiKey,
};
}
function createExecError(
message: string,
params: {
code?: string | number;
stdout?: string;
stderr?: string;
signal?: NodeJS.Signals | null;
name?: string;
} = {},
): CursorExecError {
const error = new Error(message) as CursorExecError;
if (params.name) {
error.name = params.name;
}
error.code = params.code;
error.stdout = params.stdout;
error.stderr = params.stderr;
error.signal = params.signal;
return error;
}
function execCursor(args: string[], options: CursorCallOptions): Promise<CursorExecResult> {
return new Promise<CursorExecResult>((resolve, reject) => {
const child = spawn(CURSOR_COMMAND, args, {
cwd: options.cwd,
env: buildEnv(options.cursorApiKey),
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let stdoutBytes = 0;
let stderrBytes = 0;
let settled = false;
let abortTimer: ReturnType<typeof setTimeout> | undefined;
const abortHandler = (): void => {
if (settled) return;
child.kill('SIGTERM');
const forceKillDelayMs = resolveForceKillDelayMs();
abortTimer = setTimeout(() => {
if (!settled) {
child.kill('SIGKILL');
}
}, forceKillDelayMs);
abortTimer.unref?.();
};
const cleanup = (): void => {
if (abortTimer !== undefined) {
clearTimeout(abortTimer);
}
if (options.abortSignal) {
options.abortSignal.removeEventListener('abort', abortHandler);
}
};
const resolveOnce = (result: CursorExecResult): void => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
const rejectOnce = (error: CursorExecError): void => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const appendChunk = (target: 'stdout' | 'stderr', chunk: Buffer | string): void => {
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
const byteLength = Buffer.byteLength(text);
if (target === 'stdout') {
stdoutBytes += byteLength;
if (stdoutBytes > CURSOR_MAX_BUFFER_BYTES) {
child.kill('SIGTERM');
rejectOnce(createExecError('cursor-agent stdout exceeded buffer limit', {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout,
stderr,
}));
return;
}
stdout += text;
return;
}
stderrBytes += byteLength;
if (stderrBytes > CURSOR_MAX_BUFFER_BYTES) {
child.kill('SIGTERM');
rejectOnce(createExecError('cursor-agent stderr exceeded buffer limit', {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout,
stderr,
}));
return;
}
stderr += text;
};
child.stdout?.on('data', (chunk: Buffer | string) => appendChunk('stdout', chunk));
child.stderr?.on('data', (chunk: Buffer | string) => appendChunk('stderr', chunk));
child.on('error', (error: NodeJS.ErrnoException) => {
rejectOnce(createExecError(error.message, {
code: error.code,
stdout,
stderr,
}));
});
child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
if (settled) return;
if (options.abortSignal?.aborted) {
rejectOnce(createExecError(CURSOR_ABORTED_MESSAGE, {
name: 'AbortError',
stdout,
stderr,
signal,
}));
return;
}
if (code === 0) {
resolveOnce({ stdout, stderr });
return;
}
rejectOnce(createExecError(
signal
? `cursor-agent terminated by signal ${signal}`
: `cursor-agent exited with code ${code ?? 'unknown'}`,
{
code: code ?? undefined,
stdout,
stderr,
signal,
},
));
});
if (options.abortSignal) {
if (options.abortSignal.aborted) {
abortHandler();
} else {
options.abortSignal.addEventListener('abort', abortHandler, { once: true });
}
}
});
}
function toRecord(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function firstNonEmptyString(values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
return undefined;
}
function extractContent(payload: unknown): string | undefined {
if (typeof payload === 'string') {
const trimmed = payload.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(payload)) {
const parts = payload
.map((entry) => extractContent(entry))
.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
return parts.length > 0 ? parts.join('\n') : undefined;
}
const record = toRecord(payload);
if (!record) {
return undefined;
}
const direct = firstNonEmptyString([
record.content,
record.text,
record.output,
record.result,
record.message,
]);
if (direct) {
return direct;
}
const nested = [record.data, record.response, record.payload]
.map((entry) => extractContent(entry))
.find((entry): entry is string => typeof entry === 'string' && entry.length > 0);
if (nested) {
return nested;
}
return undefined;
}
function extractSessionId(payload: unknown): string | undefined {
const record = toRecord(payload);
if (!record) {
return undefined;
}
const nestedData = toRecord(record.data);
const nestedPayload = toRecord(record.payload);
const nestedResponse = toRecord(record.response);
return firstNonEmptyString([
record.sessionId,
record.session_id,
record.chatId,
record.chat_id,
nestedData?.sessionId,
nestedData?.session_id,
nestedData?.chatId,
nestedData?.chat_id,
nestedPayload?.sessionId,
nestedPayload?.session_id,
nestedPayload?.chatId,
nestedPayload?.chat_id,
nestedResponse?.sessionId,
nestedResponse?.session_id,
nestedResponse?.chatId,
nestedResponse?.chat_id,
]);
}
function trimDetail(value: string | undefined, fallback = ''): string {
const normalized = (value ?? '').trim();
if (!normalized) {
return fallback;
}
return normalized.length > CURSOR_ERROR_DETAIL_MAX_LENGTH
? `${normalized.slice(0, CURSOR_ERROR_DETAIL_MAX_LENGTH)}...`
: normalized;
}
function isAuthenticationError(error: CursorExecError): boolean {
const message = [
trimDetail(error.message),
trimDetail(error.stderr),
trimDetail(error.stdout),
].join('\n').toLowerCase();
const patterns = [
'authentication',
'unauthorized',
'forbidden',
'api key',
'not logged in',
'login required',
'cursor_api_key',
];
return patterns.some((pattern) => message.includes(pattern));
}
function classifyExecutionError(error: CursorExecError, options: CursorCallOptions): string {
if (options.abortSignal?.aborted || error.name === 'AbortError') {
return CURSOR_ABORTED_MESSAGE;
}
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
return 'Cursor Agent CLI output exceeded buffer limit';
}
if (error.code === 'ENOENT') {
return 'cursor-agent binary not found. Install Cursor Agent CLI and ensure `cursor-agent` is in PATH.';
}
if (isAuthenticationError(error)) {
return 'Cursor authentication failed. Run `cursor-agent login` or set TAKT_CURSOR_API_KEY/cursor_api_key.';
}
if (typeof error.code === 'number') {
const detail = trimDetail(error.stderr, trimDetail(error.stdout, getErrorMessage(error)));
return `Cursor Agent CLI exited with code ${error.code}: ${detail}`;
}
return getErrorMessage(error);
}
function parseCursorOutput(stdout: string): { content: string; sessionId?: string } | { error: string } {
const trimmed = stdout.trim();
if (!trimmed) {
return { error: 'cursor-agent returned empty output' };
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return {
error: `Failed to parse cursor-agent JSON output: ${trimDetail(trimmed, '<empty>')}`,
};
}
const content = extractContent(parsed);
if (!content) {
return {
error: `Failed to extract assistant content from cursor-agent JSON output: ${trimDetail(trimmed, '<empty>')}`,
};
}
const sessionId = extractSessionId(parsed);
return { content, sessionId };
}
/**
* Client for Cursor Agent CLI interactions.
*/
export class CursorClient {
async call(agentType: string, prompt: string, options: CursorCallOptions): Promise<AgentResponse> {
const args = buildArgs(prompt, options);
try {
const { stdout } = await execCursor(args, options);
const parsed = parseCursorOutput(stdout);
if ('error' in parsed) {
return {
persona: agentType,
status: 'error',
content: parsed.error,
timestamp: new Date(),
sessionId: options.sessionId,
};
}
const sessionId = parsed.sessionId ?? options.sessionId;
if (options.onStream) {
options.onStream({ type: 'text', data: { text: parsed.content } });
options.onStream({
type: 'result',
data: {
result: parsed.content,
success: true,
sessionId: sessionId ?? '',
},
});
}
return {
persona: agentType,
status: 'done',
content: parsed.content,
timestamp: new Date(),
sessionId,
};
} catch (rawError) {
const error = rawError as CursorExecError;
const message = classifyExecutionError(error, options);
if (options.onStream) {
options.onStream({
type: 'result',
data: {
result: '',
success: false,
error: message,
sessionId: options.sessionId ?? '',
},
});
}
return {
persona: agentType,
status: 'error',
content: message,
timestamp: new Date(),
sessionId: options.sessionId,
};
}
}
async callCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: CursorCallOptions,
): Promise<AgentResponse> {
return this.call(agentName, prompt, {
...options,
systemPrompt,
});
}
}
const defaultClient = new CursorClient();
export async function callCursor(
agentType: string,
prompt: string,
options: CursorCallOptions,
): Promise<AgentResponse> {
return defaultClient.call(agentType, prompt, options);
}
export async function callCursorCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: CursorCallOptions,
): Promise<AgentResponse> {
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
}

View File

@ -0,0 +1,6 @@
/**
* Cursor integration exports
*/
export { CursorClient, callCursor, callCursorCustom } from './client.js';
export type { CursorCallOptions } from './types.js';

18
src/infra/cursor/types.ts Normal file
View File

@ -0,0 +1,18 @@
/**
* Type definitions for Cursor Agent CLI integration
*/
import type { StreamCallback } from '../claude/index.js';
import type { PermissionMode } from '../../core/models/index.js';
/** Options for calling Cursor Agent CLI */
export interface CursorCallOptions {
cwd: string;
abortSignal?: AbortSignal;
sessionId?: string;
model?: string;
systemPrompt?: string;
permissionMode?: PermissionMode;
onStream?: StreamCallback;
cursorApiKey?: string;
}

View File

@ -0,0 +1,60 @@
/**
* Cursor provider implementation
*/
import { callCursor, callCursorCustom, type CursorCallOptions } from '../cursor/index.js';
import { resolveCursorApiKey } from '../config/index.js';
import { createLogger } from '../../shared/utils/index.js';
import type { AgentResponse } from '../../core/models/index.js';
import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
const log = createLogger('cursor-provider');
function toCursorOptions(options: ProviderCallOptions): CursorCallOptions {
if (options.allowedTools && options.allowedTools.length > 0) {
log.info('Cursor provider does not support allowedTools; ignoring');
}
if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
log.info('Cursor provider does not support mcpServers; ignoring');
}
if (options.outputSchema) {
log.info('Cursor provider does not support outputSchema; ignoring');
}
return {
cwd: options.cwd,
abortSignal: options.abortSignal,
sessionId: options.sessionId,
model: options.model,
permissionMode: options.permissionMode,
onStream: options.onStream,
cursorApiKey: options.cursorApiKey ?? resolveCursorApiKey(),
};
}
/** Cursor provider — delegates to Cursor Agent CLI */
export class CursorProvider implements Provider {
setup(config: AgentSetup): ProviderAgent {
if (config.claudeAgent) {
throw new Error('Claude Code agent calls are not supported by the Cursor provider');
}
if (config.claudeSkill) {
throw new Error('Claude Code skill calls are not supported by the Cursor provider');
}
const { name, systemPrompt } = config;
if (systemPrompt) {
return {
call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
return callCursorCustom(name, prompt, systemPrompt, toCursorOptions(options));
},
};
}
return {
call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
return callCursor(name, prompt, toCursorOptions(options));
},
};
}
}

View File

@ -1,13 +1,14 @@
/**
* Provider abstraction layer
*
* Provides a unified interface for different agent providers (Claude, Codex, OpenCode, Mock).
* Provides a unified interface for different agent providers (Claude, Codex, OpenCode, Cursor, Mock).
* This enables adding new providers without modifying the runner logic.
*/
import { ClaudeProvider } from './claude.js';
import { CodexProvider } from './codex.js';
import { OpenCodeProvider } from './opencode.js';
import { CursorProvider } from './cursor.js';
import { MockProvider } from './mock.js';
import type { Provider, ProviderType } from './types.js';
@ -26,6 +27,7 @@ export class ProviderRegistry {
claude: new ClaudeProvider(),
codex: new CodexProvider(),
opencode: new OpenCodeProvider(),
cursor: new CursorProvider(),
mock: new MockProvider(),
};
}
@ -56,4 +58,3 @@ export class ProviderRegistry {
export function getProvider(type: ProviderType): Provider {
return ProviderRegistry.getInstance().get(type);
}

View File

@ -35,6 +35,7 @@ export interface ProviderCallOptions {
anthropicApiKey?: string;
openaiApiKey?: string;
opencodeApiKey?: string;
cursorApiKey?: string;
outputSchema?: Record<string, unknown>;
}
@ -49,4 +50,4 @@ export interface Provider {
}
/** Provider type */
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import { e2eBaseTestConfig } from './vitest.config.e2e.base';
export default defineConfig({
test: {
...e2eBaseTestConfig,
include: [
'e2e/specs/add-and-run.e2e.ts',
'e2e/specs/worktree.e2e.ts',
],
},
});