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 作成用) |
|
| `--repo <owner/repo>` | リポジトリを指定(PR 作成用) |
|
||||||
| `--create-worktree <yes\|no>` | worktree 確認プロンプトをスキップ |
|
| `--create-worktree <yes\|no>` | worktree 確認プロンプトをスキップ |
|
||||||
| `-q, --quiet` | 最小出力モード: AI 出力を抑制(CI 向け) |
|
| `-q, --quiet` | 最小出力モード: AI 出力を抑制(CI 向け) |
|
||||||
| `--provider <name>` | エージェント provider を上書き(claude\|codex\|opencode\|mock) |
|
| `--provider <name>` | エージェント provider を上書き(claude\|codex\|opencode\|cursor\|mock) |
|
||||||
| `--model <name>` | エージェントモデルを上書き |
|
| `--model <name>` | エージェントモデルを上書き |
|
||||||
| `--config <path>` | グローバル設定ファイルのパス(デフォルト: `~/.takt/config.yaml`) |
|
| `--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) |
|
| `--repo <owner/repo>` | Specify repository (for PR creation) |
|
||||||
| `--create-worktree <yes\|no>` | Skip worktree confirmation prompt |
|
| `--create-worktree <yes\|no>` | Skip worktree confirmation prompt |
|
||||||
| `-q, --quiet` | Minimal output mode: suppress AI output (for CI) |
|
| `-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 |
|
| `--model <name>` | Override agent model |
|
||||||
| `--config <path>` | Path to global config file (default: `~/.takt/config.yaml`) |
|
| `--config <path>` | Path to global config file (default: `~/.takt/config.yaml`) |
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
language: en # UI 言語: 'en' または 'ja'
|
language: en # UI 言語: 'en' または 'ja'
|
||||||
default_piece: default # 新規プロジェクトのデフォルト piece
|
default_piece: default # 新規プロジェクトのデフォルト piece
|
||||||
log_level: info # ログレベル: debug, info, warn, error
|
log_level: info # ログレベル: debug, info, warn, error
|
||||||
provider: claude # デフォルト provider: claude, codex, または opencode
|
provider: claude # デフォルト provider: claude, codex, opencode, または cursor
|
||||||
model: sonnet # デフォルトモデル(省略可、provider にそのまま渡される)
|
model: sonnet # デフォルトモデル(省略可、provider にそのまま渡される)
|
||||||
branch_name_strategy: romaji # ブランチ名生成方式: 'romaji'(高速)または 'ai'(低速)
|
branch_name_strategy: romaji # ブランチ名生成方式: 'romaji'(高速)または 'ai'(低速)
|
||||||
prevent_sleep: false # 実行中に macOS のアイドルスリープを防止(caffeinate)
|
prevent_sleep: false # 実行中に macOS のアイドルスリープを防止(caffeinate)
|
||||||
@ -56,10 +56,11 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
|
|||||||
# default_permission_mode: edit
|
# default_permission_mode: edit
|
||||||
|
|
||||||
# API キー設定(省略可)
|
# 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)用
|
# anthropic_api_key: sk-ant-... # Claude(Anthropic)用
|
||||||
# openai_api_key: sk-... # Codex(OpenAI)用
|
# openai_api_key: sk-... # Codex(OpenAI)用
|
||||||
# opencode_api_key: ... # OpenCode 用
|
# opencode_api_key: ... # OpenCode 用
|
||||||
|
# cursor_api_key: ... # Cursor Agent 用(省略時は login セッションにフォールバック)
|
||||||
|
|
||||||
# Codex CLI パス上書き(省略可)
|
# Codex CLI パス上書き(省略可)
|
||||||
# Codex SDK が使用する Codex CLI バイナリを上書き(実行可能ファイルの絶対パスが必要)
|
# Codex SDK が使用する Codex CLI バイナリを上書き(実行可能ファイルの絶対パスが必要)
|
||||||
@ -88,7 +89,7 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
|
|||||||
| `language` | `"en"` \| `"ja"` | `"en"` | UI 言語 |
|
| `language` | `"en"` \| `"ja"` | `"en"` | UI 言語 |
|
||||||
| `default_piece` | string | `"default"` | 新規プロジェクトのデフォルト piece |
|
| `default_piece` | string | `"default"` | 新規プロジェクトのデフォルト piece |
|
||||||
| `log_level` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | ログレベル |
|
| `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 にそのまま渡される) |
|
| `model` | string | - | デフォルトモデル名(provider にそのまま渡される) |
|
||||||
| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | ブランチ名生成方式 |
|
| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | ブランチ名生成方式 |
|
||||||
| `prevent_sleep` | boolean | `false` | macOS アイドルスリープ防止(caffeinate) |
|
| `prevent_sleep` | boolean | `false` | macOS アイドルスリープ防止(caffeinate) |
|
||||||
@ -108,6 +109,7 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
|
|||||||
| `anthropic_api_key` | string | - | Claude 用 Anthropic API キー |
|
| `anthropic_api_key` | string | - | Claude 用 Anthropic API キー |
|
||||||
| `openai_api_key` | string | - | Codex 用 OpenAI API キー |
|
| `openai_api_key` | string | - | Codex 用 OpenAI API キー |
|
||||||
| `opencode_api_key` | string | - | OpenCode API キー |
|
| `opencode_api_key` | string | - | OpenCode API キー |
|
||||||
|
| `cursor_api_key` | string | - | Cursor API キー(省略時は login セッションへフォールバック) |
|
||||||
| `codex_cli_path` | string | - | Codex CLI バイナリパス上書き(絶対パス) |
|
| `codex_cli_path` | string | - | Codex CLI バイナリパス上書き(絶対パス) |
|
||||||
| `enable_builtin_pieces` | boolean | `true` | ビルトイン piece の有効化 |
|
| `enable_builtin_pieces` | boolean | `true` | ビルトイン piece の有効化 |
|
||||||
| `disabled_builtins` | string[] | `[]` | 無効化する特定のビルトイン piece |
|
| `disabled_builtins` | string[] | `[]` | 無効化する特定のビルトイン piece |
|
||||||
@ -149,7 +151,7 @@ concurrency: 2 # このプロジェクトでの takt run 並列
|
|||||||
| フィールド | 型 | デフォルト | 説明 |
|
| フィールド | 型 | デフォルト | 説明 |
|
||||||
|-----------|------|---------|------|
|
|-----------|------|---------|------|
|
||||||
| `piece` | string | `"default"` | このプロジェクトの現在の piece 名 |
|
| `piece` | string | `"default"` | このプロジェクトの現在の piece 名 |
|
||||||
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"mock"` | - | provider 上書き |
|
| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"cursor"` \| `"mock"` | - | provider 上書き |
|
||||||
| `model` | string | - | モデル名の上書き(provider にそのまま渡される) |
|
| `model` | string | - | モデル名の上書き(provider にそのまま渡される) |
|
||||||
| `auto_pr` | boolean | - | worktree 実行後に PR を自動作成 |
|
| `auto_pr` | boolean | - | worktree 実行後に PR を自動作成 |
|
||||||
| `verbose` | boolean | - | 詳細出力モード |
|
| `verbose` | boolean | - | 詳細出力モード |
|
||||||
@ -162,7 +164,7 @@ concurrency: 2 # このプロジェクトでの takt run 並列
|
|||||||
|
|
||||||
## API キー設定
|
## 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 用
|
# OpenCode 用
|
||||||
export TAKT_OPENCODE_API_KEY=...
|
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 用
|
anthropic_api_key: sk-ant-... # Claude 用
|
||||||
openai_api_key: sk-... # Codex 用
|
openai_api_key: sk-... # Codex 用
|
||||||
opencode_api_key: ... # OpenCode 用
|
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` |
|
| Claude (Anthropic) | `TAKT_ANTHROPIC_API_KEY` | `anthropic_api_key` |
|
||||||
| Codex (OpenAI) | `TAKT_OPENAI_API_KEY` | `openai_api_key` |
|
| Codex (OpenAI) | `TAKT_OPENAI_API_KEY` | `openai_api_key` |
|
||||||
| OpenCode | `TAKT_OPENCODE_API_KEY` | `opencode_api_key` |
|
| OpenCode | `TAKT_OPENCODE_API_KEY` | `opencode_api_key` |
|
||||||
|
| Cursor Agent | `TAKT_CURSOR_API_KEY` | `cursor_api_key` |
|
||||||
|
|
||||||
### セキュリティ
|
### セキュリティ
|
||||||
|
|
||||||
- `config.yaml` に API キーを記載する場合、このファイルを Git にコミットしないよう注意してください。
|
- `config.yaml` に API キーを記載する場合、このファイルを Git にコミットしないよう注意してください。
|
||||||
- 環境変数の使用を検討してください。
|
- 環境変数の使用を検討してください。
|
||||||
- 必要に応じて `~/.takt/config.yaml` をグローバル `.gitignore` に追加してください。
|
- 必要に応じて `~/.takt/config.yaml` をグローバル `.gitignore` に追加してください。
|
||||||
|
- Cursor provider は `cursor-agent login` が済んでいれば API キーなしでも動作できます。
|
||||||
- API キーを設定すれば、対応する CLI ツール(Claude Code、Codex、OpenCode)のインストールは不要です。TAKT が対応する API を直接呼び出します。
|
- API キーを設定すれば、対応する CLI ツール(Claude Code、Codex、OpenCode)のインストールは不要です。TAKT が対応する API を直接呼び出します。
|
||||||
|
|
||||||
### Codex CLI パス上書き
|
### Codex CLI パス上書き
|
||||||
@ -224,7 +232,7 @@ codex_cli_path: /usr/local/bin/codex
|
|||||||
|
|
||||||
1. **Piece movement の `model`** - piece YAML の movement 定義で指定
|
1. **Piece movement の `model`** - piece YAML の movement 定義で指定
|
||||||
2. **グローバル設定の `model`** - `~/.takt/config.yaml` のデフォルトモデル
|
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 固有のモデルに関する注意
|
### Provider 固有のモデルに関する注意
|
||||||
|
|
||||||
@ -234,6 +242,8 @@ codex_cli_path: /usr/local/bin/codex
|
|||||||
|
|
||||||
**OpenCode** は `provider/model` 形式のモデル(例: `opencode/big-pickle`)が必要です。OpenCode provider でモデルを省略すると設定エラーになります。
|
**OpenCode** は `provider/model` 形式のモデル(例: `opencode/big-pickle`)が必要です。OpenCode provider でモデルを省略すると設定エラーになります。
|
||||||
|
|
||||||
|
**Cursor Agent** は `model` を `cursor-agent --model <model>` にそのまま渡します。省略時は Cursor CLI のデフォルトが使用されます。
|
||||||
|
|
||||||
### 設定例
|
### 設定例
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@ -261,11 +271,11 @@ Provider プロファイルを使用すると、各 provider にデフォルト
|
|||||||
|
|
||||||
TAKT は provider 非依存の3つのパーミッションモードを使用します。
|
TAKT は provider 非依存の3つのパーミッションモードを使用します。
|
||||||
|
|
||||||
| モード | 説明 | Claude | Codex | OpenCode |
|
| モード | 説明 | Claude | Codex | OpenCode | Cursor Agent |
|
||||||
|--------|------|--------|-------|----------|
|
|--------|------|--------|-------|----------|--------------|
|
||||||
| `readonly` | 読み取り専用、ファイル変更不可 | `default` | `read-only` | `read-only` |
|
| `readonly` | 読み取り専用、ファイル変更不可 | `default` | `read-only` | `read-only` | デフォルトフラグ(`--force` なし) |
|
||||||
| `edit` | 確認付きでファイル編集を許可 | `acceptEdits` | `workspace-write` | `workspace-write` |
|
| `edit` | 確認付きでファイル編集を許可 | `acceptEdits` | `workspace-write` | `workspace-write` | デフォルトフラグ(`--force` なし) |
|
||||||
| `full` | すべてのパーミッションチェックをバイパス | `bypassPermissions` | `danger-full-access` | `danger-full-access` |
|
| `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'
|
language: en # UI language: 'en' or 'ja'
|
||||||
default_piece: default # Default piece for new projects
|
default_piece: default # Default piece for new projects
|
||||||
log_level: info # Log level: debug, info, warn, error
|
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)
|
model: sonnet # Default model (optional, passed to provider as-is)
|
||||||
branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow)
|
branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow)
|
||||||
prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate)
|
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
|
# default_permission_mode: edit
|
||||||
|
|
||||||
# API Key configuration (optional)
|
# 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)
|
# anthropic_api_key: sk-ant-... # For Claude (Anthropic)
|
||||||
# openai_api_key: sk-... # For Codex (OpenAI)
|
# openai_api_key: sk-... # For Codex (OpenAI)
|
||||||
# opencode_api_key: ... # For OpenCode
|
# opencode_api_key: ... # For OpenCode
|
||||||
|
# cursor_api_key: ... # For Cursor Agent (optional; login session fallback is also supported)
|
||||||
|
|
||||||
# Codex CLI path override (optional)
|
# Codex CLI path override (optional)
|
||||||
# Override the Codex CLI binary used by the Codex SDK (must be an absolute path to an executable file)
|
# 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 |
|
| `language` | `"en"` \| `"ja"` | `"en"` | UI language |
|
||||||
| `default_piece` | string | `"default"` | Default piece for new projects |
|
| `default_piece` | string | `"default"` | Default piece for new projects |
|
||||||
| `log_level` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | Log level |
|
| `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) |
|
| `model` | string | - | Default model name (passed to provider as-is) |
|
||||||
| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | Branch name generation strategy |
|
| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | Branch name generation strategy |
|
||||||
| `prevent_sleep` | boolean | `false` | Prevent macOS idle sleep (caffeinate) |
|
| `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 |
|
| `anthropic_api_key` | string | - | Anthropic API key for Claude |
|
||||||
| `openai_api_key` | string | - | OpenAI API key for Codex |
|
| `openai_api_key` | string | - | OpenAI API key for Codex |
|
||||||
| `opencode_api_key` | string | - | OpenCode API key |
|
| `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) |
|
| `codex_cli_path` | string | - | Codex CLI binary path override (absolute) |
|
||||||
| `enable_builtin_pieces` | boolean | `true` | Enable builtin pieces |
|
| `enable_builtin_pieces` | boolean | `true` | Enable builtin pieces |
|
||||||
| `disabled_builtins` | string[] | `[]` | Specific builtin pieces to disable |
|
| `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 |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `piece` | string | `"default"` | Current piece name for this project |
|
| `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) |
|
| `model` | string | - | Override model name (passed to provider as-is) |
|
||||||
| `auto_pr` | boolean | - | Auto-create PR after worktree execution |
|
| `auto_pr` | boolean | - | Auto-create PR after worktree execution |
|
||||||
| `verbose` | boolean | - | Verbose output mode |
|
| `verbose` | boolean | - | Verbose output mode |
|
||||||
@ -162,7 +164,7 @@ Project config values override global config when both are set.
|
|||||||
|
|
||||||
## API Key Configuration
|
## 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)
|
### Environment Variables (Recommended)
|
||||||
|
|
||||||
@ -175,6 +177,9 @@ export TAKT_OPENAI_API_KEY=sk-...
|
|||||||
|
|
||||||
# For OpenCode
|
# For OpenCode
|
||||||
export TAKT_OPENCODE_API_KEY=...
|
export TAKT_OPENCODE_API_KEY=...
|
||||||
|
|
||||||
|
# For Cursor Agent (optional if cursor-agent login session exists)
|
||||||
|
export TAKT_CURSOR_API_KEY=...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config File
|
### Config File
|
||||||
@ -184,6 +189,7 @@ export TAKT_OPENCODE_API_KEY=...
|
|||||||
anthropic_api_key: sk-ant-... # For Claude
|
anthropic_api_key: sk-ant-... # For Claude
|
||||||
openai_api_key: sk-... # For Codex
|
openai_api_key: sk-... # For Codex
|
||||||
opencode_api_key: ... # For OpenCode
|
opencode_api_key: ... # For OpenCode
|
||||||
|
cursor_api_key: ... # For Cursor Agent (optional)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Priority
|
### Priority
|
||||||
@ -195,12 +201,14 @@ Environment variables take precedence over `config.yaml` settings.
|
|||||||
| Claude (Anthropic) | `TAKT_ANTHROPIC_API_KEY` | `anthropic_api_key` |
|
| Claude (Anthropic) | `TAKT_ANTHROPIC_API_KEY` | `anthropic_api_key` |
|
||||||
| Codex (OpenAI) | `TAKT_OPENAI_API_KEY` | `openai_api_key` |
|
| Codex (OpenAI) | `TAKT_OPENAI_API_KEY` | `openai_api_key` |
|
||||||
| OpenCode | `TAKT_OPENCODE_API_KEY` | `opencode_api_key` |
|
| OpenCode | `TAKT_OPENCODE_API_KEY` | `opencode_api_key` |
|
||||||
|
| Cursor Agent | `TAKT_CURSOR_API_KEY` | `cursor_api_key` |
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- If you write API keys in `config.yaml`, be careful not to commit this file to Git.
|
- If you write API keys in `config.yaml`, be careful not to commit this file to Git.
|
||||||
- Consider using environment variables instead.
|
- Consider using environment variables instead.
|
||||||
- Add `~/.takt/config.yaml` to your global `.gitignore` if needed.
|
- 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.
|
- 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
|
### 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
|
1. **Piece movement `model`** - Specified in the movement definition in piece YAML
|
||||||
2. **Global config `model`** - Default model in `~/.takt/config.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
|
### 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.
|
**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
|
### Example
|
||||||
|
|
||||||
```yaml
|
```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:
|
TAKT uses three provider-independent permission modes:
|
||||||
|
|
||||||
| Mode | Description | Claude | Codex | OpenCode |
|
| Mode | Description | Claude | Codex | OpenCode | Cursor Agent |
|
||||||
|------|-------------|--------|-------|----------|
|
|------|-------------|--------|-------|----------|--------------|
|
||||||
| `readonly` | Read-only access, no file modifications | `default` | `read-only` | `read-only` |
|
| `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` |
|
| `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` |
|
| `full` | Bypass all permission checks | `bypassPermissions` | `danger-full-access` | `danger-full-access` | `--force` |
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,8 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
|
|||||||
## 前提条件
|
## 前提条件
|
||||||
- `gh` CLI が利用可能で、対象GitHubアカウントでログイン済みであること。
|
- `gh` CLI が利用可能で、対象GitHubアカウントでログイン済みであること。
|
||||||
- `takt-testing` リポジトリが対象アカウントに存在すること(E2Eがクローンして使用)。
|
- `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`)。
|
- `TAKT_E2E_PROVIDER=opencode` の場合は `TAKT_E2E_MODEL` が必須(例: `opencode/big-pickle`)。
|
||||||
- 実行時間が長いテストがあるため、タイムアウトに注意すること。
|
- 実行時間が長いテストがあるため、タイムアウトに注意すること。
|
||||||
- E2Eは `e2e/helpers/test-repo.ts` が `gh` でリポジトリをクローンし、テンポラリディレクトリで実行する。
|
- 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` と `codex` の両方で実行。
|
||||||
- `npm run test:e2e:provider:claude`: `TAKT_E2E_PROVIDER=claude` で実行。
|
- `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: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:provider:opencode`: `TAKT_E2E_PROVIDER=opencode` で実行(`TAKT_E2E_MODEL` 必須)。
|
||||||
- `npm run test:e2e:all`: `mock` + `provider` を通しで実行。
|
- `npm run test:e2e:all`: `mock` + `provider` を通しで実行。
|
||||||
- `npm run test:e2e:claude`: `test:e2e:provider:claude` の別名。
|
- `npm run test:e2e:claude`: `test:e2e:provider:claude` の別名。
|
||||||
- `npm run test:e2e:codex`: `test:e2e:provider:codex` の別名。
|
- `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` の別名。
|
- `npm run test:e2e:opencode`: `test:e2e:provider:opencode` の別名。
|
||||||
- `npx vitest run e2e/specs/add-and-run.e2e.ts`: 単体実行の例。
|
- `npx vitest run e2e/specs/add-and-run.e2e.ts`: 単体実行の例。
|
||||||
|
|
||||||
|
|||||||
@ -8,3 +8,6 @@ notification_sound_events:
|
|||||||
piece_abort: false
|
piece_abort: false
|
||||||
run_complete: true
|
run_complete: true
|
||||||
run_abort: false
|
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: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: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: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:claude": "npm run test:e2e:provider:claude",
|
||||||
"test:e2e:codex": "npm run test:e2e:provider:codex",
|
"test:e2e:codex": "npm run test:e2e:provider:codex",
|
||||||
"test:e2e:opencode": "npm run test:e2e:provider:opencode",
|
"test:e2e:opencode": "npm run test:e2e:provider:opencode",
|
||||||
|
"test:e2e:cursor": "npm run test:e2e:provider:cursor",
|
||||||
"lint": "eslint src/",
|
"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",
|
"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"
|
"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,
|
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 { 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) ---
|
// --- 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');
|
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 () => {
|
it('should abort immediately when movement returns error status', async () => {
|
||||||
const config = buildDefaultPieceConfig();
|
const config = buildDefaultPieceConfig();
|
||||||
const onUserInput = vi.fn().mockResolvedValueOnce('should not be called');
|
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 { PieceEngine } from '../core/piece/index.js';
|
||||||
import { runAgent } from '../agents/runner.js';
|
import { runAgent } from '../agents/runner.js';
|
||||||
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
|
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
|
||||||
|
import { runReportPhase } from '../core/piece/phase-runner.js';
|
||||||
import {
|
import {
|
||||||
makeResponse,
|
makeResponse,
|
||||||
makeMovement,
|
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', () => {
|
describe('Loop detection', () => {
|
||||||
it('should abort when loop detected with action: abort', async () => {
|
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', () => {
|
describe('Iteration limit', () => {
|
||||||
it('should abort when max iterations reached without onIterationLimit callback', async () => {
|
it('should abort when max iterations reached without onIterationLimit callback', async () => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for API key authentication feature
|
* 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
|
* - Environment variable priority over config.yaml
|
||||||
* - Config.yaml fallback when env var is not set
|
* - Config.yaml fallback when env var is not set
|
||||||
* - Undefined when neither is set
|
* - Undefined when neither is set
|
||||||
@ -46,7 +46,16 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Import after mocking
|
// 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', () => {
|
describe('GlobalConfigSchema API key fields', () => {
|
||||||
it('should accept config without API keys', () => {
|
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.anthropic_api_key).toBe('sk-ant-key');
|
||||||
expect(result.openai_api_key).toBe('sk-openai-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', () => {
|
describe('GlobalConfig load/save with API keys', () => {
|
||||||
@ -101,12 +118,14 @@ describe('GlobalConfig load/save with API keys', () => {
|
|||||||
'provider: claude',
|
'provider: claude',
|
||||||
'anthropic_api_key: sk-ant-from-yaml',
|
'anthropic_api_key: sk-ant-from-yaml',
|
||||||
'openai_api_key: sk-openai-from-yaml',
|
'openai_api_key: sk-openai-from-yaml',
|
||||||
|
'cursor_api_key: cursor-from-yaml',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
writeFileSync(configPath, yaml, 'utf-8');
|
writeFileSync(configPath, yaml, 'utf-8');
|
||||||
|
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
expect(config.anthropicApiKey).toBe('sk-ant-from-yaml');
|
expect(config.anthropicApiKey).toBe('sk-ant-from-yaml');
|
||||||
expect(config.openaiApiKey).toBe('sk-openai-from-yaml');
|
expect(config.openaiApiKey).toBe('sk-openai-from-yaml');
|
||||||
|
expect(config.cursorApiKey).toBe('cursor-from-yaml');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load config without API keys', () => {
|
it('should load config without API keys', () => {
|
||||||
@ -134,11 +153,13 @@ describe('GlobalConfig load/save with API keys', () => {
|
|||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
config.anthropicApiKey = 'sk-ant-saved';
|
config.anthropicApiKey = 'sk-ant-saved';
|
||||||
config.openaiApiKey = 'sk-openai-saved';
|
config.openaiApiKey = 'sk-openai-saved';
|
||||||
|
config.cursorApiKey = 'cursor-saved';
|
||||||
saveGlobalConfig(config);
|
saveGlobalConfig(config);
|
||||||
|
|
||||||
const reloaded = loadGlobalConfig();
|
const reloaded = loadGlobalConfig();
|
||||||
expect(reloaded.anthropicApiKey).toBe('sk-ant-saved');
|
expect(reloaded.anthropicApiKey).toBe('sk-ant-saved');
|
||||||
expect(reloaded.openaiApiKey).toBe('sk-openai-saved');
|
expect(reloaded.openaiApiKey).toBe('sk-openai-saved');
|
||||||
|
expect(reloaded.cursorApiKey).toBe('cursor-saved');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not persist API keys when not set', () => {
|
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');
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
expect(content).not.toContain('anthropic_api_key');
|
expect(content).not.toContain('anthropic_api_key');
|
||||||
expect(content).not.toContain('openai_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();
|
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');
|
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', () => {
|
it('should accept opencode in ProjectConfigSchema', () => {
|
||||||
const result = ProjectConfigSchema.parse({ provider: 'opencode' });
|
const result = ProjectConfigSchema.parse({ provider: 'opencode' });
|
||||||
expect(result.provider).toBe('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', () => {
|
it('should accept concurrency in ProjectConfigSchema', () => {
|
||||||
const result = ProjectConfigSchema.parse({ concurrency: 3 });
|
const result = ProjectConfigSchema.parse({ concurrency: 3 });
|
||||||
expect(result.concurrency).toBe(3);
|
expect(result.concurrency).toBe(3);
|
||||||
@ -71,6 +83,14 @@ describe('Schemas accept opencode provider', () => {
|
|||||||
expect(result.provider).toBe('opencode');
|
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', () => {
|
it('should accept opencode in ParallelSubMovementRawSchema', () => {
|
||||||
const result = ParallelSubMovementRawSchema.parse({
|
const result = ParallelSubMovementRawSchema.parse({
|
||||||
name: 'sub-1',
|
name: 'sub-1',
|
||||||
@ -79,8 +99,16 @@ describe('Schemas accept opencode provider', () => {
|
|||||||
expect(result.provider).toBe('opencode');
|
expect(result.provider).toBe('opencode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should still accept existing providers (claude, codex, mock)', () => {
|
it('should accept cursor in ParallelSubMovementRawSchema', () => {
|
||||||
for (const provider of ['claude', 'codex', 'mock']) {
|
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 });
|
const result = GlobalConfigSchema.parse({ provider });
|
||||||
expect(result.provider).toBe(provider);
|
expect(result.provider).toBe(provider);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,6 +119,16 @@ describe('resolveMovementProviderModel', () => {
|
|||||||
expect(result.provider).toBe('claude');
|
expect(result.provider).toBe('claude');
|
||||||
expect(result.model).toBe('o3-mini');
|
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', () => {
|
describe('resolveAgentProviderModel', () => {
|
||||||
|
|||||||
@ -8,7 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
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) ---
|
// --- 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 }),
|
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;
|
abortSignal?: AbortSignal;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
||||||
stepModel?: string;
|
stepModel?: string;
|
||||||
stepProvider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
stepProvider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
||||||
personaPath?: string;
|
personaPath?: string;
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
mcpServers?: Record<string, McpServerConfig>;
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
|
|||||||
@ -46,7 +46,7 @@ program
|
|||||||
.option('--auto-pr', 'Create PR after successful execution')
|
.option('--auto-pr', 'Create PR after successful execution')
|
||||||
.option('--draft', 'Create PR as draft (requires --auto-pr or auto_pr config)')
|
.option('--draft', 'Create PR as draft (requires --auto-pr or auto_pr config)')
|
||||||
.option('--repo <owner/repo>', 'Repository (defaults to current)')
|
.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('--model <name>', 'Override agent model')
|
||||||
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
|
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
|
||||||
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')
|
.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';
|
import type { ProviderPermissionProfiles } from './provider-profiles.js';
|
||||||
|
|
||||||
export interface PersonaProviderEntry {
|
export interface PersonaProviderEntry {
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ export interface NotificationSoundEventsConfig {
|
|||||||
export interface PersistedGlobalConfig {
|
export interface PersistedGlobalConfig {
|
||||||
language: Language;
|
language: Language;
|
||||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
observability?: ObservabilityConfig;
|
observability?: ObservabilityConfig;
|
||||||
analytics?: AnalyticsConfig;
|
analytics?: AnalyticsConfig;
|
||||||
@ -92,6 +92,8 @@ export interface PersistedGlobalConfig {
|
|||||||
codexCliPath?: string;
|
codexCliPath?: string;
|
||||||
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
||||||
opencodeApiKey?: string;
|
opencodeApiKey?: string;
|
||||||
|
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
|
||||||
|
cursorApiKey?: string;
|
||||||
/** Pipeline execution settings */
|
/** Pipeline execution settings */
|
||||||
pipeline?: PipelineConfig;
|
pipeline?: PipelineConfig;
|
||||||
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
|
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
|
||||||
@ -133,7 +135,7 @@ export interface PersistedGlobalConfig {
|
|||||||
/** Project-level configuration */
|
/** Project-level configuration */
|
||||||
export interface ProjectConfig {
|
export interface ProjectConfig {
|
||||||
piece?: string;
|
piece?: string;
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
providerOptions?: MovementProviderOptions;
|
providerOptions?: MovementProviderOptions;
|
||||||
/** Provider-specific permission profiles */
|
/** Provider-specific permission profiles */
|
||||||
|
|||||||
@ -135,7 +135,7 @@ export interface PieceMovement {
|
|||||||
/** Resolved absolute path to persona prompt file (set by loader) */
|
/** Resolved absolute path to persona prompt file (set by loader) */
|
||||||
personaPath?: string;
|
personaPath?: string;
|
||||||
/** Provider override for this movement */
|
/** Provider override for this movement */
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
||||||
/** Model override for this movement */
|
/** Model override for this movement */
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Required minimum permission mode for tool execution in this movement */
|
/** Required minimum permission mode for tool execution in this movement */
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
import type { PermissionMode } from './status.js';
|
import type { PermissionMode } from './status.js';
|
||||||
|
|
||||||
/** Supported providers for profile-based permission resolution. */
|
/** 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. */
|
/** Permission profile for a single provider. */
|
||||||
export interface ProviderPermissionProfile {
|
export interface ProviderPermissionProfile {
|
||||||
|
|||||||
@ -79,7 +79,7 @@ export const MovementProviderOptionsSchema = z.object({
|
|||||||
}).optional();
|
}).optional();
|
||||||
|
|
||||||
/** Provider key schema for profile maps */
|
/** 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 */
|
/** Provider permission profile schema */
|
||||||
export const ProviderPermissionProfileSchema = z.object({
|
export const ProviderPermissionProfileSchema = z.object({
|
||||||
@ -92,6 +92,7 @@ export const ProviderPermissionProfilesSchema = z.object({
|
|||||||
claude: ProviderPermissionProfileSchema.optional(),
|
claude: ProviderPermissionProfileSchema.optional(),
|
||||||
codex: ProviderPermissionProfileSchema.optional(),
|
codex: ProviderPermissionProfileSchema.optional(),
|
||||||
opencode: ProviderPermissionProfileSchema.optional(),
|
opencode: ProviderPermissionProfileSchema.optional(),
|
||||||
|
cursor: ProviderPermissionProfileSchema.optional(),
|
||||||
mock: ProviderPermissionProfileSchema.optional(),
|
mock: ProviderPermissionProfileSchema.optional(),
|
||||||
}).optional();
|
}).optional();
|
||||||
|
|
||||||
@ -246,7 +247,7 @@ export const ParallelSubMovementRawSchema = z.object({
|
|||||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
allowed_tools: z.array(z.string()).optional(),
|
allowed_tools: z.array(z.string()).optional(),
|
||||||
mcp_servers: McpServersSchema,
|
mcp_servers: McpServersSchema,
|
||||||
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
/** Removed legacy field (no backward compatibility) */
|
/** Removed legacy field (no backward compatibility) */
|
||||||
permission_mode: z.never().optional(),
|
permission_mode: z.never().optional(),
|
||||||
@ -279,7 +280,7 @@ export const PieceMovementRawSchema = z.object({
|
|||||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
allowed_tools: z.array(z.string()).optional(),
|
allowed_tools: z.array(z.string()).optional(),
|
||||||
mcp_servers: McpServersSchema,
|
mcp_servers: McpServersSchema,
|
||||||
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
/** Removed legacy field (no backward compatibility) */
|
/** Removed legacy field (no backward compatibility) */
|
||||||
permission_mode: z.never().optional(),
|
permission_mode: z.never().optional(),
|
||||||
@ -368,7 +369,7 @@ export const PieceConfigRawSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const PersonaProviderEntrySchema = 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(),
|
model: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -424,7 +425,7 @@ export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfi
|
|||||||
export const GlobalConfigSchema = z.object({
|
export const GlobalConfigSchema = z.object({
|
||||||
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
||||||
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
|
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(),
|
model: z.string().optional(),
|
||||||
observability: ObservabilityConfigSchema.optional(),
|
observability: ObservabilityConfigSchema.optional(),
|
||||||
analytics: AnalyticsConfigSchema.optional(),
|
analytics: AnalyticsConfigSchema.optional(),
|
||||||
@ -446,6 +447,8 @@ export const GlobalConfigSchema = z.object({
|
|||||||
codex_cli_path: z.string().optional(),
|
codex_cli_path: z.string().optional(),
|
||||||
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
||||||
opencode_api_key: z.string().optional(),
|
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 execution settings */
|
||||||
pipeline: PipelineConfigSchema.optional(),
|
pipeline: PipelineConfigSchema.optional(),
|
||||||
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
|
/** 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(),
|
piece_categories_file: z.string().optional(),
|
||||||
/** Per-persona provider and model overrides. */
|
/** Per-persona provider and model overrides. */
|
||||||
persona_providers: z.record(z.string(), z.union([
|
persona_providers: z.record(z.string(), z.union([
|
||||||
z.enum(['claude', 'codex', 'opencode', 'mock']),
|
z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']),
|
||||||
PersonaProviderEntrySchema,
|
PersonaProviderEntrySchema,
|
||||||
])).optional(),
|
])).optional(),
|
||||||
/** Global provider-specific options (lowest priority) */
|
/** Global provider-specific options (lowest priority) */
|
||||||
@ -496,7 +499,7 @@ export const GlobalConfigSchema = z.object({
|
|||||||
/** Project config schema */
|
/** Project config schema */
|
||||||
export const ProjectConfigSchema = z.object({
|
export const ProjectConfigSchema = z.object({
|
||||||
piece: z.string().optional(),
|
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(),
|
model: z.string().optional(),
|
||||||
provider_options: MovementProviderOptionsSchema,
|
provider_options: MovementProviderOptionsSchema,
|
||||||
provider_profiles: ProviderPermissionProfilesSchema,
|
provider_profiles: ProviderPermissionProfilesSchema,
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export interface DecomposeTaskOptions {
|
|||||||
personaPath?: string;
|
personaPath?: string;
|
||||||
language?: Language;
|
language?: Language;
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MorePartsResponse {
|
export interface MorePartsResponse {
|
||||||
|
|||||||
@ -186,6 +186,11 @@ export class MovementExecutor {
|
|||||||
updatePersonaSession: (persona: string, sessionId: string | undefined) => void,
|
updatePersonaSession: (persona: string, sessionId: string | undefined) => void,
|
||||||
): Promise<AgentResponse> {
|
): Promise<AgentResponse> {
|
||||||
let nextResponse = response;
|
let nextResponse = response;
|
||||||
|
|
||||||
|
if (nextResponse.status === 'error' || nextResponse.status === 'blocked') {
|
||||||
|
return nextResponse;
|
||||||
|
}
|
||||||
|
|
||||||
const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(
|
const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(
|
||||||
state,
|
state,
|
||||||
nextResponse.content,
|
nextResponse.content,
|
||||||
@ -195,7 +200,8 @@ export class MovementExecutor {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Phase 2: report output (resume same session, Write only)
|
// 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);
|
const reportResult = await runReportPhase(step, movementIteration, phaseCtx);
|
||||||
if (reportResult?.blocked) {
|
if (reportResult?.blocked) {
|
||||||
nextResponse = { ...nextResponse, status: 'blocked', content: reportResult.response.content };
|
nextResponse = { ...nextResponse, status: 'blocked', content: reportResult.response.content };
|
||||||
@ -275,6 +281,23 @@ export class MovementExecutor {
|
|||||||
let response = await executeAgent(step.persona, instruction, agentOptions);
|
let response = await executeAgent(step.persona, instruction, agentOptions);
|
||||||
updatePersonaSession(sessionKey, response.sessionId);
|
updatePersonaSession(sessionKey, response.sessionId);
|
||||||
this.deps.onPhaseComplete?.(step, 1, 'execute', response.content, response.status, response.error);
|
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(
|
response = await this.applyPostExecutionPhases(
|
||||||
step,
|
step,
|
||||||
state,
|
state,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export const DEFAULT_PROVIDER_PERMISSION_PROFILES: ProviderPermissionProfiles =
|
|||||||
claude: { defaultPermissionMode: 'edit' },
|
claude: { defaultPermissionMode: 'edit' },
|
||||||
codex: { defaultPermissionMode: 'edit' },
|
codex: { defaultPermissionMode: 'edit' },
|
||||||
opencode: { defaultPermissionMode: 'edit' },
|
opencode: { defaultPermissionMode: 'edit' },
|
||||||
|
cursor: { defaultPermissionMode: 'edit' },
|
||||||
mock: { 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 { ProviderPermissionProfiles } from '../models/provider-profiles.js';
|
||||||
import type { MovementProviderOptions } from '../models/piece-types.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 type ProviderOptionsSource = 'env' | 'project' | 'global' | 'default';
|
||||||
|
|
||||||
export interface StreamInitEventData {
|
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: 'openai_api_key', type: 'string' },
|
||||||
{ path: 'codex_cli_path', type: 'string' },
|
{ path: 'codex_cli_path', type: 'string' },
|
||||||
{ path: 'opencode_api_key', type: 'string' },
|
{ path: 'opencode_api_key', type: 'string' },
|
||||||
|
{ path: 'cursor_api_key', type: 'string' },
|
||||||
{ path: 'pipeline', type: 'json' },
|
{ path: 'pipeline', type: 'json' },
|
||||||
{ path: 'pipeline.default_branch_prefix', type: 'string' },
|
{ path: 'pipeline.default_branch_prefix', type: 'string' },
|
||||||
{ path: 'pipeline.commit_message_template', type: 'string' },
|
{ path: 'pipeline.commit_message_template', type: 'string' },
|
||||||
|
|||||||
@ -192,6 +192,7 @@ export class GlobalConfigManager {
|
|||||||
openaiApiKey: parsed.openai_api_key,
|
openaiApiKey: parsed.openai_api_key,
|
||||||
codexCliPath: parsed.codex_cli_path,
|
codexCliPath: parsed.codex_cli_path,
|
||||||
opencodeApiKey: parsed.opencode_api_key,
|
opencodeApiKey: parsed.opencode_api_key,
|
||||||
|
cursorApiKey: parsed.cursor_api_key,
|
||||||
pipeline: parsed.pipeline ? {
|
pipeline: parsed.pipeline ? {
|
||||||
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
|
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
|
||||||
commitMessageTemplate: parsed.pipeline.commit_message_template,
|
commitMessageTemplate: parsed.pipeline.commit_message_template,
|
||||||
@ -280,6 +281,9 @@ export class GlobalConfigManager {
|
|||||||
if (config.opencodeApiKey) {
|
if (config.opencodeApiKey) {
|
||||||
raw.opencode_api_key = config.opencodeApiKey;
|
raw.opencode_api_key = config.opencodeApiKey;
|
||||||
}
|
}
|
||||||
|
if (config.cursorApiKey) {
|
||||||
|
raw.cursor_api_key = config.cursorApiKey;
|
||||||
|
}
|
||||||
if (config.pipeline) {
|
if (config.pipeline) {
|
||||||
const pipelineRaw: Record<string, unknown> = {};
|
const pipelineRaw: Record<string, unknown> = {};
|
||||||
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
|
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
|
||||||
@ -410,7 +414,7 @@ export function setLanguage(language: Language): void {
|
|||||||
saveGlobalConfig(config);
|
saveGlobalConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setProvider(provider: 'claude' | 'codex' | 'opencode'): void {
|
export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor'): void {
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
config.provider = provider;
|
config.provider = provider;
|
||||||
saveGlobalConfig(config);
|
saveGlobalConfig(config);
|
||||||
@ -485,3 +489,19 @@ export function resolveOpencodeApiKey(): string | undefined {
|
|||||||
return 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,
|
resolveOpenaiApiKey,
|
||||||
resolveCodexCliPath,
|
resolveCodexCliPath,
|
||||||
resolveOpencodeApiKey,
|
resolveOpencodeApiKey,
|
||||||
|
resolveCursorApiKey,
|
||||||
} from './globalConfig.js';
|
} from './globalConfig.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -56,11 +56,12 @@ export async function promptLanguageSelection(): Promise<Language> {
|
|||||||
* Prompt user to select provider for resources.
|
* Prompt user to select provider for resources.
|
||||||
* Exits process if cancelled (initial setup is required).
|
* Exits process if cancelled (initial setup is required).
|
||||||
*/
|
*/
|
||||||
export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode'> {
|
export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode' | 'cursor'> {
|
||||||
const options: { label: string; value: 'claude' | 'codex' | 'opencode' }[] = [
|
const options: { label: string; value: 'claude' | 'codex' | 'opencode' | 'cursor' }[] = [
|
||||||
{ label: 'Claude Code', value: 'claude' },
|
{ label: 'Claude Code', value: 'claude' },
|
||||||
{ label: 'Codex', value: 'codex' },
|
{ label: 'Codex', value: 'codex' },
|
||||||
{ label: 'OpenCode', value: 'opencode' },
|
{ label: 'OpenCode', value: 'opencode' },
|
||||||
|
{ label: 'Cursor Agent', value: 'cursor' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await selectOptionWithDefault(
|
const result = await selectOptionWithDefault(
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export interface ProjectLocalConfig {
|
|||||||
/** Current piece name */
|
/** Current piece name */
|
||||||
piece?: string;
|
piece?: string;
|
||||||
/** Provider selection for agent runtime */
|
/** Provider selection for agent runtime */
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
||||||
/** Model selection for agent runtime */
|
/** Model selection for agent runtime */
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Auto-create PR after worktree execution */
|
/** 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
|
* 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.
|
* This enables adding new providers without modifying the runner logic.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ClaudeProvider } from './claude.js';
|
import { ClaudeProvider } from './claude.js';
|
||||||
import { CodexProvider } from './codex.js';
|
import { CodexProvider } from './codex.js';
|
||||||
import { OpenCodeProvider } from './opencode.js';
|
import { OpenCodeProvider } from './opencode.js';
|
||||||
|
import { CursorProvider } from './cursor.js';
|
||||||
import { MockProvider } from './mock.js';
|
import { MockProvider } from './mock.js';
|
||||||
import type { Provider, ProviderType } from './types.js';
|
import type { Provider, ProviderType } from './types.js';
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ export class ProviderRegistry {
|
|||||||
claude: new ClaudeProvider(),
|
claude: new ClaudeProvider(),
|
||||||
codex: new CodexProvider(),
|
codex: new CodexProvider(),
|
||||||
opencode: new OpenCodeProvider(),
|
opencode: new OpenCodeProvider(),
|
||||||
|
cursor: new CursorProvider(),
|
||||||
mock: new MockProvider(),
|
mock: new MockProvider(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -56,4 +58,3 @@ export class ProviderRegistry {
|
|||||||
export function getProvider(type: ProviderType): Provider {
|
export function getProvider(type: ProviderType): Provider {
|
||||||
return ProviderRegistry.getInstance().get(type);
|
return ProviderRegistry.getInstance().get(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,7 @@ export interface ProviderCallOptions {
|
|||||||
anthropicApiKey?: string;
|
anthropicApiKey?: string;
|
||||||
openaiApiKey?: string;
|
openaiApiKey?: string;
|
||||||
opencodeApiKey?: string;
|
opencodeApiKey?: string;
|
||||||
|
cursorApiKey?: string;
|
||||||
outputSchema?: Record<string, unknown>;
|
outputSchema?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,4 +50,4 @@ export interface Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Provider type */
|
/** 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