Merge pull request #403 from j5ik2o/feature/cursor-agent-cli-provider-spec
feat: cursor-agent対応
This commit is contained in:
parent
0186cee1d1
commit
204843f498
@ -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`) |
|
||||
|
||||
|
||||
@ -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`) |
|
||||
|
||||
|
||||
@ -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 <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` |
|
||||
|
||||
### 設定方法
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`: 単体実行の例。
|
||||
|
||||
|
||||
@ -8,3 +8,6 @@ notification_sound_events:
|
||||
piece_abort: false
|
||||
run_complete: true
|
||||
run_abort: false
|
||||
provider_profiles:
|
||||
cursor:
|
||||
default_permission_mode: full
|
||||
|
||||
@ -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"
|
||||
|
||||
11
src/__tests__/cli-provider-option.test.ts
Normal file
11
src/__tests__/cli-provider-option.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
176
src/__tests__/cursor-client.test.ts
Normal file
176
src/__tests__/cursor-client.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
143
src/__tests__/cursor-provider.test.ts
Normal file
143
src/__tests__/cursor-provider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' },
|
||||
};
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
1
src/infra/config/env/config-env-overrides.ts
vendored
1
src/infra/config/env/config-env-overrides.ts
vendored
@ -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' },
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ export {
|
||||
resolveOpenaiApiKey,
|
||||
resolveCodexCliPath,
|
||||
resolveOpencodeApiKey,
|
||||
resolveCursorApiKey,
|
||||
} from './globalConfig.js';
|
||||
|
||||
export {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
497
src/infra/cursor/client.ts
Normal 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);
|
||||
}
|
||||
6
src/infra/cursor/index.ts
Normal file
6
src/infra/cursor/index.ts
Normal 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
18
src/infra/cursor/types.ts
Normal 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;
|
||||
}
|
||||
60
src/infra/providers/cursor.ts
Normal file
60
src/infra/providers/cursor.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
12
vitest.config.e2e.cursor.ts
Normal file
12
vitest.config.e2e.cursor.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user