diff --git a/docs/cli-reference.ja.md b/docs/cli-reference.ja.md index 5b1c2ce9..461b15c5 100644 --- a/docs/cli-reference.ja.md +++ b/docs/cli-reference.ja.md @@ -19,7 +19,7 @@ | `--repo ` | リポジトリを指定(PR 作成用) | | `--create-worktree ` | worktree 確認プロンプトをスキップ | | `-q, --quiet` | 最小出力モード: AI 出力を抑制(CI 向け) | -| `--provider ` | エージェント provider を上書き(claude\|codex\|opencode\|mock) | +| `--provider ` | エージェント provider を上書き(claude\|codex\|opencode\|cursor\|mock) | | `--model ` | エージェントモデルを上書き | | `--config ` | グローバル設定ファイルのパス(デフォルト: `~/.takt/config.yaml`) | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4562ac8d..df53d630 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -19,7 +19,7 @@ This document provides a complete reference for all TAKT CLI commands and option | `--repo ` | Specify repository (for PR creation) | | `--create-worktree ` | Skip worktree confirmation prompt | | `-q, --quiet` | Minimal output mode: suppress AI output (for CI) | -| `--provider ` | Override agent provider (claude\|codex\|opencode\|mock) | +| `--provider ` | Override agent provider (claude\|codex\|opencode\|cursor\|mock) | | `--model ` | Override agent model | | `--config ` | Path to global config file (default: `~/.takt/config.yaml`) | diff --git a/docs/configuration.ja.md b/docs/configuration.ja.md index bd157a7e..c15c0a4c 100644 --- a/docs/configuration.ja.md +++ b/docs/configuration.ja.md @@ -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-... # Claude(Anthropic)用 # openai_api_key: sk-... # Codex(OpenAI)用 # 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 ` にそのまま渡します。省略時は 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` | ### 設定方法 diff --git a/docs/configuration.md b/docs/configuration.md index c976e446..4202cd90 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 `. 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 diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index c7649e02..7ed9b2e0 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -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`: 単体実行の例。 diff --git a/e2e/fixtures/config.e2e.yaml b/e2e/fixtures/config.e2e.yaml index 4cbdd6e1..b0a4989c 100644 --- a/e2e/fixtures/config.e2e.yaml +++ b/e2e/fixtures/config.e2e.yaml @@ -8,3 +8,6 @@ notification_sound_events: piece_abort: false run_complete: true run_abort: false +provider_profiles: + cursor: + default_permission_mode: full diff --git a/package.json b/package.json index ec54c0fc..37d26127 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/__tests__/cli-provider-option.test.ts b/src/__tests__/cli-provider-option.test.ts new file mode 100644 index 00000000..0c3eed7c --- /dev/null +++ b/src/__tests__/cli-provider-option.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/config-env-overrides.test.ts b/src/__tests__/config-env-overrides.test.ts index 69d7500d..4ef95fc3 100644 --- a/src/__tests__/config-env-overrides.test.ts +++ b/src/__tests__/config-env-overrides.test.ts @@ -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 = {}; + applyGlobalConfigEnvOverrides(raw); + + expect(raw.cursor_api_key).toBe('cursor-key-from-env'); + }); }); diff --git a/src/__tests__/cursor-client.test.ts b/src/__tests__/cursor-client.test.ts new file mode 100644 index 00000000..5d2b5854 --- /dev/null +++ b/src/__tests__/cursor-client.test.ts @@ -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 & { message: string }; +}; + +type MockChildProcess = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; +}; + +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'); + }); +}); diff --git a/src/__tests__/cursor-provider.test.ts b/src/__tests__/cursor-provider.test.ts new file mode 100644 index 00000000..80711293 --- /dev/null +++ b/src/__tests__/cursor-provider.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/engine-blocked.test.ts b/src/__tests__/engine-blocked.test.ts index 02abc209..50fce6d5 100644 --- a/src/__tests__/engine-blocked.test.ts +++ b/src/__tests__/engine-blocked.test.ts @@ -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'); diff --git a/src/__tests__/engine-error.test.ts b/src/__tests__/engine-error.test.ts index 553ec2f7..1a751d71 100644 --- a/src/__tests__/engine-error.test.ts +++ b/src/__tests__/engine-error.test.ts @@ -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 () => { diff --git a/src/__tests__/globalConfig-resolvers.test.ts b/src/__tests__/globalConfig-resolvers.test.ts index 83141987..a1dff714 100644 --- a/src/__tests__/globalConfig-resolvers.test.ts +++ b/src/__tests__/globalConfig-resolvers.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts index 0d215da4..6bccfe91 100644 --- a/src/__tests__/opencode-config.test.ts +++ b/src/__tests__/opencode-config.test.ts @@ -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); } diff --git a/src/__tests__/provider-resolution.test.ts b/src/__tests__/provider-resolution.test.ts index 2adfc6d5..2b883170 100644 --- a/src/__tests__/provider-resolution.test.ts +++ b/src/__tests__/provider-resolution.test.ts @@ -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', () => { diff --git a/src/__tests__/report-phase-blocked.test.ts b/src/__tests__/report-phase-blocked.test.ts index 02417846..b49c1993 100644 --- a/src/__tests__/report-phase-blocked.test.ts +++ b/src/__tests__/report-phase-blocked.test.ts @@ -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'); + }); }); diff --git a/src/agents/types.ts b/src/agents/types.ts index 497fd081..2dda854a 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -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; diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 142ba026..4488040d 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -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 ', 'Repository (defaults to current)') - .option('--provider ', 'Override agent provider (claude|codex|opencode|mock)') + .option('--provider ', 'Override agent provider (claude|codex|opencode|cursor|mock)') .option('--model ', 'Override agent model') .option('-t, --task ', 'Task content (as alternative to GitHub issue)') .option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation') diff --git a/src/core/models/persisted-global-config.ts b/src/core/models/persisted-global-config.ts index 028ca56f..6c65b367 100644 --- a/src/core/models/persisted-global-config.ts +++ b/src/core/models/persisted-global-config.ts @@ -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 */ diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 2ebd0964..1bf444b2 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -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 */ diff --git a/src/core/models/provider-profiles.ts b/src/core/models/provider-profiles.ts index 53ded4f1..e2165fc8 100644 --- a/src/core/models/provider-profiles.ts +++ b/src/core/models/provider-profiles.ts @@ -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 { diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 8d855415..074b6337 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -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, diff --git a/src/core/piece/agent-usecases.ts b/src/core/piece/agent-usecases.ts index 5d8f545a..b35f944f 100644 --- a/src/core/piece/agent-usecases.ts +++ b/src/core/piece/agent-usecases.ts @@ -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 { diff --git a/src/core/piece/engine/MovementExecutor.ts b/src/core/piece/engine/MovementExecutor.ts index b7594702..0f36b7ad 100644 --- a/src/core/piece/engine/MovementExecutor.ts +++ b/src/core/piece/engine/MovementExecutor.ts @@ -186,6 +186,11 @@ export class MovementExecutor { updatePersonaSession: (persona: string, sessionId: string | undefined) => void, ): Promise { 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, diff --git a/src/core/piece/permission-profile-resolution.ts b/src/core/piece/permission-profile-resolution.ts index a877aef6..7afeb82e 100644 --- a/src/core/piece/permission-profile-resolution.ts +++ b/src/core/piece/permission-profile-resolution.ts @@ -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' }, }; diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index 86c49c50..4fca7cf6 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -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 { diff --git a/src/infra/config/env/config-env-overrides.ts b/src/infra/config/env/config-env-overrides.ts index 99bb8ca1..af697c1d 100644 --- a/src/infra/config/env/config-env-overrides.ts +++ b/src/infra/config/env/config-env-overrides.ts @@ -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' }, diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index bf10412a..26e27388 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -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 = {}; 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; + } +} diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index b2326038..17e262f3 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -16,6 +16,7 @@ export { resolveOpenaiApiKey, resolveCodexCliPath, resolveOpencodeApiKey, + resolveCursorApiKey, } from './globalConfig.js'; export { diff --git a/src/infra/config/global/initialization.ts b/src/infra/config/global/initialization.ts index ec5656a9..9255d257 100644 --- a/src/infra/config/global/initialization.ts +++ b/src/infra/config/global/initialization.ts @@ -56,11 +56,12 @@ export async function promptLanguageSelection(): Promise { * 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( diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 8058e1f8..77b6c4fa 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -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 */ diff --git a/src/infra/cursor/client.ts b/src/infra/cursor/client.ts new file mode 100644 index 00000000..dae47e8d --- /dev/null +++ b/src/infra/cursor/client.ts @@ -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 { + return new Promise((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 | 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 | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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, '')}`, + }; + } + + const content = extractContent(parsed); + if (!content) { + return { + error: `Failed to extract assistant content from cursor-agent JSON output: ${trimDetail(trimmed, '')}`, + }; + } + + 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 { + 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 { + return this.call(agentName, prompt, { + ...options, + systemPrompt, + }); + } +} + +const defaultClient = new CursorClient(); + +export async function callCursor( + agentType: string, + prompt: string, + options: CursorCallOptions, +): Promise { + return defaultClient.call(agentType, prompt, options); +} + +export async function callCursorCustom( + agentName: string, + prompt: string, + systemPrompt: string, + options: CursorCallOptions, +): Promise { + return defaultClient.callCustom(agentName, prompt, systemPrompt, options); +} diff --git a/src/infra/cursor/index.ts b/src/infra/cursor/index.ts new file mode 100644 index 00000000..b52a14f4 --- /dev/null +++ b/src/infra/cursor/index.ts @@ -0,0 +1,6 @@ +/** + * Cursor integration exports + */ + +export { CursorClient, callCursor, callCursorCustom } from './client.js'; +export type { CursorCallOptions } from './types.js'; diff --git a/src/infra/cursor/types.ts b/src/infra/cursor/types.ts new file mode 100644 index 00000000..74a3af97 --- /dev/null +++ b/src/infra/cursor/types.ts @@ -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; +} diff --git a/src/infra/providers/cursor.ts b/src/infra/providers/cursor.ts new file mode 100644 index 00000000..dab456d7 --- /dev/null +++ b/src/infra/providers/cursor.ts @@ -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 => { + return callCursorCustom(name, prompt, systemPrompt, toCursorOptions(options)); + }, + }; + } + + return { + call: async (prompt: string, options: ProviderCallOptions): Promise => { + return callCursor(name, prompt, toCursorOptions(options)); + }, + }; + } +} diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts index 88659c88..82b7a06b 100644 --- a/src/infra/providers/index.ts +++ b/src/infra/providers/index.ts @@ -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); } - diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index b5c9263f..4313150f 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -35,6 +35,7 @@ export interface ProviderCallOptions { anthropicApiKey?: string; openaiApiKey?: string; opencodeApiKey?: string; + cursorApiKey?: string; outputSchema?: Record; } @@ -49,4 +50,4 @@ export interface Provider { } /** Provider type */ -export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; +export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; diff --git a/vitest.config.e2e.cursor.ts b/vitest.config.e2e.cursor.ts new file mode 100644 index 00000000..a7b77398 --- /dev/null +++ b/vitest.config.e2e.cursor.ts @@ -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', + ], + }, +});