diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a8aaa564..5d33fa0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ -# CLAUDE.md +# Agent Guidelines -AI-facing guidance for Claude Code when working with this repository. +AI-facing guidance for agent tooling when working with this repository. ## Critical Constraints (NEVER VIOLATE) @@ -17,7 +17,7 @@ Tests set `process.env.CCS_HOME` to a temp directory. Code using `os.homedir()` ## Core Function -CLI wrapper for instant switching between multiple Claude accounts and alternative models (GLM, GLMT, Kimi). See README.md for user documentation. +CLI wrapper for instant switching between multiple provider accounts and alternative models (GLM, GLMT, Kimi). See README.md for user documentation. ## Design Principles (ENFORCE STRICTLY) @@ -113,7 +113,7 @@ bun run validate # Step 3: Final check (must pass) - **Scope:** CCS CLI terminal output (`src/` code that prints to stdout/stderr) - **Does NOT apply to:** PR descriptions, commit messages, documentation, comments, AI conversations 2. **TTY-aware colors** - Respect NO_COLOR env var -3. **Non-invasive** - NEVER modify `~/.claude/settings.json` without explicit user request and confirmation (exception: `ccs persist` command) +3. **Non-invasive** - NEVER modify external tool settings (`~/.claude/settings.json`) without explicit user request and confirmation (exception: `ccs persist` command) 4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically 5. **CLI documentation** - ALL CLI changes MUST update respective `--help` handler (see table below) 6. **Idempotent** - All install operations safe to run multiple times @@ -129,6 +129,7 @@ bun run validate # Step 3: Final check (must pass) | `ccs cliproxy --help` | `src/commands/cliproxy-command.ts` → `showHelp()` | | `ccs config --help` | `src/commands/config-command.ts` → `showHelp()` | | `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` | +| `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` | | `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` | | `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` | | `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` | diff --git a/README.md b/README.md index 31d4cc0b..b6a62ffe 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Want to run the dashboard in Docker? See `docker/README.md`. The dashboard provides visual management for all account types: - **Claude Accounts**: Create isolated instances (work, personal, client) -- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity +- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **API Profiles**: Configure GLM, Kimi with your keys - **Health Monitor**: Real-time status across all profiles @@ -92,7 +92,8 @@ The dashboard provides visual management for all account types: | **Gemini** | OAuth | `ccs gemini` | Zero-config, fast iteration | | **Codex** | OAuth | `ccs codex` | Code generation | | **Copilot** | OAuth | `ccs copilot` or `ccs ghcp` | GitHub Copilot models | -| **Kiro** | OAuth | `ccs kiro` | AWS CodeWhisperer (Claude-powered) | +| **Cursor IDE** | Local Token | `ccs cursor` | Cursor subscription models via local daemon | +| **Kiro** | OAuth (AWS default) | `ccs kiro` | AWS CodeWhisperer (Claude-powered) | | **Antigravity** | OAuth | `ccs agy` | Alternative routing | | **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API | | **Ollama** | Local | `ccs ollama` | Local open-source models, privacy | @@ -132,6 +133,7 @@ The dashboard provides visual management for all account types: ccs # Default Claude session ccs gemini # Gemini (OAuth) ccs codex # OpenAI Codex (OAuth) +ccs cursor # Cursor IDE integration (token import + local daemon) ccs kiro # Kiro/AWS CodeWhisperer (OAuth) ccs ghcp # GitHub Copilot (OAuth device flow) ccs agy # Antigravity (OAuth) @@ -139,6 +141,41 @@ ccs ollama # Local Ollama (no API key needed) ccs glm # GLM (API key) ``` +### Kiro Auth Methods + +`ccs kiro --auth` defaults to AWS Builder ID Device OAuth (best support for AWS org accounts). + +```bash +ccs kiro --auth --kiro-auth-method aws # AWS Builder ID device code (default) +ccs kiro --auth --kiro-auth-method aws-authcode # AWS Builder ID auth code +ccs kiro --auth --kiro-auth-method google # Google OAuth +ccs kiro --auth --kiro-auth-method github # Dashboard management OAuth flow +``` + +Dashboard parity: `ccs config` -> Accounts -> Add Kiro account -> choose `Auth Method`. + +### Cursor IDE Quick Start + +```bash +ccs cursor enable +ccs cursor auth +ccs cursor start +ccs cursor status +``` + +If auto-detect is unavailable: + +```bash +ccs cursor auth --manual --token --machine-id +``` + +Defaults: +- Port: `20129` +- Ghost mode: enabled +- Dashboard page: `ccs config` -> `Cursor IDE` + +Detailed guide: [`docs/cursor-integration.md`](./docs/cursor-integration.md) + ### Parallel Workflows Run multiple terminals with different providers: @@ -205,6 +242,15 @@ ccs cliproxy doctor # Check quota status for all agy accounts **Auto-Failover**: When an Antigravity account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota). +### CLIProxy Lifecycle + +```bash +ccs cliproxy start # Start CLIProxy background service +ccs cliproxy status # Check running status +ccs cliproxy restart # Restart CLIProxy service +ccs cliproxy stop # Stop running CLIProxy service +``` +
## Configuration @@ -325,6 +371,7 @@ See [Remote Proxy documentation](https://docs.ccs.kaitran.ca/features/remote-pro | Multi-Account Claude | [docs.ccs.kaitran.ca/providers/claude-accounts](https://docs.ccs.kaitran.ca/providers/claude-accounts) | | API Profiles | [docs.ccs.kaitran.ca/providers/api-profiles](https://docs.ccs.kaitran.ca/providers/api-profiles) | | Remote Proxy | [docs.ccs.kaitran.ca/features/remote-proxy](https://docs.ccs.kaitran.ca/features/remote-proxy) | +| Cursor IDE (local guide) | [./docs/cursor-integration.md](./docs/cursor-integration.md) | | CLI Reference | [docs.ccs.kaitran.ca/reference/cli-commands](https://docs.ccs.kaitran.ca/reference/cli-commands) | | Architecture | [docs.ccs.kaitran.ca/reference/architecture](https://docs.ccs.kaitran.ca/reference/architecture) | | Troubleshooting | [docs.ccs.kaitran.ca/reference/troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) | diff --git a/config/base-codex.settings.json b/config/base-codex.settings.json index cf2915ad..dcc10763 100644 --- a/config/base-codex.settings.json +++ b/config/base-codex.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "gpt-5.1-codex-max", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.1-codex-max-high", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.1-codex-max", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.1-codex-mini-high" + "ANTHROPIC_MODEL": "gpt-5.3-codex-xhigh", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.3-codex-xhigh", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.3-codex-high", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-mini-medium" } } diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md new file mode 100644 index 00000000..6997dfd3 --- /dev/null +++ b/docs/cursor-integration.md @@ -0,0 +1,107 @@ +# Cursor IDE Integration + +This guide covers the local Cursor integration in CCS, including CLI setup, daemon lifecycle, and dashboard controls. + +## What It Provides + +- OpenAI-compatible local endpoint powered by Cursor credentials. +- Cursor model list and chat completions via local daemon. +- Dedicated dashboard page: `ccs config` -> `Cursor IDE`. + +## Prerequisites + +- Cursor IDE installed and logged in. +- CCS installed and configured (`ccs config` works). +- For auto-detect auth on macOS/Linux: `sqlite3` available in PATH. + +## CLI Workflow + +### 1) Enable integration + +```bash +ccs cursor enable +``` + +### 2) Import credentials + +Auto-detect from Cursor local SQLite state: + +```bash +ccs cursor auth +``` + +Manual fallback: + +```bash +ccs cursor auth --manual --token --machine-id +``` + +### 3) Start daemon + +```bash +ccs cursor start +``` + +### 4) Verify status + +```bash +ccs cursor status +``` + +### 5) Stop daemon + +```bash +ccs cursor stop +``` + +## Runtime Defaults + +- Default port: `20129` +- `ghost_mode`: enabled +- `auto_start`: disabled + +These values are managed in unified config and can be updated from CLI or dashboard. + +## Dashboard Usage + +Open dashboard: + +```bash +ccs config +``` + +Then navigate to `Cursor IDE` in the sidebar. + +Available controls: + +- Integration toggle (`enabled`) +- Auth actions (auto-detect, manual import) +- Daemon actions (start/stop) +- Runtime config (port, auto-start, ghost mode) +- Models list +- Raw editor for `~/.ccs/cursor.settings.json` + +## Raw Settings and Unified Config Sync + +Raw settings are stored in: + +`~/.ccs/cursor.settings.json` + +When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchronizes that port back into unified config so CLI and dashboard remain consistent. + +## Troubleshooting + +### `Not authenticated` or `expired` in `ccs cursor status` + +- Re-run `ccs cursor auth` (or manual auth command). + +### Auto-detect fails + +- Ensure Cursor is logged in. +- Confirm `sqlite3` is installed (macOS/Linux). +- Use manual auth import if needed. + +### Daemon fails to start + +- Check if port `20129` is in use. +- Change port in dashboard config tab, then retry `ccs cursor start`. diff --git a/docs/hardening-debt-burndown.md b/docs/hardening-debt-burndown.md new file mode 100644 index 00000000..9a10e352 --- /dev/null +++ b/docs/hardening-debt-burndown.md @@ -0,0 +1,45 @@ +# Hardening Debt Burndown Tracker + +Last Updated: 2026-02-12 +Owner: Stream D (`#542`) + +## Scope + +Maintainability hardening groundwork with low-risk changes: + +- Inventory legacy shims/compatibility markers +- Inventory sync filesystem usage, especially runtime hotpaths +- Incrementally migrate hotpath sync I/O to async I/O with tests + +## How to Measure + +Run: + +```bash +bun run report:hardening +``` + +Generated artifacts: + +- `docs/reports/hardening-inventory.json` +- `docs/reports/hardening-inventory.md` + +## Kickoff Baseline (Issue #542 Stream D) + +The current baseline is sourced from `docs/reports/hardening-inventory.json` after running `bun run report:hardening`. +Baseline captured: `2026-02-12`. + +| Metric | Baseline | +|---|---:| +| Sync fs occurrences (all) | 835 | +| Sync fs files affected (all) | 100 | +| Sync fs occurrences (runtime hotpaths) | 724 | +| Sync fs files affected (runtime hotpaths) | 89 | +| Legacy shim markers | 131 | +| Legacy shim files affected | 56 | + +## Initial Async I/O Migration Log + +| Date | Area | Change | Safety Notes | +|---|---|---|---| +| 2026-02-12 | `src/web-server/jsonl-parser.ts` | Migrated `parseProjectDirectory()` directory listing from sync `readdirSync` to async `fs.promises.readdir` | Existing behavior kept (same filtering/fallback); covered by `tests/unit/jsonl-parser.test.ts` | diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json new file mode 100644 index 00000000..cb0c20a0 --- /dev/null +++ b/docs/metrics/maintainability-baseline.json @@ -0,0 +1,9 @@ +{ + "sourceDirectory": "src", + "largeFileThresholdLoc": 350, + "typeScriptFileCount": 341, + "locInSrc": 67708, + "processExitReferenceCount": 164, + "synchronousFsApiReferenceCount": 850, + "largeFileCountOver350Loc": 55 +} diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 7f257d11..07598a41 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-02-04 +Last Updated: 2026-02-12 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -39,6 +39,15 @@ All major modularization work is complete. The codebase evolved from monolithic ## Current Status +### Maintainability Hardening Kickoff + +- Issue owner: Stream D for **#542** +- Automated inventory command: `bun run report:hardening` +- Generated report artifacts: + - `docs/reports/hardening-inventory.json` + - `docs/reports/hardening-inventory.md` +- Debt burndown tracker: [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md) + ### Remaining Large Files (Acceptable) **CLI** (complex core logic): @@ -191,6 +200,23 @@ All criteria achieved: - [x] Clear domain boundaries - [x] Consistent naming conventions +## Maintainability Gate (Issue #539 Foundation) + +- Baseline metrics artifact: `docs/metrics/maintainability-baseline.json` +- Generate or refresh baseline: + - `bun run maintainability:baseline` + - `npm run maintainability:baseline` +- Run regression check gate: + - `bun run maintainability:check` + - `npm run maintainability:check` + +The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable. + +The check mode supports a maintainability regression gate that blocks increases in: +- `process.exit` references +- synchronous fs API references +- TypeScript files over 350 LOC + --- ## Related Documentation @@ -198,4 +224,5 @@ All criteria achieved: - [Codebase Summary](./codebase-summary.md) - Current structure - [Code Standards](./code-standards.md) - Patterns and conventions - [System Architecture](./system-architecture.md) - Architecture diagrams +- [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md) - Legacy shim + sync-fs debt tracking - [CLAUDE.md](../CLAUDE.md) - AI development guidance diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json new file mode 100644 index 00000000..2777c900 --- /dev/null +++ b/docs/reports/hardening-inventory.json @@ -0,0 +1,470 @@ +{ + "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", + "syncFs": { + "totalOccurrences": 835, + "filesAffected": 100, + "hotpathOccurrences": 724, + "hotpathFilesAffected": 89, + "topHotpathFiles": [ + { + "file": "src/management/shared-manager.ts", + "count": 60, + "calls": [ + "copyFileSync", + "cpSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "readlinkSync", + "rmSync", + "statSync", + "symlinkSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/utils/claude-symlink-manager.ts", + "count": 27, + "calls": [ + "copyFileSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "readlinkSync", + "renameSync", + "rmSync", + "statSync", + "symlinkSync", + "unlinkSync" + ], + "markers": [] + }, + { + "file": "src/utils/shell-completion.ts", + "count": 23, + "calls": [ + "appendFileSync", + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "statSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/settings-routes.ts", + "count": 23, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "renameSync", + "statSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/utils/claude-dir-installer.ts", + "count": 21, + "calls": [ + "copyFileSync", + "cpSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "renameSync", + "rmSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/cliproxy/binary/version-cache.ts", + "count": 20, + "calls": [ + "existsSync", + "mkdirSync", + "readFileSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/management/recovery-manager.ts", + "count": 20, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "renameSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/cliproxy-stats-routes.ts", + "count": 20, + "calls": [ + "closeSync", + "existsSync", + "fstatSync", + "mkdirSync", + "openSync", + "readdirSync", + "readFileSync", + "readSync", + "renameSync", + "statSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/misc-routes.ts", + "count": 20, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "renameSync", + "statSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/persist-routes.ts", + "count": 17, + "calls": [ + "closeSync", + "copyFileSync", + "existsSync", + "lstatSync", + "openSync", + "readdirSync", + "readSync", + "renameSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + } + ], + "topFilesOverall": [ + { + "file": "src/management/shared-manager.ts", + "count": 60, + "calls": [ + "copyFileSync", + "cpSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "readlinkSync", + "rmSync", + "statSync", + "symlinkSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/config/migration-manager.ts", + "count": 27, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "renameSync", + "rmdirSync", + "unlinkSync" + ], + "markers": [] + }, + { + "file": "src/utils/claude-symlink-manager.ts", + "count": 27, + "calls": [ + "copyFileSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "readlinkSync", + "renameSync", + "rmSync", + "statSync", + "symlinkSync", + "unlinkSync" + ], + "markers": [] + }, + { + "file": "src/utils/shell-completion.ts", + "count": 23, + "calls": [ + "appendFileSync", + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "statSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/settings-routes.ts", + "count": 23, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "renameSync", + "statSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/utils/claude-dir-installer.ts", + "count": 21, + "calls": [ + "copyFileSync", + "cpSync", + "existsSync", + "lstatSync", + "mkdirSync", + "readdirSync", + "renameSync", + "rmSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/cliproxy/binary/version-cache.ts", + "count": 20, + "calls": [ + "existsSync", + "mkdirSync", + "readFileSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/copilot/copilot-package-manager.ts", + "count": 20, + "calls": [ + "existsSync", + "mkdirSync", + "readFileSync", + "rmSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/management/recovery-manager.ts", + "count": 20, + "calls": [ + "copyFileSync", + "existsSync", + "mkdirSync", + "renameSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/web-server/routes/cliproxy-stats-routes.ts", + "count": 20, + "calls": [ + "closeSync", + "existsSync", + "fstatSync", + "mkdirSync", + "openSync", + "readdirSync", + "readFileSync", + "readSync", + "renameSync", + "statSync", + "writeFileSync" + ], + "markers": [] + } + ] + }, + "legacyShim": { + "totalMarkers": 131, + "filesAffected": 56, + "topFiles": [ + { + "file": "src/utils/config-manager.ts", + "count": 13, + "calls": [], + "markers": [ + "* Get config file path (legacy JSON path)", + "* Precedence: --config-dir flag > CCS_DIR env > CCS_HOME env (legacy, appends .ccs) > ~/.ccs default", + "* Read and parse config (legacy compatibility)", + "* Returns Config with profiles from unified config.yaml or legacy config.json.", + "* Returns config.yaml in unified mode, config.json in legacy mode.", + "* then falls back to config.json for backward compatibility.", + "// Convert unified cliproxy variants to legacy format", + "// Convert unified profiles to legacy format for compatibility", + "// If not found in unified config, try legacy config.json as fallback", + "// Legacy config is invalid JSON - that's OK in unified mode", + "// Legacy mode - read from config.json only", + "// Legacy mode: read config.json", + "// Merge legacy profiles into available list (avoid duplicates)" + ] + }, + { + "file": "src/auth/profile-detector.ts", + "count": 11, + "calls": [], + "markers": [ + "* - Legacy JSON format (config.json, profiles.json) as fallback", + "* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]", + "* 3. Settings-based profiles (config.profiles section) [legacy]", + "* 4. Account-based profiles (profiles.json) [legacy]", + "* Priority: settings-based profiles (glm/kimi) checked FIRST for backward compatibility.", + "// Check if account-based default exists (legacy)", + "// Fall back to legacy config", + "// Fall back to legacy config display", + "// Fall through to legacy if not found in unified config", + "// Priority 3: Check settings-based profiles (glm, kimi) - LEGACY FALLBACK", + "// Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK" + ] + }, + { + "file": "src/config/unified-config-loader.ts", + "count": 9, + "calls": [], + "markers": [ + "* 'json' if only legacy config exists,", + "* Check if legacy config.json exists", + "* Get path to legacy config.json", + "* Provides fallback to legacy JSON format for backward compatibility.", + "// Legacy field for backwards compatibility", + "// Legacy fields (deprecated)", + "// Legacy fields (keep for backwards compatibility during read)", + "partial.websearch?.gemini?.enabled ?? // Legacy fallback", + "partial.websearch?.gemini?.timeout ?? // Legacy fallback" + ] + }, + { + "file": "src/commands/setup-command.ts", + "count": 7, + "calls": [], + "markers": [ + "* 3. Legacy config.json profiles (GLM, Kimi)", + "* 4. Legacy profiles.json accounts", + "// Also check legacy config.json for existing profiles", + "// Has legacy accounts - NOT first time", + "// Has legacy profiles - NOT first time", + "// Legacy config exists but is invalid - ignore and continue", + "// Legacy profiles exists but is invalid - ignore and continue" + ] + }, + { + "file": "src/management/checks/config-check.ts", + "count": 6, + "calls": [], + "markers": [ + "* - Prefers config.yaml (v2) over config.json (legacy)", + "// Fallback to config.json (legacy format)", + "// Inform if legacy config.json also exists (purely informational, not a check)", + "console.log(` ${info('config.json'.padEnd(22))} Legacy (ignored)`);", + "console.log(` ${ok('config.json'.padEnd(22))} Valid (legacy)`);", + "info: 'Valid (legacy)'," + ] + }, + { + "file": "src/web-server/routes/account-routes.ts", + "count": 6, + "calls": [], + "markers": [ + "* Uses ProfileRegistry to read from both legacy (profiles.json)", + "// Add legacy profiles first", + "// Delete from appropriate config (unified and/or legacy)", + "// Get default from unified config first, fallback to legacy", + "// Get profiles from both legacy and unified config (same logic as CLI)", + "// Use unified config if in unified mode, otherwise use legacy" + ] + }, + { + "file": "src/config/migration-manager.ts", + "count": 5, + "calls": [], + "markers": [ + "* Check if there are legacy profiles that haven't been migrated to config.yaml.", + "* Handles migration from legacy JSON config (v1) to unified YAML config (v2).", + "`Profile \"${name}\" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy (${pathStr})`", + "console.log(infoBox('Migrated legacy profiles to config.yaml', 'SUCCESS'));", + "console.log(infoBox('Migration failed - using legacy config', 'WARNING'));" + ] + }, + { + "file": "src/api/services/profile-writer.ts", + "count": 4, + "calls": [], + "markers": [ + "* Supports both unified YAML config and legacy JSON config.", + "/** Create settings.json file for API profile (legacy format) */", + "/** Remove API profile from legacy config */", + "/** Update config.json with new API profile (legacy format) */" + ] + }, + { + "file": "src/cliproxy/quota-fetcher-gemini-cli.ts", + "count": 4, + "calls": [], + "markers": [ + "// Legacy pattern: gemini-email.json", + "// Must match account AND be gemini type (or legacy gemini- prefix)", + "// Try exact legacy match first", + "`gemini-${sanitizedId}.json`, // Legacy format" + ] + }, + { + "file": "src/auth/profile-registry.ts", + "count": 3, + "calls": [], + "markers": [ + "* Get all profiles merged from both legacy and unified config.", + "* Get resolved default profile from unified config first, fallback to legacy.", + "// Start with legacy profiles" + ] + } + ], + "explicitShimFiles": [ + "src/cliproxy/openai-compat-manager.ts" + ] + } +} diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md new file mode 100644 index 00000000..69a86f89 --- /dev/null +++ b/docs/reports/hardening-inventory.md @@ -0,0 +1,48 @@ +# Hardening Inventory Report + +Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` + +## Summary + +| Metric | Value | +|---|---:| +| Sync fs occurrences (all) | 835 | +| Sync fs files affected (all) | 100 | +| Sync fs occurrences (runtime hotpaths) | 724 | +| Sync fs files affected (runtime hotpaths) | 89 | +| Legacy shim markers | 131 | +| Legacy shim files affected | 56 | + +## Top Runtime Hotpath Sync fs Files + +| File | Sync Calls | API Names | +|---|---:|---| +| `src/management/shared-manager.ts` | 60 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync | +| `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync | +| `src/utils/shell-completion.ts` | 23 | appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync | +| `src/web-server/routes/settings-routes.ts` | 23 | copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync | +| `src/utils/claude-dir-installer.ts` | 21 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync | +| `src/cliproxy/binary/version-cache.ts` | 20 | existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync | +| `src/management/recovery-manager.ts` | 20 | copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync | +| `src/web-server/routes/cliproxy-stats-routes.ts` | 20 | closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync | +| `src/web-server/routes/misc-routes.ts` | 20 | copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync | +| `src/web-server/routes/persist-routes.ts` | 17 | closeSync, copyFileSync, existsSync, lstatSync, openSync, readdirSync, readSync, renameSync, unlinkSync, writeFileSync | + +## Top Legacy Shim Marker Files + +| File | Marker Count | +|---|---:| +| `src/utils/config-manager.ts` | 13 | +| `src/auth/profile-detector.ts` | 11 | +| `src/config/unified-config-loader.ts` | 9 | +| `src/commands/setup-command.ts` | 7 | +| `src/management/checks/config-check.ts` | 6 | +| `src/web-server/routes/account-routes.ts` | 6 | +| `src/config/migration-manager.ts` | 5 | +| `src/api/services/profile-writer.ts` | 4 | +| `src/cliproxy/quota-fetcher-gemini-cli.ts` | 4 | +| `src/auth/profile-registry.ts` | 3 | + +## Explicit Shim/Re-export Files + +- `src/cliproxy/openai-compat-manager.ts` diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 10b55a2a..96726327 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -86,7 +86,7 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi v 1. CLIProxy Hardcoded ----+---> gemini, codex, agy, kiro, ghcp (OAuth-based) | Zero-config OAuth providers - | (kiro: Auth Code, ghcp: Device Code) + | (kiro: method-aware, ghcp: Device Code) | 2. CLIProxy Variants -----+---> config.cliproxy section (User-defined) | Custom provider configurations @@ -570,10 +570,10 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi | Authentication Flow | +===========================================================================+ - OAuth Providers - Authorization Code Flow (Gemini, Codex, AGY, Kiro) - -------------------------------------------------------------------- + OAuth Providers - Authorization Code Flow (Gemini, Codex, AGY) + -------------------------------------------------------------- - 1. User runs: ccs gemini (or ccs kiro) + 1. User runs: ccs gemini | v 2. Check token cache (~/.ccs/cliproxy/auth/) @@ -584,7 +584,6 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi | v 3. Open browser for OAuth (localhost:PORT callback) - | - Kiro uses port 9876 v 4. Callback with auth code | @@ -595,6 +594,21 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi 6. Cache token locally + Kiro OAuth - Method-Aware Flow (CLI + Dashboard parity) + ------------------------------------------------------- + + Supported methods: + - aws: Device Code (default, AWS org friendly) + - aws-authcode: Authorization Code via CLI flow + - google: Social OAuth via management API + - github: Social OAuth via management API (Dashboard flow) + + Key behavior: + - Device Code method uses /start route (no callback port) + - Callback/social methods use /start-url + status polling + - Some management flows return state first, auth_url later + + OAuth Providers - Device Code Flow (GitHub Copilot/ghcp) -------------------------------------------------------- diff --git a/package.json b/package.json index c9520ad7..5919a029 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.43.0", + "version": "7.43.0-dev.20", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", @@ -64,8 +64,10 @@ "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", - "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test:all", + "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run maintainability:check && bun run test:all", "verify:bundle": "node scripts/verify-bundle.js", + "maintainability:baseline": "node scripts/maintainability-baseline.js --out docs/metrics/maintainability-baseline.json", + "maintainability:check": "node scripts/maintainability-baseline.js --check docs/metrics/maintainability-baseline.json", "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", "test:all": "bun test tests/unit tests/integration tests/npm", @@ -73,6 +75,7 @@ "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", "test:e2e": "bun test tests/e2e/ --bail --timeout 60000", + "report:hardening": "node scripts/hardening-inventory.js", "dev": "bun run build:server && bun dist/ccs.js config --dev", "dev:symlink": "bash scripts/dev-symlink.sh", "dev:unlink": "bash scripts/dev-symlink.sh --restore", diff --git a/scripts/hardening-inventory.js b/scripts/hardening-inventory.js new file mode 100644 index 00000000..549fe35a --- /dev/null +++ b/scripts/hardening-inventory.js @@ -0,0 +1,526 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const ROOT_DIR = path.resolve(__dirname, '..'); +const SRC_DIR = path.join(ROOT_DIR, 'src'); +const REPORT_DIR = path.join(ROOT_DIR, 'docs', 'reports'); +const JSON_REPORT_PATH = path.join(REPORT_DIR, 'hardening-inventory.json'); +const MD_REPORT_PATH = path.join(REPORT_DIR, 'hardening-inventory.md'); + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); +const HOTPATH_PATTERNS = [ + /^src\/web-server\//, + /^src\/commands\//, + /^src\/cliproxy\//, + /^src\/management\//, + /^src\/auth\//, + /^src\/delegation\//, + /^src\/utils\//, + /^src\/ccs\.ts$/, +]; + +const SYNC_CALL_NAMES = [ + 'accessSync', + 'appendFileSync', + 'chmodSync', + 'chownSync', + 'closeSync', + 'copyFileSync', + 'cpSync', + 'existsSync', + 'fstatSync', + 'fsyncSync', + 'ftruncateSync', + 'futimesSync', + 'lchmodSync', + 'lchownSync', + 'linkSync', + 'lstatSync', + 'mkdirSync', + 'mkdtempSync', + 'openSync', + 'opendirSync', + 'readFileSync', + 'readdirSync', + 'readlinkSync', + 'readSync', + 'readvSync', + 'realpathSync', + 'renameSync', + 'rmSync', + 'rmdirSync', + 'statSync', + 'symlinkSync', + 'truncateSync', + 'unlinkSync', + 'utimesSync', + 'writeFileSync', + 'writeSync', + 'writevSync', +]; +const SYNC_CALL_CAPTURE_REGEX = new RegExp( + `(?:\\bfs(?:\\s*\\?\\.)?\\s*\\.\\s*|(? pattern.test(filePath)); +} + +function walkFiles(dirPath) { + const output = []; + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + output.push(...walkFiles(fullPath)); + continue; + } + + if (entry.isFile() && isSourceFile(fullPath)) { + output.push(fullPath); + } + } + + return output; +} + +function uniqueSorted(values) { + return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b)); +} + +function sortByCountDesc(items) { + return [...items].sort((a, b) => { + if (b.count !== a.count) return b.count - a.count; + return a.file.localeCompare(b.file); + }); +} + +function summarize(items, limit = 10) { + return sortByCountDesc(items) + .slice(0, limit) + .map((item) => ({ + file: item.file, + count: item.count, + calls: uniqueSorted(item.calls || []), + markers: uniqueSorted(item.markers || []), + })); +} + +function isRegexLiteralStart(previousSignificantChar, previousIdentifier) { + return ( + previousSignificantChar === '' || + '([{:;,=!?+-*%^&|~<>'.includes(previousSignificantChar) || + REGEX_LITERAL_KEYWORDS.has(previousIdentifier) + ); +} + +function stripComments(sourceText) { + let output = ''; + let index = 0; + let inLineComment = false; + let inBlockComment = false; + let inSingleQuote = false; + let inDoubleQuote = false; + let inTemplateLiteral = false; + let inRegexLiteral = false; + let inRegexCharClass = false; + let previousSignificantChar = ''; + let previousIdentifier = ''; + + while (index < sourceText.length) { + const current = sourceText[index]; + const next = sourceText[index + 1]; + + if (inLineComment) { + if (current === '\n' || current === '\r') { + inLineComment = false; + output += current; + } else { + output += ' '; + } + index += 1; + continue; + } + + if (inBlockComment) { + if (current === '*' && next === '/') { + output += ' '; + index += 2; + inBlockComment = false; + continue; + } + + output += current === '\n' || current === '\r' ? current : ' '; + index += 1; + continue; + } + + if (inRegexLiteral) { + if (current === '\n' || current === '\r') { + output += current; + index += 1; + inRegexLiteral = false; + inRegexCharClass = false; + continue; + } + + output += ' '; + + if (current === '\\') { + output += next === '\n' || next === '\r' ? next : ' '; + index += 2; + continue; + } + + if (!inRegexCharClass && current === '[') { + inRegexCharClass = true; + index += 1; + continue; + } + + if (inRegexCharClass && current === ']') { + inRegexCharClass = false; + index += 1; + continue; + } + + if (!inRegexCharClass && current === '/') { + index += 1; + while (index < sourceText.length && /[a-z]/i.test(sourceText[index])) { + output += ' '; + index += 1; + } + inRegexLiteral = false; + previousSignificantChar = 'r'; + previousIdentifier = ''; + continue; + } + + index += 1; + continue; + } + + if (inSingleQuote) { + output += current === '\n' || current === '\r' ? current : ' '; + if (current === '\\') { + output += next === '\n' || next === '\r' ? next : ' '; + index += 2; + continue; + } + if (current === "'") { + inSingleQuote = false; + previousSignificantChar = 's'; + previousIdentifier = ''; + } + index += 1; + continue; + } + + if (inDoubleQuote) { + output += current === '\n' || current === '\r' ? current : ' '; + if (current === '\\') { + output += next === '\n' || next === '\r' ? next : ' '; + index += 2; + continue; + } + if (current === '"') { + inDoubleQuote = false; + previousSignificantChar = 's'; + previousIdentifier = ''; + } + index += 1; + continue; + } + + if (inTemplateLiteral) { + output += current === '\n' || current === '\r' ? current : ' '; + if (current === '\\') { + output += next === '\n' || next === '\r' ? next : ' '; + index += 2; + continue; + } + if (current === '`') { + inTemplateLiteral = false; + previousSignificantChar = 's'; + previousIdentifier = ''; + } + index += 1; + continue; + } + + if (current === '/' && next === '/') { + output += ' '; + index += 2; + inLineComment = true; + continue; + } + + if (current === '/' && next === '*') { + output += ' '; + index += 2; + inBlockComment = true; + continue; + } + + if (current === '/' && isRegexLiteralStart(previousSignificantChar, previousIdentifier)) { + output += ' '; + index += 1; + inRegexLiteral = true; + inRegexCharClass = false; + continue; + } + + if (/[A-Za-z_$]/.test(current)) { + let tokenEnd = index + 1; + while (tokenEnd < sourceText.length && /[A-Za-z0-9_$]/.test(sourceText[tokenEnd])) { + tokenEnd += 1; + } + const token = sourceText.slice(index, tokenEnd); + output += token; + previousSignificantChar = 'i'; + previousIdentifier = token; + index = tokenEnd; + continue; + } + + if (current === "'") { + inSingleQuote = true; + output += ' '; + index += 1; + continue; + } + + if (current === '"') { + inDoubleQuote = true; + output += ' '; + index += 1; + continue; + } + + if (current === '`') { + inTemplateLiteral = true; + output += ' '; + index += 1; + continue; + } + + output += current; + if (!/\s/.test(current)) { + previousSignificantChar = current; + previousIdentifier = ''; + } + index += 1; + } + + return output; +} + +function collectSyncCallSites(sourceText) { + const sanitizedSource = stripComments(sourceText); + const captureRegex = new RegExp(SYNC_CALL_CAPTURE_REGEX.source, SYNC_CALL_CAPTURE_REGEX.flags); + const calls = []; + + for (const match of sanitizedSource.matchAll(captureRegex)) { + calls.push(match[1]); + } + + return { + count: calls.length, + calls, + }; +} + +function buildReport() { + const files = walkFiles(SRC_DIR); + const syncEntries = []; + const legacyEntries = []; + + for (const fullPath of files) { + const file = relativePath(fullPath); + const sourceText = fs.readFileSync(fullPath, 'utf8'); + const lines = sourceText.split(/\r?\n/); + const { count: syncCount, calls: syncCalls } = collectSyncCallSites(sourceText); + let legacyCount = 0; + const legacyMarkers = []; + + for (const line of lines) { + if (LEGACY_MARKER_REGEX.test(line)) { + legacyCount += 1; + const normalized = line.trim(); + if (normalized.length > 0) { + legacyMarkers.push(normalized); + } + } + } + + if (syncCount > 0) { + syncEntries.push({ + file, + count: syncCount, + calls: syncCalls, + hotpath: isHotpath(file), + }); + } + + if (legacyCount > 0) { + legacyEntries.push({ + file, + count: legacyCount, + markers: legacyMarkers, + }); + } + } + + const syncHotpathEntries = syncEntries.filter((entry) => entry.hotpath); + const totalSyncCount = syncEntries.reduce((acc, entry) => acc + entry.count, 0); + const totalSyncHotpathCount = syncHotpathEntries.reduce((acc, entry) => acc + entry.count, 0); + const totalLegacyMarkers = legacyEntries.reduce((acc, entry) => acc + entry.count, 0); + + return { + scope: 'src/**/*.{ts,tsx,js,jsx,mjs,cjs}', + syncFs: { + totalOccurrences: totalSyncCount, + filesAffected: syncEntries.length, + hotpathOccurrences: totalSyncHotpathCount, + hotpathFilesAffected: syncHotpathEntries.length, + topHotpathFiles: summarize(syncHotpathEntries), + topFilesOverall: summarize(syncEntries), + }, + legacyShim: { + totalMarkers: totalLegacyMarkers, + filesAffected: legacyEntries.length, + topFiles: summarize(legacyEntries), + explicitShimFiles: uniqueSorted( + legacyEntries + .map((entry) => entry.file) + .filter((file) => /shim|re-export|compat/i.test(path.basename(file))) + ), + }, + }; +} + +function renderMarkdown(report) { + const lines = []; + + lines.push('# Hardening Inventory Report'); + lines.push(''); + lines.push(`Scope: \`${report.scope}\``); + lines.push(''); + lines.push('## Summary'); + lines.push(''); + lines.push('| Metric | Value |'); + lines.push('|---|---:|'); + lines.push(`| Sync fs occurrences (all) | ${report.syncFs.totalOccurrences} |`); + lines.push(`| Sync fs files affected (all) | ${report.syncFs.filesAffected} |`); + lines.push(`| Sync fs occurrences (runtime hotpaths) | ${report.syncFs.hotpathOccurrences} |`); + lines.push(`| Sync fs files affected (runtime hotpaths) | ${report.syncFs.hotpathFilesAffected} |`); + lines.push(`| Legacy shim markers | ${report.legacyShim.totalMarkers} |`); + lines.push(`| Legacy shim files affected | ${report.legacyShim.filesAffected} |`); + lines.push(''); + + lines.push('## Top Runtime Hotpath Sync fs Files'); + lines.push(''); + lines.push('| File | Sync Calls | API Names |'); + lines.push('|---|---:|---|'); + + for (const item of report.syncFs.topHotpathFiles) { + lines.push(`| \`${item.file}\` | ${item.count} | ${item.calls.join(', ')} |`); + } + + if (report.syncFs.topHotpathFiles.length === 0) { + lines.push('| _none_ | 0 | - |'); + } + + lines.push(''); + lines.push('## Top Legacy Shim Marker Files'); + lines.push(''); + lines.push('| File | Marker Count |'); + lines.push('|---|---:|'); + + for (const item of report.legacyShim.topFiles) { + lines.push(`| \`${item.file}\` | ${item.count} |`); + } + + if (report.legacyShim.topFiles.length === 0) { + lines.push('| _none_ | 0 |'); + } + + lines.push(''); + lines.push('## Explicit Shim/Re-export Files'); + lines.push(''); + for (const file of report.legacyShim.explicitShimFiles) { + lines.push(`- \`${file}\``); + } + if (report.legacyShim.explicitShimFiles.length === 0) { + lines.push('- _none_'); + } + + lines.push(''); + return lines.join('\n'); +} + +function main() { + const report = buildReport(); + + fs.mkdirSync(REPORT_DIR, { recursive: true }); + fs.writeFileSync(JSON_REPORT_PATH, JSON.stringify(report, null, 2) + '\n', 'utf8'); + fs.writeFileSync(MD_REPORT_PATH, renderMarkdown(report), 'utf8'); + + const relJson = relativePath(JSON_REPORT_PATH); + const relMd = relativePath(MD_REPORT_PATH); + + console.log(`[hardening-inventory] generatedAt=${new Date().toISOString()}`); + console.log( + `[hardening-inventory] sync-fs total=${report.syncFs.totalOccurrences}, hotpath=${report.syncFs.hotpathOccurrences}` + ); + console.log( + `[hardening-inventory] legacy markers total=${report.legacyShim.totalMarkers}, files=${report.legacyShim.filesAffected}` + ); + console.log(`[hardening-inventory] wrote ${relJson}`); + console.log(`[hardening-inventory] wrote ${relMd}`); +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildReport, + collectSyncCallSites, + renderMarkdown, + stripComments, +}; diff --git a/scripts/maintainability-baseline.js b/scripts/maintainability-baseline.js new file mode 100644 index 00000000..0216b841 --- /dev/null +++ b/scripts/maintainability-baseline.js @@ -0,0 +1,309 @@ +#!/usr/bin/env node + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const SRC_DIR = path.join(PROJECT_ROOT, 'src'); +const DEFAULT_BASELINE_PATH = path.join( + PROJECT_ROOT, + 'docs', + 'metrics', + 'maintainability-baseline.json' +); + +const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.cts', '.mts']); +const LARGE_FILE_THRESHOLD_LOC = 350; + +const FS_SYNC_APIS = [ + 'accessSync', + 'appendFileSync', + 'chmodSync', + 'chownSync', + 'closeSync', + 'copyFileSync', + 'cpSync', + 'existsSync', + 'fchmodSync', + 'fchownSync', + 'fdatasyncSync', + 'fstatSync', + 'fsyncSync', + 'ftruncateSync', + 'futimesSync', + 'lchmodSync', + 'lchownSync', + 'linkSync', + 'lstatSync', + 'lutimesSync', + 'mkdirSync', + 'mkdtempSync', + 'openSync', + 'opendirSync', + 'readFileSync', + 'readdirSync', + 'readlinkSync', + 'readSync', + 'realpathSync', + 'renameSync', + 'rmSync', + 'rmdirSync', + 'statSync', + 'symlinkSync', + 'truncateSync', + 'unlinkSync', + 'utimesSync', + 'writeFileSync', + 'writeSync', + 'writevSync', +]; + +const PROCESS_EXIT_PATTERN = /\bprocess\s*\.\s*exit\b/g; +const FS_SYNC_PATTERN = new RegExp(`\\b(?:${FS_SYNC_APIS.join('|')})\\b`, 'g'); + +function printUsage() { + console.log( + [ + 'Usage:', + ' node scripts/maintainability-baseline.js', + ' node scripts/maintainability-baseline.js --out [path]', + ' node scripts/maintainability-baseline.js --check [path]', + '', + 'Defaults:', + ` baseline path: ${path.relative(PROJECT_ROOT, DEFAULT_BASELINE_PATH)}`, + ].join('\n') + ); +} + +function parseArgs(argv) { + const options = { + outPath: null, + checkPath: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-h') { + printUsage(); + process.exit(0); + } + + if (arg === '--out' || arg === '--write') { + const nextArg = argv[index + 1]; + if (nextArg && !nextArg.startsWith('--')) { + options.outPath = nextArg; + index += 1; + } else { + options.outPath = path.relative(process.cwd(), DEFAULT_BASELINE_PATH); + } + continue; + } + + if (arg === '--check') { + const nextArg = argv[index + 1]; + if (nextArg && !nextArg.startsWith('--')) { + options.checkPath = nextArg; + index += 1; + } else { + options.checkPath = path.relative(process.cwd(), DEFAULT_BASELINE_PATH); + } + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function collectTrackedFilesFromGit() { + let output; + try { + output = execFileSync('git', ['ls-files', '-z', '--', 'src'], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + throw new Error( + 'Unable to enumerate tracked files via git. Run this command from a git checkout with git installed.' + ); + } + + if (!output) { + return []; + } + + return output + .split('\0') + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)) + .map(relativePath => path.resolve(PROJECT_ROOT, relativePath)) + .filter(filePath => { + const relativeToSrc = path.relative(SRC_DIR, filePath); + if (relativeToSrc.startsWith('..') || path.isAbsolute(relativeToSrc)) { + return false; + } + + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + throw new Error(`Tracked path is not a file: ${path.relative(PROJECT_ROOT, filePath)}`); + } + + return true; + }); +} + +function collectFilesInSrc() { + return collectTrackedFilesFromGit(); +} + +function countLines(content) { + if (content.length === 0) { + return 0; + } + + const lineBreakMatches = content.match(/\r\n|\n|\r/g); + const lineBreakCount = lineBreakMatches ? lineBreakMatches.length : 0; + const endsWithLineBreak = content.endsWith('\n') || content.endsWith('\r'); + + return endsWithLineBreak ? lineBreakCount : lineBreakCount + 1; +} + +function countMatches(content, pattern) { + const matches = content.match(pattern); + return matches ? matches.length : 0; +} + +function collectMetrics() { + if (!fs.existsSync(SRC_DIR)) { + throw new Error(`Directory not found: ${SRC_DIR}`); + } + + const files = collectFilesInSrc(); + + let typeScriptFileCount = 0; + let locInSrc = 0; + let processExitReferenceCount = 0; + let synchronousFsApiReferenceCount = 0; + let largeFileCountOver350Loc = 0; + + for (const filePath of files) { + const content = fs.readFileSync(filePath, 'utf8'); + const loc = countLines(content); + const extension = path.extname(filePath).toLowerCase(); + const isTypeScriptFile = TYPESCRIPT_EXTENSIONS.has(extension); + + locInSrc += loc; + processExitReferenceCount += countMatches(content, PROCESS_EXIT_PATTERN); + synchronousFsApiReferenceCount += countMatches(content, FS_SYNC_PATTERN); + + if (isTypeScriptFile) { + typeScriptFileCount += 1; + if (loc > LARGE_FILE_THRESHOLD_LOC) { + largeFileCountOver350Loc += 1; + } + } + } + + return { + sourceDirectory: 'src', + largeFileThresholdLoc: LARGE_FILE_THRESHOLD_LOC, + typeScriptFileCount, + locInSrc, + processExitReferenceCount, + synchronousFsApiReferenceCount, + largeFileCountOver350Loc, + }; +} + +function writeMetrics(outPath, metrics) { + const resolvedOutPath = path.resolve(process.cwd(), outPath); + fs.mkdirSync(path.dirname(resolvedOutPath), { recursive: true }); + fs.writeFileSync(resolvedOutPath, `${JSON.stringify(metrics, null, 2)}\n`, 'utf8'); +} + +function runCheck(checkPath, currentMetrics) { + const resolvedCheckPath = path.resolve(process.cwd(), checkPath); + const baselineContent = fs.readFileSync(resolvedCheckPath, 'utf8'); + const baselineMetrics = JSON.parse(baselineContent); + + if (baselineMetrics.sourceDirectory !== currentMetrics.sourceDirectory) { + throw new Error( + `Baseline sourceDirectory mismatch: expected "${currentMetrics.sourceDirectory}", got "${baselineMetrics.sourceDirectory}"` + ); + } + + if (baselineMetrics.largeFileThresholdLoc !== currentMetrics.largeFileThresholdLoc) { + throw new Error( + `Baseline largeFileThresholdLoc mismatch: expected ${currentMetrics.largeFileThresholdLoc}, got ${baselineMetrics.largeFileThresholdLoc}` + ); + } + + const gatedKeys = [ + 'processExitReferenceCount', + 'synchronousFsApiReferenceCount', + 'largeFileCountOver350Loc', + ]; + + const violations = []; + for (const key of gatedKeys) { + if (typeof baselineMetrics[key] !== 'number') { + throw new Error(`Baseline is missing numeric metric: ${key}`); + } + + if (currentMetrics[key] > baselineMetrics[key]) { + violations.push({ + metric: key, + baseline: baselineMetrics[key], + current: currentMetrics[key], + }); + } + } + + return { + gate: 'maintainability-baseline', + baselinePath: path.relative(PROJECT_ROOT, resolvedCheckPath), + passed: violations.length === 0, + comparedMetrics: gatedKeys, + baseline: { + typeScriptFileCount: baselineMetrics.typeScriptFileCount, + locInSrc: baselineMetrics.locInSrc, + processExitReferenceCount: baselineMetrics.processExitReferenceCount, + synchronousFsApiReferenceCount: baselineMetrics.synchronousFsApiReferenceCount, + largeFileCountOver350Loc: baselineMetrics.largeFileCountOver350Loc, + }, + current: { + typeScriptFileCount: currentMetrics.typeScriptFileCount, + locInSrc: currentMetrics.locInSrc, + processExitReferenceCount: currentMetrics.processExitReferenceCount, + synchronousFsApiReferenceCount: currentMetrics.synchronousFsApiReferenceCount, + largeFileCountOver350Loc: currentMetrics.largeFileCountOver350Loc, + }, + violations, + }; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const metrics = collectMetrics(); + + if (options.outPath) { + writeMetrics(options.outPath, metrics); + } + + if (options.checkPath) { + const checkResult = runCheck(options.checkPath, metrics); + console.log(JSON.stringify(checkResult, null, 2)); + if (!checkResult.passed) { + process.exit(1); + } + return; + } + + console.log(JSON.stringify(metrics, null, 2)); +} + +main(); diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index 2773e0ad..f31d4986 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -72,9 +72,13 @@ export function listApiProfiles(): ApiListResult { } // CLIProxy variants for (const [name, variant] of Object.entries(unifiedConfig.cliproxy?.variants || {})) { + const provider = + variant && 'type' in variant && variant.type === 'composite' + ? 'composite' + : (variant as { provider?: string })?.provider || 'unknown'; variants.push({ name, - provider: variant?.provider || 'unknown', + provider, settings: variant?.settings || '-', }); } diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index d5db7ae1..0d10bad3 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -13,24 +13,23 @@ import * as fs from 'fs'; import * as path from 'path'; import { findSimilarStrings, expandPath } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; -import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types'; +import { + UnifiedConfig, + CopilotConfig, + CLIProxyVariantConfig, + CompositeVariantConfig, + CompositeTierConfig, +} from '../config/unified-config-types'; import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; import { getCcsDir } from '../utils/config-manager'; +import type { CLIProxyProvider } from '../cliproxy/types'; +import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; /** CLIProxy profile names (OAuth-based, zero config) */ -export const CLIPROXY_PROFILES = [ - 'gemini', - 'codex', - 'agy', - 'qwen', - 'iflow', - 'kiro', - 'ghcp', - 'claude', -] as const; -export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number]; +export const CLIPROXY_PROFILES: readonly CLIProxyProvider[] = CLIPROXY_PROVIDER_IDS; +export type CLIProxyProfileName = CLIProxyProvider; export interface ProfileDetectionResult { type: ProfileType; @@ -46,6 +45,16 @@ export interface ProfileDetectionResult { env?: Record; /** For copilot profile: the copilot config */ copilotConfig?: CopilotConfig; + /** For composite variants: true when variant mixes providers per tier */ + isComposite?: boolean; + /** For composite variants: which tier is the default */ + compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; + /** For composite variants: per-tier provider+model mappings */ + compositeTiers?: { + opus: CompositeTierConfig; + sonnet: CompositeTierConfig; + haiku: CompositeTierConfig; + }; } export interface AllProfiles { @@ -110,12 +119,46 @@ class ProfileDetector { // Check CLIProxy variants first if (config.cliproxy?.variants?.[profileName]) { const variant = config.cliproxy.variants[profileName]; + + // Handle composite variants + if ('type' in variant && variant.type === 'composite') { + const composite = variant as CompositeVariantConfig; + + // Defensive: check for missing tiers or default_tier + if (!composite.tiers || !composite.default_tier) { + console.warn( + `[!] Warning: Composite variant '${profileName}' has missing tiers or default_tier` + ); + return null; + } + + const defaultTierConfig = composite.tiers[composite.default_tier]; + if (!defaultTierConfig) { + console.warn( + `[!] Warning: Composite variant '${profileName}' missing config for default tier '${composite.default_tier}'` + ); + return null; + } + + return { + type: 'cliproxy', + name: profileName, + provider: defaultTierConfig.provider as CLIProxyProfileName, + settingsPath: composite.settings, + port: composite.port, + isComposite: true, + compositeDefaultTier: composite.default_tier, + compositeTiers: composite.tiers, + }; + } + + const singleVariant = variant as CLIProxyVariantConfig; return { type: 'cliproxy', name: profileName, - provider: variant.provider as CLIProxyProfileName, - settingsPath: variant.settings, - port: variant.port, + provider: singleVariant.provider as CLIProxyProfileName, + settingsPath: singleVariant.settings, + port: singleVariant.port, }; } @@ -200,11 +243,11 @@ class ProfileDetector { } // Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config - if (CLIPROXY_PROFILES.includes(profileName as CLIProxyProfileName)) { + if (isCLIProxyProvider(profileName)) { return { type: 'cliproxy', name: profileName, - provider: profileName as CLIProxyProfileName, + provider: profileName, }; } @@ -376,7 +419,11 @@ class ProfileDetector { lines.push('CLIProxy variants (unified config):'); variants.forEach((name) => { const variant = unifiedConfig.cliproxy?.variants[name]; - lines.push(` - ${name} (${variant?.provider || 'unknown'})`); + const label = + variant && 'type' in variant && variant.type === 'composite' + ? 'composite' + : (variant as { provider?: string })?.provider || 'unknown'; + lines.push(` - ${name} (${label})`); }); } diff --git a/src/ccs.ts b/src/ccs.ts index 43d7eba2..9b6ca3f2 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -532,6 +532,14 @@ async function main(): Promise { return; } + // Special case: cursor command (Cursor IDE integration) + // All `ccs cursor *` routes to cursor command handler — cursor has no profile-switching mode + if (firstArg === 'cursor') { + const { handleCursorCommand } = await import('./commands/cursor-command'); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + // Special case: copilot command (GitHub Copilot integration) // Only route to command handler for known subcommands, otherwise treat as profile const COPILOT_SUBCOMMANDS = [ @@ -555,10 +563,29 @@ async function main(): Promise { // Special case: headless delegation (-p flag) if (args.includes('-p') || args.includes('--prompt')) { - const { DelegationHandler } = await import('./delegation/delegation-handler'); - const handler = new DelegationHandler(); - await handler.route(args); - return; + // CLIProxy profiles (codex/gemini/agy/etc, including user variants) must stay on + // the normal CLIProxy path so provider-specific flags (e.g. --effort/--thinking) + // and proxy chains are applied consistently. + let shouldUseDelegation = true; + const { profile } = detectProfile(args); + try { + const ProfileDetectorModule = await import('./auth/profile-detector'); + const ProfileDetector = ProfileDetectorModule.default; + const detector = new ProfileDetector(); + const profileInfo = detector.detectProfileType(profile); + if (profileInfo.type === 'cliproxy') { + shouldUseDelegation = false; + } + } catch { + // Best effort detection only; keep delegation fallback behavior. + } + + if (shouldUseDelegation) { + const { DelegationHandler } = await import('./delegation/delegation-handler'); + const handler = new DelegationHandler(); + await handler.route(args); + return; + } } // First-time install: offer setup wizard for interactive users @@ -608,6 +635,10 @@ async function main(): Promise { await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath, port: variantPort, + isComposite: profileInfo.isComposite, + compositeTiers: profileInfo.compositeTiers, + compositeDefaultTier: profileInfo.compositeDefaultTier, + profileName: profileInfo.name, }); } else if (profileInfo.type === 'copilot') { // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 234a00ba..91feaa70 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -7,6 +7,74 @@ import { CLIProxyProvider } from '../types'; import { AccountInfo } from '../account-manager'; +/** + * Kiro authentication methods supported by CLIProxyAPIPlus. + * - aws: AWS Builder ID via Device Code flow + * - aws-authcode: AWS Builder ID via Authorization Code flow (CLI flag only) + * - google: Social OAuth via Google + * - github: Social OAuth via GitHub (management API only) + */ +export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const; +export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number]; + +/** CLI binary supports these Kiro methods directly via flags. */ +export const KIRO_CLI_AUTH_METHODS = ['aws', 'aws-authcode', 'google'] as const; +export type KiroCLIAuthMethod = (typeof KIRO_CLI_AUTH_METHODS)[number]; + +/** Default Kiro method for CCS UX and AWS Organization support. */ +export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws'; + +export function isKiroAuthMethod(value: string): value is KiroAuthMethod { + return KIRO_AUTH_METHODS.includes(value as KiroAuthMethod); +} + +export function isKiroCLIAuthMethod(value: string): value is KiroCLIAuthMethod { + return KIRO_CLI_AUTH_METHODS.includes(value as KiroCLIAuthMethod); +} + +export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod { + if (!value) return DEFAULT_KIRO_AUTH_METHOD; + const normalized = value.trim().toLowerCase(); + return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD; +} + +export function isKiroDeviceCodeMethod(method: KiroAuthMethod): boolean { + return method === 'aws'; +} + +export function getKiroCallbackPort(method: KiroAuthMethod): number | null { + return isKiroDeviceCodeMethod(method) ? null : 9876; +} + +export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string { + switch (method) { + case 'aws': + return '--kiro-aws-login'; + case 'aws-authcode': + return '--kiro-aws-authcode'; + case 'google': + return '--kiro-google-login'; + } +} + +/** + * Kiro method for CLIProxyAPI management endpoint: + * GET /v0/management/kiro-auth-url?method= + */ +export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' { + switch (method) { + case 'google': + return 'google'; + case 'github': + return 'github'; + case 'aws-authcode': + return 'aws'; + case 'aws': + default: + return 'aws'; + } +} + /** * OAuth callback ports used by CLIProxyAPI (hardcoded in binary) * See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth @@ -15,19 +83,19 @@ import { AccountInfo } from '../account-manager'; * - Gemini: Authorization Code Flow with local callback server on port 8085 * - Codex: Authorization Code Flow with local callback server on port 1455 * - Agy: Authorization Code Flow with local callback server on port 51121 - * - Kiro: Authorization Code Flow with local callback server on port 9876 * - iFlow: Authorization Code Flow with local callback server on port 11451 * - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth) + * - Kiro: Device Code Flow (polling-based, NO callback port needed) * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - GHCP: Device Code Flow (polling-based, NO callback port needed) */ export const OAUTH_CALLBACK_PORTS: Partial> = { gemini: 8085, - kiro: 9876, codex: 1455, agy: 51121, iflow: 11451, claude: 54545, + // kiro: Device Code Flow - no callback port // qwen: Device Code Flow - no callback port // ghcp: Device Code Flow - no callback port }; @@ -113,7 +181,9 @@ export const OAUTH_CONFIGS: Record = { displayName: 'Kiro (AWS)', authUrl: 'https://oidc.us-east-1.amazonaws.com', scopes: ['codewhisperer:completions', 'codewhisperer:conversations'], - authFlag: '--kiro-login', + // Default to AWS Builder ID device code flow for better compatibility. + // Other Kiro methods are selected at runtime via OAuthOptions.kiroMethod. + authFlag: '--kiro-aws-login', }, ghcp: { provider: 'ghcp', @@ -213,6 +283,8 @@ export interface OAuthOptions { account?: string; add?: boolean; nickname?: string; + /** Kiro auth method override (CLI + Dashboard parity). */ + kiroMethod?: KiroAuthMethod; /** If true, triggered from Web UI (enables project selection prompt) */ fromUI?: boolean; /** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 29e47e98..dc651216 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -29,10 +29,15 @@ import { } from '../../management/oauth-port-diagnostics'; import { OAuthOptions, - OAUTH_CALLBACK_PORTS, + DEFAULT_KIRO_AUTH_METHOD, + getKiroCallbackPort, + getKiroCLIAuthFlag, + isKiroCLIAuthMethod, + isKiroDeviceCodeMethod, getOAuthConfig, ProviderOAuthConfig, CLIPROXY_CALLBACK_PROVIDER_MAP, + normalizeKiroAuthMethod, } from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager'; @@ -414,6 +419,8 @@ export async function triggerOAuth( const oauthConfig = getOAuthConfig(provider); const { verbose = false, add = false, fromUI = false, noIncognito = true } = options; let { nickname } = options; + const resolvedKiroMethod = + provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD; // Check for existing accounts const existingAccounts = getProviderAccounts(provider); @@ -444,10 +451,28 @@ export async function triggerOAuth( return null; } - const callbackPort = OAUTH_PORTS[provider]; + if (provider === 'kiro' && resolvedKiroMethod === 'github') { + console.log(fail('Kiro GitHub login is only available in Dashboard management OAuth flow.')); + console.log(' Use: ccs config -> Accounts -> Add Kiro account -> Method: GitHub OAuth'); + return null; + } + + const callbackPort = + provider === 'kiro' ? getKiroCallbackPort(resolvedKiroMethod) : OAUTH_PORTS[provider]; const isCLI = !fromUI; const headless = options.headless ?? isHeadlessEnvironment(); - const isDeviceCodeFlow = callbackPort === null; + const isDeviceCodeFlow = + provider === 'kiro' ? isKiroDeviceCodeMethod(resolvedKiroMethod) : callbackPort === null; + + let authFlag = oauthConfig.authFlag; + if (provider === 'kiro') { + if (!isKiroCLIAuthMethod(resolvedKiroMethod)) { + console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`)); + console.log(' Use Dashboard management OAuth for this method.'); + return null; + } + authFlag = getKiroCLIAuthFlag(resolvedKiroMethod); + } // Interactive mode selection for headless environments // Skip if explicit mode flag provided or device code flow (no callback needed) @@ -493,7 +518,7 @@ export async function triggerOAuth( const { binaryPath, tokenDir, configPath } = prepared; // Free callback port if needed (only for authorization code flows) - const localCallbackPort = OAUTH_CALLBACK_PORTS[provider]; + const localCallbackPort = callbackPort; if (localCallbackPort) { const killed = killProcessOnPort(localCallbackPort, verbose); if (killed && verbose) { @@ -502,7 +527,7 @@ export async function triggerOAuth( } // Build args - const args = ['--config', configPath, oauthConfig.authFlag]; + const args = ['--config', configPath, authFlag]; if (headless) { args.push('--no-browser'); } diff --git a/src/cliproxy/composite-validator.ts b/src/cliproxy/composite-validator.ts new file mode 100644 index 00000000..981b58e0 --- /dev/null +++ b/src/cliproxy/composite-validator.ts @@ -0,0 +1,118 @@ +/** + * Shared validation helpers for composite CLIProxy variants. + * Used by API routes, service layer, and config loader to avoid contract drift. + */ + +import { CLIPROXY_SUPPORTED_PROVIDERS, CompositeTierConfig } from '../config/unified-config-types'; +import type { CLIProxyProvider } from './types'; + +export const VALID_COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const; +export type CompositeTierName = (typeof VALID_COMPOSITE_TIERS)[number]; + +interface CompositeValidationOptions { + defaultTier?: unknown; + requireAllTiers?: boolean; +} + +type CompositeTierInput = Partial>; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isValidProvider(provider: unknown): provider is CLIProxyProvider { + return ( + typeof provider === 'string' && + CLIPROXY_SUPPORTED_PROVIDERS.includes(provider as CLIProxyProvider) + ); +} + +export function validateCompositeDefaultTier(defaultTier: unknown): string | null { + if ( + defaultTier !== undefined && + !VALID_COMPOSITE_TIERS.includes(defaultTier as CompositeTierName) + ) { + return `Invalid default_tier '${String(defaultTier)}': must be one of ${VALID_COMPOSITE_TIERS.join(', ')}`; + } + return null; +} + +/** + * Validate composite tier payload. + * + * Create mode (`requireAllTiers=true`): all tiers required. + * Update mode (`requireAllTiers=false`): partial tiers allowed. + */ +export function validateCompositeTiers( + tiers: unknown, + options: CompositeValidationOptions = {} +): string | null { + const { defaultTier, requireAllTiers = false } = options; + + const defaultTierError = validateCompositeDefaultTier(defaultTier); + if (defaultTierError) { + return defaultTierError; + } + + if (!isRecord(tiers)) { + return "Invalid tiers payload: expected object with tier keys ('opus', 'sonnet', 'haiku')"; + } + + const tierMap = tiers as CompositeTierInput; + + for (const tier of VALID_COMPOSITE_TIERS) { + const tierValue = tierMap[tier]; + + if (requireAllTiers && tierValue === undefined) { + return `Missing required tier '${tier}': all tiers (opus, sonnet, haiku) required for create`; + } + + if (tierValue === undefined) { + continue; + } + + if (!isRecord(tierValue)) { + return `Invalid tier config for '${tier}': expected object with provider and model`; + } + + const provider = tierValue.provider; + const model = tierValue.model; + + if (typeof provider !== 'string' || typeof model !== 'string') { + return `Invalid tier config for '${tier}': requires 'provider' and 'model' strings`; + } + + if (!model.trim()) { + return `Invalid model for tier '${tier}': model cannot be empty or whitespace`; + } + + if (!isValidProvider(provider)) { + return `Invalid provider '${provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`; + } + + if (tierValue.fallback !== undefined) { + const fallback = tierValue.fallback; + if (!isRecord(fallback)) { + return `Invalid fallback config for tier '${tier}': expected object with provider and model`; + } + + if (typeof fallback.provider !== 'string' || typeof fallback.model !== 'string') { + return `Invalid fallback config for tier '${tier}': requires 'provider' and 'model' strings`; + } + + if (!fallback.model.trim()) { + return `Invalid fallback model for tier '${tier}': model cannot be empty or whitespace`; + } + + if (!isValidProvider(fallback.provider)) { + return `Invalid fallback provider '${fallback.provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`; + } + + if (fallback.provider === provider && fallback.model === model) { + return `Circular fallback in tier '${tier}': fallback cannot point to same provider and model`; + } + } + } + + return null; +} diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 4674ecd3..11a74b2a 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -10,6 +10,7 @@ import { getGlobalEnvConfig } from '../../config/unified-config-loader'; import { getEffectiveApiKey } from '../auth-token-manager'; import { expandPath } from '../../utils/helpers'; import { warn } from '../../utils/ui'; +import { CompositeTierConfig } from '../../config/unified-config-types'; import { validatePort, validateRemotePort, @@ -413,3 +414,102 @@ export function getRemoteEnvVars( return env; } + +/** Remote config for composite variant (passed from env-resolver) */ +export interface CompositeRemoteConfig { + host: string; + port: number; + protocol: 'http' | 'https'; + authToken?: string; +} + +/** + * Get environment variables for composite variant. + * Uses root URL (no /api/provider/ path) for model-based routing. + * Each tier maps to a different provider's model, routed by CLIProxyAPI. + * + * @param tiers Per-tier provider+model mappings + * @param defaultTier Which tier ANTHROPIC_MODEL equals + * @param port Local CLIProxy port (ignored if remoteConfig provided) + * @param customSettingsPath Optional path to user's custom settings file + * @param remoteConfig Optional remote proxy config (overrides localhost URL/auth) + */ +export function getCompositeEnvVars( + tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig }, + defaultTier: 'opus' | 'sonnet' | 'haiku', + port: number = CLIPROXY_DEFAULT_PORT, + customSettingsPath?: string, + remoteConfig?: CompositeRemoteConfig +): Record { + const globalEnv = getGlobalEnvVars(); + + // Load user settings if provided (may contain additional env vars like hooks) + let additionalEnvVars: Record = {}; + if (customSettingsPath) { + const expandedPath = expandPath(customSettingsPath); + if (fs.existsSync(expandedPath)) { + try { + const content = fs.readFileSync(expandedPath, 'utf-8'); + const settings: ProviderSettings = JSON.parse(content); + if (settings.env && typeof settings.env === 'object') { + // Extract non-core env vars (hooks, etc.) + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ANTHROPIC_MODEL: _model, + ANTHROPIC_DEFAULT_OPUS_MODEL: _opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku, + ...extra + } = settings.env as Record; + additionalEnvVars = extra; + } + } catch { + // Invalid JSON — ignore + } + } + } + + const validPort = validatePort(port); + + // Defensive: handle missing tiers gracefully + const opusModel = tiers.opus?.model; + const sonnetModel = tiers.sonnet?.model; + const haikuModel = tiers.haiku?.model; + const defaultModel = tiers[defaultTier]?.model; + + // If default tier is missing, we cannot proceed meaningfully + if (!defaultModel) { + throw new Error(`Missing model for default tier '${defaultTier}'`); + } + + // Determine base URL and auth token based on remote vs local mode + const baseUrl = remoteConfig + ? (() => { + const normalizedProtocol = normalizeProtocol(remoteConfig.protocol); + const effectivePort = + validateRemotePort(remoteConfig.port) ?? getRemoteDefaultPort(normalizedProtocol); + const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; + const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; + return `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`; + })() + : `http://127.0.0.1:${validPort}`; + + const authToken = remoteConfig?.authToken ?? getEffectiveApiKey(); + + const env: Record = { + ...globalEnv, + ...additionalEnvVars, + // Root URL — CLIProxyAPI routes based on model name in request body + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: authToken, + ANTHROPIC_MODEL: defaultModel, + }; + + // Only set tier env vars if the tier exists + if (opusModel) env.ANTHROPIC_DEFAULT_OPUS_MODEL = opusModel; + if (sonnetModel) env.ANTHROPIC_DEFAULT_SONNET_MODEL = sonnetModel; + if (haikuModel) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = haikuModel; + + return env; +} diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 398e9dc5..940c157e 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -13,6 +13,30 @@ import { warn } from '../../utils/ui'; /** Model tier types for thinking budget defaults */ export type ModelTier = 'opus' | 'sonnet' | 'haiku'; +/** + * Normalize model ID for provider capability lookup. + * Codex may carry effort suffixes in either legacy "(high)" or current "-high" forms. + */ +function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvider): string { + const withoutExtendedContext = model.replace(/\[1m\]$/i, '').trim(); + + if (provider !== 'codex') return withoutExtendedContext; + + // New codex suffix form: gpt-5.3-codex-high -> gpt-5.3-codex + const codexSuffixMatch = withoutExtendedContext.match(/^(.*)-(xhigh|high|medium)$/i); + if (codexSuffixMatch?.[1]) { + return codexSuffixMatch[1].trim(); + } + + // Legacy codex suffix form: gpt-5.3-codex(high) -> gpt-5.3-codex + const codexLegacyMatch = withoutExtendedContext.match(/^(.*)\((xhigh|high|medium)\)$/i); + if (codexLegacyMatch?.[1]) { + return codexLegacyMatch[1].trim(); + } + + return withoutExtendedContext; +} + /** * Check if warnings should be shown based on thinking config. * Defaults to true if show_warnings is not explicitly false. @@ -41,11 +65,46 @@ export function detectTierFromModel(modelName: string): ModelTier { * @returns Model name with thinking suffix, e.g., "gemini-3-pro-preview(high)" */ export function applyThinkingSuffix(model: string, thinkingValue: string | number): string { - // Don't apply if model already ends with a parenthesized suffix (e.g., "model(high)" or "model(8192)") - // Matches: ends with "(...)" where content is non-empty - if (/\([^)]+\)$/.test(model)) { + return applyThinkingSuffixForProvider(model, thinkingValue); +} + +/** + * Apply provider-aware thinking suffix to model name. + * - Most providers: model(level|budget) e.g. "gemini-2.5-pro(high)" + * - Codex: model-effort e.g. "gpt-5.3-codex-xhigh" + */ +function applyThinkingSuffixForProvider( + model: string, + thinkingValue: string | number, + provider?: CLIProxyProvider +): string { + const codexEffortRegex = /^(medium|high|xhigh)$/i; + const codexModelSuffixRegex = /-(medium|high|xhigh)$/i; + const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/); + + // Existing parenthesized suffix: + // - keep as-is for non-codex providers + // - for codex effort levels, normalize to codex model suffix style + if (parenthesizedSuffixMatch) { + if (provider === 'codex') { + const normalizedParensValue = parenthesizedSuffixMatch[1]?.trim().toLowerCase() || ''; + if (codexEffortRegex.test(normalizedParensValue)) { + return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`); + } + } return model; } + + if (provider === 'codex') { + if (codexModelSuffixRegex.test(model)) { + return model; + } + const normalized = String(thinkingValue).trim().toLowerCase(); + if (codexEffortRegex.test(normalized)) { + return `${model}-${normalized}`; + } + } + return `${model}(${thinkingValue})`; } @@ -67,19 +126,38 @@ export function getThinkingValueForTier( return thinkingConfig.tier_defaults?.[tier] ?? DEFAULT_THINKING_TIER_DEFAULTS[tier]; } +/** + * Composite tier config for provider lookup (subset of full CompositeTierConfig) + */ +interface CompositeTierProvider { + provider?: CLIProxyProvider; +} + /** * Apply thinking configuration to env vars. * Modifies ANTHROPIC_MODEL and tier models with thinking suffixes. * * @param envVars - Environment variables to modify - * @param provider - CLIProxy provider + * @param provider - CLIProxy provider (default provider for base model) * @param thinkingOverride - Optional CLI override (takes priority over config) + * @param compositeTierThinking - Optional per-tier thinking overrides for composite variants + * @param compositeTiers - Optional per-tier provider config for composite variants * @returns Modified env vars with thinking suffixes applied */ export function applyThinkingConfig( envVars: NodeJS.ProcessEnv, provider: CLIProxyProvider, - thinkingOverride?: string | number + thinkingOverride?: string | number, + compositeTierThinking?: { + opus?: string; + sonnet?: string; + haiku?: string; + }, + compositeTiers?: { + opus?: CompositeTierProvider; + sonnet?: CompositeTierProvider; + haiku?: CompositeTierProvider; + } ): NodeJS.ProcessEnv { const thinkingConfig = getThinkingConfig(); const result = { ...envVars }; @@ -89,18 +167,30 @@ export function applyThinkingConfig( return result; } + // Explicit "off" (CLI override or manual config override) must disable ALL tier thinking. + const explicitOffOverride = + thinkingOverride === 'off' || + (thinkingOverride === undefined && + thinkingConfig.mode === 'manual' && + thinkingConfig.override === 'off'); + if (explicitOffOverride) { + return result; + } + // Get base model to check thinking support const baseModel = result.ANTHROPIC_MODEL || ''; - if (!supportsThinking(provider, baseModel)) { - // U2: Warn user if they explicitly provided --thinking but model doesn't support it - if (thinkingOverride !== undefined && shouldShowWarnings(thinkingConfig)) { - console.warn( - warn( - `Model ${baseModel || 'unknown'} (provider: ${provider}) does not support thinking budget. --thinking flag ignored.` - ) - ); - } - return result; + const normalizedBaseModel = normalizeModelForThinkingLookup(baseModel, provider); + const baseModelSupportsThinking = supportsThinking(provider, normalizedBaseModel); + if ( + !baseModelSupportsThinking && + thinkingOverride !== undefined && + shouldShowWarnings(thinkingConfig) + ) { + console.warn( + warn( + `Model ${baseModel || 'unknown'} (provider: ${provider}) does not support thinking budget. --thinking flag ignored for default model.` + ) + ); } // Determine thinking value to use @@ -114,27 +204,44 @@ export function applyThinkingConfig( thinkingValue = thinkingConfig.override; } else if (thinkingConfig.mode === 'auto') { // Auto mode: detect tier and apply default - const tier = detectTierFromModel(baseModel); - thinkingValue = getThinkingValueForTier(tier, provider, thinkingConfig); + const tier = detectTierFromModel(normalizedBaseModel); + // Check per-tier config first if composite + const perTierValue = compositeTierThinking?.[tier]; + if (perTierValue !== undefined) { + thinkingValue = perTierValue; + } else { + thinkingValue = getThinkingValueForTier(tier, provider, thinkingConfig); + } } else { return result; // No thinking to apply } - // Validate thinking value against model capabilities - const validation = validateThinking(provider, baseModel, thinkingValue); - if (validation.warning && shouldShowWarnings(thinkingConfig)) { - console.warn(warn(validation.warning)); + if (baseModelSupportsThinking) { + // Validate thinking value against default model capabilities. + const validation = validateThinking(provider, normalizedBaseModel, thinkingValue); + if (validation.warning && shouldShowWarnings(thinkingConfig)) { + console.warn(warn(validation.warning)); + } + thinkingValue = validation.value; } - thinkingValue = validation.value; - // If validation says off, don't apply suffix + // If auto-detection resolves default tier to "off", skip the main model but still allow + // explicit per-tier thinking values for other tiers. if (thinkingValue === 'off') { - return result; - } - - // Apply thinking suffix to main model - if (result.ANTHROPIC_MODEL) { - result.ANTHROPIC_MODEL = applyThinkingSuffix(result.ANTHROPIC_MODEL, thinkingValue); + const hasPerTierThinking = + compositeTierThinking && + Object.values(compositeTierThinking).some((v) => v !== undefined && v !== 'off'); + if (!hasPerTierThinking) { + return result; // No thinking to apply anywhere + } + // Otherwise, continue to process tiers with their own config (skip main model) + } else if (baseModelSupportsThinking && result.ANTHROPIC_MODEL) { + // Apply thinking suffix to main model (only if not off) + result.ANTHROPIC_MODEL = applyThinkingSuffixForProvider( + result.ANTHROPIC_MODEL, + thinkingValue, + provider + ); } // Apply to tier models if they support thinking @@ -146,16 +253,46 @@ export function applyThinkingConfig( for (const tierVar of tierModels) { const model = result[tierVar]; - if (model && supportsThinking(provider, model)) { + if (model) { // Get tier-specific thinking value const tier = tierVar.includes('OPUS') ? 'opus' : tierVar.includes('SONNET') ? 'sonnet' : 'haiku'; - const tierThinkingValue = - thinkingOverride ?? getThinkingValueForTier(tier, provider, thinkingConfig); - result[tierVar] = applyThinkingSuffix(model, tierThinkingValue); + + // P2 FIX: Use tier-specific provider from compositeTiers for mixed-provider composites + // Falls back to the default provider if not a composite or tier not specified + const tierProvider = compositeTiers?.[tier]?.provider ?? provider; + const normalizedTierModel = normalizeModelForThinkingLookup(model, tierProvider); + + // Check if this tier's model supports thinking (using tier-specific provider) + if (!supportsThinking(tierProvider, normalizedTierModel)) { + continue; + } + + // Priority chain: CLI --thinking > per-tier config > global config > defaults + let tierThinkingValue: string | number; + if (thinkingOverride !== undefined) { + // CLI override takes priority + tierThinkingValue = thinkingOverride; + } else { + const perTierValue = compositeTierThinking?.[tier]; + if (perTierValue !== undefined) { + // Per-tier config from composite variant + tierThinkingValue = perTierValue; + } else { + // Global config or defaults (use tier-specific provider for provider overrides) + tierThinkingValue = getThinkingValueForTier(tier, tierProvider, thinkingConfig); + } + } + + // If per-tier thinking is 'off', skip this tier + if (tierThinkingValue === 'off') { + continue; + } + + result[tierVar] = applyThinkingSuffixForProvider(model, tierThinkingValue, tierProvider); } } diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 2747a70a..4390ee9c 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -9,9 +9,15 @@ * - WebSearch and ImageAnalysis hook integration */ -import { getEffectiveEnvVars, getRemoteEnvVars, applyThinkingConfig } from '../config-generator'; +import { + getEffectiveEnvVars, + getRemoteEnvVars, + getCompositeEnvVars, + applyThinkingConfig, +} from '../config-generator'; import { applyExtendedContextConfig } from '../config/extended-context-config'; import { CLIProxyProvider } from '../types'; +import { CompositeTierConfig } from '../../config/unified-config-types'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; @@ -41,6 +47,16 @@ export interface ProxyChainConfig { /** Extended context override: true = force on, false = force off, undefined = auto */ extendedContextOverride?: boolean; verbose: boolean; + /** Composite variant: true when mixing providers per tier */ + isComposite?: boolean; + /** Composite variant: per-tier provider+model mappings */ + compositeTiers?: { + opus: CompositeTierConfig; + sonnet: CompositeTierConfig; + haiku: CompositeTierConfig; + }; + /** Composite variant: which tier is the default */ + compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; } /** @@ -60,53 +76,113 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record 0) { + compositeTierThinking = tierThinking; + } } // Apply thinking configuration to model (auto tier-based or manual override) - applyThinkingConfig(envVars, provider, thinkingOverride); + envVars = applyThinkingConfig( + envVars, + provider, + thinkingOverride, + compositeTierThinking, + compositeTiers + ); // Apply extended context suffix for 1M token context window // Auto-enabled for Gemini, opt-in for Claude (--1m flag) @@ -176,3 +252,24 @@ export function logEnvironment( log(`Claude env: Global env applied (telemetry/reporting disabled)`); } } + +/** Apply fallback provider config to env vars for a failed tier */ +export function applyFallback( + env: Record, + failedTier: 'opus' | 'sonnet' | 'haiku', + fallback: { provider: string; model: string } +): Record { + const tierEnvMap = { + opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL', + sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL', + haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + } as const; + const result = { ...env }; + const originalModel = result[tierEnvMap[failedTier]]; + result[tierEnvMap[failedTier]] = fallback.model; + // If failed tier is default tier, also update ANTHROPIC_MODEL + if (result.ANTHROPIC_MODEL === originalModel) { + result.ANTHROPIC_MODEL = fallback.model; + } + return result; +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 4f7d430d..3ea5b1c7 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -52,6 +52,7 @@ import { import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { installImageAnalyzerHook } from '../../utils/hooks'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; +import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types'; // Import modular components import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager'; @@ -63,6 +64,7 @@ import { handleQuotaCheck, } from './retry-handler'; import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge'; +import { parseThinkingOverride } from './thinking-arg-parser'; import { warnCrossProviderDuplicates, cleanupStaleAutoPauses, @@ -113,20 +115,31 @@ export async function execClaudeWithCLIProxy( } }; + // Helper: Extract unique providers from composite tiers + const compositeProviders = + cfg.isComposite && cfg.compositeTiers + ? [...new Set(Object.values(cfg.compositeTiers).map((t) => t.provider))] + : []; + // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) const unifiedConfig = loadOrCreateUnifiedConfig(); // 0a. Runtime backend/provider validation const backend: CLIProxyBackend = unifiedConfig.cliproxy?.backend ?? DEFAULT_BACKEND; - if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(provider)) { - console.error(''); - console.error(fail(`${provider} requires CLIProxyAPIPlus backend`)); - console.error(''); - console.error('To use this provider, either:'); - console.error(' 1. Set `cliproxy.backend: plus` in ~/.ccs/config.yaml'); - console.error(' 2. Use --backend=plus flag: ccs ' + provider + ' --backend=plus'); - console.error(''); - throw new Error(`Provider ${provider} requires Plus backend`); + + // Collect all providers to validate (default + composite tiers) + const allProviders = [provider, ...compositeProviders]; + for (const p of allProviders) { + if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) { + console.error(''); + console.error(fail(`${p} requires CLIProxyAPIPlus backend`)); + console.error(''); + console.error('To use this provider, either:'); + console.error(' 1. Set `cliproxy.backend: plus` in ~/.ccs/config.yaml'); + console.error(' 2. Use --backend=plus flag: ccs ' + p + ' --backend=plus'); + console.error(''); + throw new Error(`Provider ${p} requires Plus backend`); + } } const cliproxyServerConfig = unifiedConfig.cliproxy_server; @@ -295,50 +308,63 @@ export async function execClaudeWithCLIProxy( setNickname = argsWithoutProxy[nicknameIdx + 1]; } - // Parse --thinking flag - let thinkingOverride: string | number | undefined; - const thinkingEqArg = argsWithoutProxy.find((arg) => arg.startsWith('--thinking=')); - if (thinkingEqArg) { - const val = thinkingEqArg.substring('--thinking='.length); - if (!val || val.trim() === '') { - console.error(fail('--thinking requires a value')); - console.error(' Examples: --thinking=low, --thinking=8192, --thinking=off'); + // Parse --kiro-auth-method flag + let kiroAuthMethod: KiroAuthMethod | undefined; + const kiroMethodIdx = argsWithoutProxy.indexOf('--kiro-auth-method'); + if (kiroMethodIdx !== -1) { + const rawMethod = argsWithoutProxy[kiroMethodIdx + 1]; + if (!rawMethod || rawMethod.startsWith('-')) { + console.error(fail('--kiro-auth-method requires a value')); + console.error(' Supported values: aws, aws-authcode, google, github'); + process.exitCode = 1; + return; + } + const normalized = rawMethod.trim().toLowerCase(); + if (!isKiroAuthMethod(normalized)) { + console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`)); + console.error(' Supported values: aws, aws-authcode, google, github'); + process.exitCode = 1; + return; + } + kiroAuthMethod = normalizeKiroAuthMethod(normalized); + } + + if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { + console.error(fail('--kiro-auth-method is only valid for ccs kiro')); + process.exitCode = 1; + return; + } + + // Parse --thinking / --effort flags (aliases; first occurrence wins) + const thinkingParse = parseThinkingOverride(argsWithoutProxy); + if (thinkingParse.error) { + const { flag } = thinkingParse.error; + console.error(fail(`${flag} requires a value`)); + + if (provider === 'codex') { + console.error(' Codex examples: --effort xhigh, --effort high, --effort medium'); + console.error(' Alias: --thinking xhigh (same behavior)'); + } else { + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); console.error(' Levels: minimal, low, medium, high, xhigh, auto'); - process.exit(1); } - const numVal = parseInt(val, 10); - thinkingOverride = !isNaN(numVal) ? numVal : val; - const allThinkingFlags = argsWithoutProxy.filter( - (arg) => arg === '--thinking' || arg.startsWith('--thinking=') + process.exit(1); + } + + const thinkingOverride = thinkingParse.value; + if (thinkingParse.duplicateDisplays.length > 0) { + console.warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}` ); - if (allThinkingFlags.length > 1) { - console.warn( - `[!] Multiple --thinking flags detected. Using first occurrence: ${thinkingEqArg}` - ); - } - } else { - const thinkingIdx = argsWithoutProxy.indexOf('--thinking'); - if (thinkingIdx !== -1) { - const nextArg = argsWithoutProxy[thinkingIdx + 1]; - if (!nextArg || nextArg.startsWith('-')) { - console.error(fail('--thinking requires a value')); - console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); - console.error(' Levels: minimal, low, medium, high, xhigh, auto'); - process.exit(1); - } - const numVal = parseInt(nextArg, 10); - thinkingOverride = !isNaN(numVal) ? numVal : nextArg; + } - const allThinkingFlags = argsWithoutProxy.filter( - (arg) => arg === '--thinking' || arg.startsWith('--thinking=') - ); - if (allThinkingFlags.length > 1) { - console.warn( - `[!] Multiple --thinking flags detected. Using first occurrence: --thinking ${nextArg}` - ); - } - } + if (thinkingParse.sourceFlag === '--effort' && provider !== 'codex') { + console.warn( + warn( + '`--effort` is primarily for codex. Continuing as alias of `--thinking` for compatibility.' + ) + ); } // Parse --1m / --no-1m flags for extended context (1M token window) @@ -421,8 +447,18 @@ export async function execClaudeWithCLIProxy( // Handle --config if (forceConfig && supportsModelConfig(provider)) { - await configureProviderModel(provider, true, cfg.customSettingsPath); - process.exit(0); + // Block --config for composite variants (per-tier models in config.yaml) + if (cfg.isComposite) { + const variantName = cfg.profileName || provider; + console.log( + warn('Composite variants use per-tier config. Edit config.yaml to change tier models.') + ); + console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`); + process.exit(1); + } else { + await configureProviderModel(provider, true, cfg.customSettingsPath); + process.exit(0); + } } // Handle --logout @@ -457,6 +493,7 @@ export async function execClaudeWithCLIProxy( const authSuccess = await triggerOAuth(provider, { verbose, import: true, + ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), ...(setNickname ? { nickname: setNickname } : {}), }); if (!authSuccess) { @@ -477,11 +514,58 @@ export async function execClaudeWithCLIProxy( if (providerConfig.requiresOAuth && !skipLocalAuth) { log(`Checking authentication for ${provider}`); - if (forceAuth || !isAuthenticated(provider)) { + // Multi-provider auth check for composite variants + if (compositeProviders.length > 0) { + // Handle forceAuth for composite providers + if (forceAuth) { + const { triggerOAuth } = await import('../auth-handler'); + const failures: string[] = []; + for (const p of compositeProviders) { + const authSuccess = await triggerOAuth(p, { + verbose, + add: addAccount, + ...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}), + ...(forceHeadless ? { headless: true } : {}), + ...(setNickname ? { nickname: setNickname } : {}), + ...(noIncognito ? { noIncognito: true } : {}), + ...(pasteCallback ? { pasteCallback: true } : {}), + ...(portForward ? { portForward: true } : {}), + }); + if (!authSuccess) { + failures.push(p); + } + } + if (failures.length > 0) { + const succeeded = compositeProviders.filter((p) => !failures.includes(p)); + console.error(fail(`Auth failed for: ${failures.join(', ')}`)); + if (succeeded.length > 0) { + console.error(info(`Succeeded: ${succeeded.join(', ')}`)); + } + process.exit(1); + } + process.exit(0); + } + + // Check for unauthenticated providers + const unauthenticatedProviders: string[] = []; + for (const p of compositeProviders) { + if (!isAuthenticated(p)) { + unauthenticatedProviders.push(p); + } + } + if (unauthenticatedProviders.length > 0) { + console.error(fail('Composite variant requires authentication for multiple providers:')); + for (const p of unauthenticatedProviders) { + console.error(` - ${p} (run "ccs ${p} --auth")`); + } + process.exit(1); + } + } else if (forceAuth || !isAuthenticated(provider)) { const { triggerOAuth } = await import('../auth-handler'); const authSuccess = await triggerOAuth(provider, { verbose, add: addAccount, + ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), ...(noIncognito ? { noIncognito: true } : {}), @@ -498,8 +582,14 @@ export async function execClaudeWithCLIProxy( log(`${provider} already authenticated`); } - // 3a. Proactive token refresh - await handleTokenExpiration(provider, verbose); + // 3a. Proactive token refresh (multi-provider for composite) + if (compositeProviders.length > 0) { + for (const p of compositeProviders) { + await handleTokenExpiration(p, verbose); + } + } else { + await handleTokenExpiration(provider, verbose); + } // 3a-1. Update lastUsedAt const usedAccount = getDefaultAccount(provider); @@ -510,7 +600,14 @@ export async function execClaudeWithCLIProxy( // 3b. Preflight quota check (Antigravity only) if (!skipLocalAuth) { - await handleQuotaCheck(provider); + // Multi-tier quota check for composite variants (check if ANY tier uses 'agy') + if (compositeProviders.length > 0) { + if (compositeProviders.includes('agy')) { + await handleQuotaCheck('agy'); + } + } else { + await handleQuotaCheck(provider); + } } // 3c. Account safety: enforce cross-provider isolation @@ -529,27 +626,50 @@ export async function execClaudeWithCLIProxy( } // 4. First-run model configuration - if (supportsModelConfig(provider) && !skipLocalAuth) { + if (!cfg.isComposite && supportsModelConfig(provider) && !skipLocalAuth) { await configureProviderModel(provider, false, cfg.customSettingsPath); } - // 5. Check for broken models - const currentModel = getCurrentModel(provider, cfg.customSettingsPath); - if (currentModel && isModelBroken(provider, currentModel)) { - const modelEntry = findModel(provider, currentModel); - const issueUrl = getModelIssueUrl(provider, currentModel); - console.error(''); - console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); - console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); - if (issueUrl) { - console.error(` Tracking: ${issueUrl}`); + // 5. Check for broken models (multi-tier for composite) + if (compositeProviders.length > 0 && cfg.compositeTiers) { + // Check all tier models in composite variant + const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku']; + for (const tier of tiers) { + const tierConfig = cfg.compositeTiers[tier]; + if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) { + const modelEntry = findModel(tierConfig.provider, tierConfig.model); + const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model); + console.error(''); + console.error( + warn( + `${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code` + ) + ); + console.error(' Tool calls will fail. Consider changing the model in config.yaml.'); + if (issueUrl) { + console.error(` Tracking: ${issueUrl}`); + } + console.error(''); + } } - if (skipLocalAuth) { - console.error(' Note: Model may be overridden by remote proxy configuration.'); - } else { - console.error(` Run "ccs ${provider} --config" to change model.`); + } else { + const currentModel = getCurrentModel(provider, cfg.customSettingsPath); + if (currentModel && isModelBroken(provider, currentModel)) { + const modelEntry = findModel(provider, currentModel); + const issueUrl = getModelIssueUrl(provider, currentModel); + console.error(''); + console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); + console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); + if (issueUrl) { + console.error(` Tracking: ${issueUrl}`); + } + if (skipLocalAuth) { + console.error(' Note: Model may be overridden by remote proxy configuration.'); + } else { + console.error(` Run "ccs ${provider} --config" to change model.`); + } + console.error(''); } - console.error(''); } // 6. Ensure user settings file exists @@ -640,6 +760,9 @@ export async function execClaudeWithCLIProxy( thinkingOverride, extendedContextOverride, verbose, + isComposite: cfg.isComposite, + compositeTiers: cfg.compositeTiers, + compositeDefaultTier: cfg.compositeDefaultTier, }); if (initialEnvVars.ANTHROPIC_BASE_URL) { @@ -665,11 +788,12 @@ export async function execClaudeWithCLIProxy( ? `http://127.0.0.1:${toolSanitizationPort}` : initialEnvVars.ANTHROPIC_BASE_URL; - // 10. Setup Codex reasoning proxy (Codex only) + // 10. Setup Codex reasoning proxy (single-provider Codex only) let codexReasoningProxy: CodexReasoningProxy | null = null; let codexReasoningPort: number | null = null; - if (provider === 'codex') { + // Composite variants require root model-routed endpoints, never provider-pinned codex endpoints. + if (provider === 'codex' && !cfg.isComposite) { if (!postSanitizationBaseUrl) { log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled'); } else { @@ -729,8 +853,23 @@ export async function execClaudeWithCLIProxy( thinkingOverride, extendedContextOverride, verbose, + isComposite: cfg.isComposite, + compositeTiers: cfg.compositeTiers, + compositeDefaultTier: cfg.compositeDefaultTier, }); + if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) { + const mode = useRemoteProxy + ? proxyConfig.protocol === 'https' + ? 'remote-https' + : 'remote-http' + : 'local'; + const defaultTierProvider = cfg.compositeTiers[cfg.compositeDefaultTier]?.provider ?? provider; + log( + `Composite self-check: mode=${mode}, baseUrl=${env.ANTHROPIC_BASE_URL || 'unset'}, defaultTier=${cfg.compositeDefaultTier}, defaultProvider=${defaultTierProvider}` + ); + } + const webSearchEnv = getWebSearchHookEnv(); logEnvironment(env, webSearchEnv, verbose); @@ -746,7 +885,9 @@ export async function execClaudeWithCLIProxy( '--accounts', '--use', '--nickname', + '--kiro-auth-method', '--thinking', + '--effort', '--1m', '--no-1m', '--incognito', @@ -758,11 +899,14 @@ export async function execClaudeWithCLIProxy( const claudeArgs = argsWithoutProxy.filter((arg, idx) => { if (ccsFlags.includes(arg)) return false; if (arg.startsWith('--thinking=')) return false; + if (arg.startsWith('--effort=')) return false; if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false; if ( argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname' || - argsWithoutProxy[idx - 1] === '--thinking' + argsWithoutProxy[idx - 1] === '--kiro-auth-method' || + argsWithoutProxy[idx - 1] === '--thinking' || + argsWithoutProxy[idx - 1] === '--effort' ) return false; return true; diff --git a/src/cliproxy/executor/retry-handler.ts b/src/cliproxy/executor/retry-handler.ts index d3bb5996..514af33f 100644 --- a/src/cliproxy/executor/retry-handler.ts +++ b/src/cliproxy/executor/retry-handler.ts @@ -11,6 +11,7 @@ import { fail, warn, info } from '../../utils/ui'; import { CLIProxyProvider } from '../types'; import { handleBanDetection } from '../account-safety'; +import { CompositeTierConfig } from '../../config/unified-config-types'; /** * Check if error is network-related @@ -98,3 +99,34 @@ export async function handleQuotaCheck(provider: CLIProxyProvider): Promise p.test(stderr)); +} diff --git a/src/cliproxy/executor/thinking-arg-parser.ts b/src/cliproxy/executor/thinking-arg-parser.ts new file mode 100644 index 00000000..7f414d00 --- /dev/null +++ b/src/cliproxy/executor/thinking-arg-parser.ts @@ -0,0 +1,98 @@ +/** + * Parse CLI thinking/effort override flags. + * + * Supported aliases: + * - --thinking + * - --thinking= + * - --effort + * - --effort= + */ + +export type ThinkingFlag = '--thinking' | '--effort'; + +export interface ThinkingParseError { + flag: ThinkingFlag; + form: 'inline' | 'separate'; +} + +export interface ThinkingParseResult { + value?: string | number; + sourceFlag?: ThinkingFlag; + sourceDisplay?: string; + duplicateDisplays: string[]; + error?: ThinkingParseError; +} + +function parseThinkingValue(raw: string): string | number { + const trimmed = raw.trim(); + + // Keep strict integer parsing for legacy compatibility with --thinking numeric budgets. + if (/^-?\d+$/.test(trimmed)) { + return Number.parseInt(trimmed, 10); + } + + return trimmed; +} + +/** + * Parse first thinking/effort value from args. + * If multiple flags are provided, first occurrence wins and later ones are reported as duplicates. + */ +export function parseThinkingOverride(args: string[]): ThinkingParseResult { + let value: string | number | undefined; + let sourceFlag: ThinkingFlag | undefined; + let sourceDisplay: string | undefined; + const duplicateDisplays: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + let flag: ThinkingFlag | null = null; + let rawValue: string | undefined; + let display = ''; + let form: 'inline' | 'separate' = 'separate'; + + if (arg === '--thinking' || arg === '--effort') { + flag = arg; + const nextArg = args[i + 1]; + if (!nextArg || nextArg.startsWith('-')) { + if (value === undefined) { + return { value, sourceFlag, sourceDisplay, duplicateDisplays, error: { flag, form } }; + } + duplicateDisplays.push(`${flag} `); + continue; + } + rawValue = nextArg; + display = `${flag} ${rawValue}`; + i += 1; // Consume value + } else if (arg.startsWith('--thinking=') || arg.startsWith('--effort=')) { + const [rawFlag, ...rest] = arg.split('='); + const joined = rest.join('='); + flag = rawFlag as ThinkingFlag; + rawValue = joined; + form = 'inline'; + if (!rawValue || rawValue.trim() === '') { + if (value === undefined) { + return { value, sourceFlag, sourceDisplay, duplicateDisplays, error: { flag, form } }; + } + duplicateDisplays.push(`${flag}=`); + continue; + } + display = `${flag}=${rawValue}`; + } + + if (!flag || rawValue === undefined) { + continue; + } + + if (value === undefined) { + value = parseThinkingValue(rawValue); + sourceFlag = flag; + sourceDisplay = display; + } else { + duplicateDisplays.push(display); + } + } + + return { value, sourceFlag, sourceDisplay, duplicateDisplays }; +} diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 6948a2e7..e50d0ffc 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -160,12 +160,23 @@ export const MODEL_CATALOG: Partial> = codex: { provider: 'codex', displayName: 'Copilot Codex', - defaultModel: 'gpt-5.2-codex', + defaultModel: 'gpt-5.3-codex', models: [ + { + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + description: 'Supports up to xhigh effort', + thinking: { + type: 'levels', + levels: ['medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + }, { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', - description: 'Full reasoning support (xhigh)', + description: 'Previous stable Codex model', thinking: { type: 'levels', levels: ['medium', 'high', 'xhigh'], @@ -176,7 +187,7 @@ export const MODEL_CATALOG: Partial> = { id: 'gpt-5-mini', name: 'GPT-5 Mini', - description: 'Capped at high reasoning (no xhigh)', + description: 'Capped at high effort (no xhigh)', thinking: { type: 'levels', levels: ['medium', 'high'], diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 1442a93a..1b03a8f4 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -14,6 +14,22 @@ import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; +const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; + +function stripCodexEffortSuffix(model: string, provider: CLIProxyProvider): string { + if (provider !== 'codex') return model; + return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +} + +function normalizeCodexTierModel( + provider: CLIProxyProvider, + model: string, + fallbackEffort: 'medium' | 'high' | 'xhigh' +): string { + if (provider !== 'codex') return model; + return CODEX_EFFORT_SUFFIX_REGEX.test(model) ? model : `${model}-${fallbackEffort}`; +} + /** * Check if provider has user settings configured */ @@ -120,7 +136,9 @@ export async function configureProviderModel( // Find default index - use current model if configured, otherwise catalog default const currentModel = getCurrentModel(provider, customSettingsPath); - const targetModel = currentModel || catalog.defaultModel; + const targetModel = currentModel + ? stripCodexEffortSuffix(currentModel, provider) + : catalog.defaultModel; const defaultIdx = catalog.models.findIndex((m) => m.id === targetModel); const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0; @@ -142,6 +160,14 @@ export async function configureProviderModel( // Get base env vars for defaults const baseEnv = getClaudeEnvVars(provider); + const selectedDefaultModel = normalizeCodexTierModel(provider, selectedModel, 'xhigh'); + const selectedOpusModel = normalizeCodexTierModel(provider, selectedModel, 'xhigh'); + const selectedSonnetModel = normalizeCodexTierModel(provider, selectedModel, 'high'); + const selectedHaikuModel = normalizeCodexTierModel( + provider, + baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || selectedModel, + 'medium' + ); // Read existing settings to preserve user customizations let existingSettings: Record = {}; @@ -163,10 +189,10 @@ export async function configureProviderModel( const ccsControlledEnv: Record = { ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', - ANTHROPIC_MODEL: selectedModel, - ANTHROPIC_DEFAULT_OPUS_MODEL: selectedModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: selectedModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || '', + ANTHROPIC_MODEL: selectedDefaultModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: selectedOpusModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: selectedSonnetModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: selectedHaikuModel, }; // Merge: user env vars (preserved) + CCS controlled (override) @@ -228,13 +254,16 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise m.id === currentModel); + const entry = catalog.models.find((m) => m.id === normalizedCurrentModel); const displayName = entry?.name || 'Unknown'; console.error( ` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}` @@ -251,7 +280,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise { - const isCurrent = m.id === currentModel; + const isCurrent = m.id === normalizedCurrentModel; console.error(formatModelDetailed(m, isCurrent)); }); diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts new file mode 100644 index 00000000..6dec96a8 --- /dev/null +++ b/src/cliproxy/provider-capabilities.ts @@ -0,0 +1,113 @@ +import type { CLIProxyProvider } from './types'; + +export type OAuthFlowType = 'authorization_code' | 'device_code'; + +export interface ProviderCapabilities { + displayName: string; + oauthFlow: OAuthFlowType; + callbackPort: number | null; + /** + * Alternative provider names used by CLIProxyAPI or stats endpoints. + * These aliases normalize external names to canonical CCS provider IDs. + */ + aliases: readonly string[]; +} + +export const PROVIDER_CAPABILITIES: Record = { + gemini: { + displayName: 'Google Gemini', + oauthFlow: 'authorization_code', + callbackPort: 8085, + aliases: ['gemini-cli'], + }, + codex: { + displayName: 'Codex', + oauthFlow: 'authorization_code', + callbackPort: 1455, + aliases: [], + }, + agy: { + displayName: 'AntiGravity', + oauthFlow: 'authorization_code', + callbackPort: 51121, + aliases: ['antigravity'], + }, + qwen: { + displayName: 'Qwen', + oauthFlow: 'device_code', + callbackPort: null, + aliases: [], + }, + iflow: { + displayName: 'iFlow', + oauthFlow: 'authorization_code', + callbackPort: 11451, + aliases: [], + }, + kiro: { + displayName: 'Kiro (AWS)', + oauthFlow: 'device_code', + callbackPort: null, + aliases: ['codewhisperer'], + }, + ghcp: { + displayName: 'GitHub Copilot (OAuth)', + oauthFlow: 'device_code', + callbackPort: null, + aliases: ['github-copilot', 'copilot'], + }, + claude: { + displayName: 'Claude', + oauthFlow: 'authorization_code', + callbackPort: 54545, + aliases: ['anthropic'], + }, +}; + +export const CLIPROXY_PROVIDER_IDS = Object.freeze( + Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[] +); + +const PROVIDER_ID_SET = new Set(CLIPROXY_PROVIDER_IDS); + +const PROVIDER_ALIAS_MAP: ReadonlyMap = (() => { + const entries: Array<[string, CLIProxyProvider]> = []; + for (const provider of CLIPROXY_PROVIDER_IDS) { + entries.push([provider, provider]); + for (const alias of PROVIDER_CAPABILITIES[provider].aliases) { + entries.push([alias.toLowerCase(), provider]); + } + } + return new Map(entries); +})(); + +export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider { + return PROVIDER_ID_SET.has(provider as CLIProxyProvider); +} + +export function getProviderCapabilities(provider: CLIProxyProvider): ProviderCapabilities { + return PROVIDER_CAPABILITIES[provider]; +} + +export function getProviderDisplayName(provider: CLIProxyProvider): string { + return PROVIDER_CAPABILITIES[provider].displayName; +} + +export function getProvidersByOAuthFlow(flowType: OAuthFlowType): CLIProxyProvider[] { + return CLIPROXY_PROVIDER_IDS.filter( + (provider) => PROVIDER_CAPABILITIES[provider].oauthFlow === flowType + ); +} + +export function getOAuthFlowType(provider: CLIProxyProvider): OAuthFlowType { + return PROVIDER_CAPABILITIES[provider].oauthFlow; +} + +export function getOAuthCallbackPort(provider: CLIProxyProvider): number | null { + return PROVIDER_CAPABILITIES[provider].callbackPort; +} + +export function mapExternalProviderName(providerName: string): CLIProxyProvider | null { + const normalized = providerName.toLowerCase(); + return PROVIDER_ALIAS_MAP.get(normalized) ?? null; +} diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 878dbe2d..88ac119a 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -14,6 +14,8 @@ import type { CodexQuotaResult, CodexQuotaWindow } from './quota-types'; /** ChatGPT backend API base URL */ const CODEX_API_BASE = 'https://chatgpt.com/backend-api'; +const CODEX_QUOTA_TIMEOUT_MS = 12000; +const CODEX_QUOTA_MAX_ATTEMPTS = 2; /** * User agent matching Codex CLI for API compatibility. @@ -222,114 +224,134 @@ export async function fetchCodexQuota( } const url = `${CODEX_API_BASE}/wham/usage`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + let lastErrorMsg = 'Unknown error'; - try { - const response = await fetch(url, { - method: 'GET', - signal: controller.signal, - headers: { - Authorization: `Bearer ${authData.accessToken}`, - 'ChatGPT-Account-Id': authData.accountId, - 'User-Agent': USER_AGENT, - }, - }); + for (let attempt = 1; attempt <= CODEX_QUOTA_MAX_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CODEX_QUOTA_TIMEOUT_MS); - clearTimeout(timeoutId); + try { + const response = await fetch(url, { + method: 'GET', + signal: controller.signal, + headers: { + Authorization: `Bearer ${authData.accessToken}`, + 'ChatGPT-Account-Id': authData.accountId, + 'User-Agent': USER_AGENT, + }, + }); - if (verbose) console.error(`[i] Codex API status: ${response.status}`); + clearTimeout(timeoutId); + + if (verbose) console.error(`[i] Codex API status: ${response.status} (attempt ${attempt})`); + + if (response.status === 401) { + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'Token expired or invalid', + accountId, + needsReauth: true, + }; + } + + if (response.status === 403) { + // 403 = account lacks API access (not same as quota exhausted) + // Keep success=false with isForbidden flag for UI to show distinct "403" badge + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: '403 Forbidden - No quota API access', + accountId, + isForbidden: true, + }; + } + + if (response.status === 429) { + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'Rate limited - try again later', + accountId, + }; + } + + if (!response.ok) { + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: `API error: ${response.status}`, + accountId, + }; + } + + const data = (await response.json()) as CodexUsageResponse; + const windows = buildCodexQuotaWindows(data); + + // Extract plan type + const planTypeRaw = data.plan_type || data.planType; + let planType: 'free' | 'plus' | 'team' | null = null; + if (planTypeRaw) { + const normalized = planTypeRaw.toLowerCase(); + if (normalized === 'free') planType = 'free'; + else if (normalized === 'plus') planType = 'plus'; + else if (normalized === 'team') planType = 'team'; + } + + if (verbose) console.error(`[i] Codex windows found: ${windows.length}`); - if (response.status === 401) { return { - success: false, - windows: [], - planType: null, + success: true, + windows, + planType, lastUpdated: Date.now(), - error: 'Token expired or invalid', - accountId, - needsReauth: true, - }; - } - - if (response.status === 403) { - // 403 = account lacks API access (not same as quota exhausted) - // Keep success=false with isForbidden flag for UI to show distinct "403" badge - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: '403 Forbidden - No quota API access', - accountId, - isForbidden: true, - }; - } - - if (response.status === 429) { - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: 'Rate limited - try again later', accountId, }; - } - - if (!response.ok) { - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: `API error: ${response.status}`, - accountId, - }; - } - - const data = (await response.json()) as CodexUsageResponse; - const windows = buildCodexQuotaWindows(data); - - // Extract plan type - const planTypeRaw = data.plan_type || data.planType; - let planType: 'free' | 'plus' | 'team' | null = null; - if (planTypeRaw) { - const normalized = planTypeRaw.toLowerCase(); - if (normalized === 'free') planType = 'free'; - else if (normalized === 'plus') planType = 'plus'; - else if (normalized === 'team') planType = 'team'; - } - - if (verbose) console.error(`[i] Codex windows found: ${windows.length}`); - - return { - success: true, - windows, - planType, - lastUpdated: Date.now(), - accountId, - }; - } catch (err) { - clearTimeout(timeoutId); - const errorMsg = - err instanceof Error && err.name === 'AbortError' + } catch (err) { + clearTimeout(timeoutId); + const isAbortError = err instanceof Error && err.name === 'AbortError'; + lastErrorMsg = isAbortError ? 'Request timeout' : err instanceof Error ? err.message : 'Unknown error'; - if (verbose) console.error(`[!] Codex quota error: ${errorMsg}`); + if (verbose) { + console.error(`[!] Codex quota error (attempt ${attempt}): ${lastErrorMsg}`); + } - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: errorMsg, - accountId, - }; + // Retry timeout once; other failures return immediately. + if (isAbortError && attempt < CODEX_QUOTA_MAX_ATTEMPTS) { + continue; + } + + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: lastErrorMsg, + accountId, + }; + } } + + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: lastErrorMsg, + accountId, + }; } /** diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts index 5de9218e..aaedf04e 100644 --- a/src/cliproxy/remote-auth-fetcher.ts +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -9,6 +9,8 @@ import { buildManagementHeaders, ProxyTarget, } from './proxy-target-resolver'; +import { getProviderDisplayName, mapExternalProviderName } from './provider-capabilities'; +import type { CLIProxyProvider } from './types'; /** Timeout for remote fetch requests (ms) */ const REMOTE_FETCH_TIMEOUT_MS = 5000; @@ -43,32 +45,6 @@ export interface RemoteAuthStatus { source: 'remote'; } -/** Map CLIProxyAPI provider names to CCS internal names */ -const PROVIDER_MAP: Record = { - gemini: 'gemini', - 'gemini-cli': 'gemini', // CLIProxyAPI uses 'gemini-cli' for Gemini CLI auth - antigravity: 'agy', - codex: 'codex', - qwen: 'qwen', - iflow: 'iflow', - kiro: 'kiro', - codewhisperer: 'kiro', // CLIProxyAPI may use 'codewhisperer' for Kiro - ghcp: 'ghcp', - 'github-copilot': 'ghcp', - copilot: 'ghcp', -}; - -/** Display names for providers */ -const PROVIDER_DISPLAY_NAMES: Record = { - gemini: 'Google Gemini', - agy: 'AntiGravity', - codex: 'Codex', - qwen: 'Qwen', - iflow: 'iFlow', - kiro: 'Kiro (AWS)', - ghcp: 'GitHub Copilot (OAuth)', -}; - /** * Fetch auth status from remote CLIProxyAPI * @throws Error if remote is unreachable or returns error @@ -124,10 +100,10 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise(); + const byProvider = new Map(); for (const file of files) { - const provider = PROVIDER_MAP[file.provider.toLowerCase()]; + const provider = mapExternalProviderName(file.provider); if (!provider) { // Unknown provider, skip (could add logging in debug mode) continue; @@ -154,7 +130,7 @@ function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { result.push({ provider, - displayName: PROVIDER_DISPLAY_NAMES[provider] || provider, + displayName: getProviderDisplayName(provider), authenticated: activeFiles.length > 0, tokenFiles: providerFiles.length, accounts, diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index ea9dff59..0ef4d2e7 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -76,7 +76,7 @@ async function waitForPort( } /** - * Register cleanup handlers to stop proxy on process exit + * Register process-exit cleanup handlers for in-process workers. */ function registerCleanup(): void { if (cleanupRegistered) return; @@ -87,11 +87,8 @@ function registerCleanup(): void { tokenRefreshWorker.stop(); tokenRefreshWorker = null; } - // Then stop proxy process - if (proxyProcess && !proxyProcess.killed) { - proxyProcess.kill('SIGTERM'); - proxyProcess = null; - } + // Do not stop detached CLIProxy on parent exit. + // Persistence is expected across CCS command lifecycles. }; process.once('exit', cleanup); diff --git a/src/cliproxy/services/index.ts b/src/cliproxy/services/index.ts index c4ece9c5..b67ed7a3 100644 --- a/src/cliproxy/services/index.ts +++ b/src/cliproxy/services/index.ts @@ -9,19 +9,25 @@ export { variantExists, listVariants, createVariant, + createCompositeVariant, + updateCompositeVariant, removeVariant, type VariantConfig, type VariantOperationResult, + type CreateCompositeVariantOptions, + type UpdateCompositeVariantOptions, } from './variant-service'; // Proxy lifecycle export { getProxyStatus, stopProxy, + startProxy, isProxyRunning, getActiveSessionCount, type ProxyStatusResult, type StopProxyResult, + type StartProxyResult, } from './proxy-lifecycle-service'; // Binary management diff --git a/src/cliproxy/services/proxy-lifecycle-service.ts b/src/cliproxy/services/proxy-lifecycle-service.ts index 7ea4c124..4c140262 100644 --- a/src/cliproxy/services/proxy-lifecycle-service.ts +++ b/src/cliproxy/services/proxy-lifecycle-service.ts @@ -2,13 +2,15 @@ * CLIProxy Proxy Lifecycle Service * * Handles start/stop/status operations for CLIProxy instances. - * Delegates to session-tracker for actual process management. + * Delegates to session-tracker and service-manager for actual process management. */ import { stopProxy as stopProxySession, getProxyStatus as getProxyStatusSession, } from '../session-tracker'; +import { ensureCliproxyService } from '../service-manager'; +import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; /** Proxy status result */ export interface ProxyStatusResult { @@ -27,18 +29,37 @@ export interface StopProxyResult { error?: string; } +/** Start proxy result */ +export interface StartProxyResult { + started: boolean; + alreadyRunning: boolean; + port: number; + configRegenerated?: boolean; + error?: string; +} + /** * Get current proxy status */ -export function getProxyStatus(): ProxyStatusResult { - return getProxyStatusSession(); +export function getProxyStatus(port?: number): ProxyStatusResult { + return getProxyStatusSession(port); } /** * Stop the running CLIProxy instance */ -export async function stopProxy(): Promise { - return stopProxySession(); +export async function stopProxy(port?: number): Promise { + return stopProxySession(port); +} + +/** + * Start CLIProxy service (or reuse existing running instance) + */ +export async function startProxy( + port: number = CLIPROXY_DEFAULT_PORT, + verbose: boolean = false +): Promise { + return ensureCliproxyService(port, verbose); } /** diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts index 1fb0ec65..1b1b3f15 100644 --- a/src/cliproxy/services/variant-config-adapter.ts +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -7,6 +7,12 @@ import * as fs from 'fs'; import { getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { CLIProxyProvider } from '../types'; +import { + CLIProxyVariantConfig, + CompositeVariantConfig, + CompositeTierConfig, + CLIPROXY_SUPPORTED_PROVIDERS, +} from '../../config/unified-config-types'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig, @@ -27,6 +33,16 @@ export interface VariantConfig { account?: string; model?: string; port?: number; + /** Composite variant fields */ + type?: 'composite'; + default_tier?: 'opus' | 'sonnet' | 'haiku'; + tiers?: { + opus: CompositeTierConfig; + sonnet: CompositeTierConfig; + haiku: CompositeTierConfig; + }; + /** Whether any tier has fallback configured */ + hasFallback?: boolean; } /** @@ -82,14 +98,66 @@ export function listVariantsFromConfig(): Record { const unifiedConfig = loadOrCreateUnifiedConfig(); const variants = unifiedConfig.cliproxy?.variants || {}; const result: Record = {}; - for (const name of Object.keys(variants)) { - const v = variants[name]; - result[name] = { - provider: v.provider, - settings: v.settings, - account: v.account, - port: v.port, - }; + for (const [name, variantConfig] of Object.entries(variants)) { + try { + if ('type' in variantConfig && variantConfig.type === 'composite') { + const composite = variantConfig as CompositeVariantConfig; + const tiers = composite.tiers as Partial<{ + opus: CompositeTierConfig; + sonnet: CompositeTierConfig; + haiku: CompositeTierConfig; + }> | null; + + if (!tiers || !tiers.opus || !tiers.sonnet || !tiers.haiku || !composite.default_tier) { + console.warn( + `[!] Skipping malformed composite variant '${name}': missing required tier configuration` + ); + continue; + } + + const defaultTierConfig = tiers[composite.default_tier]; + if (!defaultTierConfig) { + console.warn( + `[!] Skipping malformed composite variant '${name}': missing default tier '${composite.default_tier}'` + ); + continue; + } + + const normalizedTiers = { + opus: tiers.opus, + sonnet: tiers.sonnet, + haiku: tiers.haiku, + }; + + const hasFallback = !!( + normalizedTiers.opus.fallback || + normalizedTiers.sonnet.fallback || + normalizedTiers.haiku.fallback + ); + + result[name] = { + provider: defaultTierConfig.provider, + settings: composite.settings, + port: composite.port, + type: 'composite', + default_tier: composite.default_tier, + tiers: normalizedTiers, + hasFallback, + }; + } else { + const single = variantConfig as CLIProxyVariantConfig; + result[name] = { + provider: single.provider, + settings: single.settings, + account: single.account, + port: single.port, + }; + } + } catch (error) { + console.warn( + `[!] Skipping malformed variant '${name}': ${(error as Error).message || 'invalid config'}` + ); + } } return result; } @@ -117,6 +185,27 @@ export function listVariantsFromConfig(): Record { } } +/** + * Save composite variant to unified config + */ +export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void { + const unifiedConfig = loadOrCreateUnifiedConfig(); + + if (!unifiedConfig.cliproxy) { + unifiedConfig.cliproxy = { + oauth_accounts: {}, + providers: [...CLIPROXY_SUPPORTED_PROVIDERS], + variants: {}, + }; + } + if (!unifiedConfig.cliproxy.variants) { + unifiedConfig.cliproxy.variants = {}; + } + + unifiedConfig.cliproxy.variants[name] = config; + saveUnifiedConfig(unifiedConfig); +} + /** * Save variant to unified config */ @@ -132,7 +221,7 @@ export function saveVariantUnified( if (!config.cliproxy) { config.cliproxy = { oauth_accounts: {}, - providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp', 'claude'], + providers: [...CLIPROXY_SUPPORTED_PROVIDERS], variants: {}, }; } @@ -204,7 +293,23 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu delete config.cliproxy.variants[name]; saveUnifiedConfig(config); - return { provider: variant.provider, settings: variant.settings, port: variant.port }; + if ('type' in variant && variant.type === 'composite') { + const composite = variant as CompositeVariantConfig; + return { + provider: composite.tiers[composite.default_tier].provider, + settings: composite.settings, + port: composite.port, + type: 'composite', + default_tier: composite.default_tier, + tiers: composite.tiers, + }; + } + const singleVariant = variant as CLIProxyVariantConfig; + return { + provider: singleVariant.provider, + settings: singleVariant.settings, + port: singleVariant.port, + }; } /** diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 31ad54ce..a3471c13 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -9,18 +9,24 @@ import * as os from 'os'; import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types'; +import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types'; import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { DEFAULT_BACKEND } from '../platform-detector'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { deleteConfigForPort } from '../config-generator'; -import { deleteSessionLockForPort } from '../session-tracker'; +import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker'; +import { warn } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; +import { validateCompositeTiers } from '../composite-validator'; import { createSettingsFile, createSettingsFileUnified, + createCompositeSettingsFile, deleteSettingsFile, getRelativeSettingsPath, updateSettingsModel, + updateSettingsProviderAndModel, } from './variant-settings'; import { VariantConfig, @@ -28,6 +34,7 @@ import { listVariantsFromConfig, saveVariantUnified, saveVariantLegacy, + saveCompositeVariantUnified, removeVariantFromUnifiedConfig, removeVariantFromLegacyConfig, getNextAvailablePort, @@ -149,6 +156,22 @@ export function createVariant( */ export function removeVariant(name: string): VariantOperationResult { try { + // First check if variant exists and has active sessions + const variants = listVariantsFromConfig(); + const existingVariant = variants[name]; + + if (!existingVariant) { + return { success: false, error: `Variant '${name}' not found` }; + } + + // Check for active sessions on this variant's port before deletion + if (existingVariant.port && hasActiveSessions(existingVariant.port)) { + return { + success: false, + error: `Cannot delete variant '${name}': CLIProxy is running with active sessions. Stop the session first.`, + }; + } + let variant: VariantConfig | null; if (isUnifiedMode()) { @@ -174,11 +197,7 @@ export function removeVariant(name: string): VariantOperationResult { } } - if (!variant) { - return { success: false, error: `Variant '${name}' not found` }; - } - - return { success: true, variant }; + return { success: true, variant: variant ?? undefined }; } catch (error) { return { success: false, error: (error as Error).message }; } @@ -203,10 +222,39 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari return { success: false, error: `Variant '${name}' not found` }; } - // Update model in settings file if provided - if (updates.model !== undefined && existing.settings) { + if (existing.type === 'composite') { + console.log( + warn( + 'Cannot update composite variant properties directly. Remove and recreate, or edit config.yaml.' + ) + ); + return { success: false, error: 'Composite variant update not supported' }; + } + + const providerChanged = + updates.provider !== undefined && updates.provider !== existing.provider; + const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0; + + if (providerChanged && !hasModelUpdate) { + return { + success: false, + error: 'Changing provider requires model update in the same request', + }; + } + + // Update settings file + if (existing.settings) { const settingsPath = existing.settings.replace(/^~/, os.homedir()); - updateSettingsModel(settingsPath, updates.model); + if (providerChanged) { + updateSettingsProviderAndModel( + settingsPath, + updates.provider as CLIProxyProfileName, + updates.model?.trim() || '', + existing.port + ); + } else if (updates.model !== undefined) { + updateSettingsModel(settingsPath, updates.model, existing.provider as CLIProxyProfileName); + } } // Update config entry if provider or account changed @@ -245,7 +293,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari success: true, variant: { provider: updates.provider ?? existing.provider, - model: updates.model ?? existing.model, + model: updates.model?.trim() || existing.model, account: updates.account !== undefined ? updates.account : existing.account, port: existing.port, settings: existing.settings, @@ -255,3 +303,174 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari return { success: false, error: (error as Error).message }; } } + +/** Composite variant creation options */ +export interface CreateCompositeVariantOptions { + name: string; + defaultTier: 'opus' | 'sonnet' | 'haiku'; + tiers: { + opus: CompositeTierConfig; + sonnet: CompositeTierConfig; + haiku: CompositeTierConfig; + }; +} + +/** + * Create a new composite CLIProxy variant. + * Mixes different providers per tier using CLIProxyAPI root endpoints. + */ +export function createCompositeVariant( + options: CreateCompositeVariantOptions +): VariantOperationResult { + if (!isUnifiedMode()) { + throw new Error( + 'Composite variants require unified config (config.yaml). Run "ccs migrate" first.' + ); + } + + try { + const { name, defaultTier, tiers } = options; + + const validationError = validateCompositeTiers(tiers, { + defaultTier, + requireAllTiers: true, + }); + if (validationError) { + return { success: false, error: validationError }; + } + + // Validate all tier providers against backend compatibility + const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku']; + for (const tier of tierNames) { + const backendError = validateProviderBackend(tiers[tier].provider); + if (backendError) { + return { success: false, error: `${tier} tier: ${backendError}` }; + } + } + + // Allocate unique port for this composite variant + const port = getNextAvailablePort(); + + // Create settings file with root URL + per-tier models + const settingsPath = createCompositeSettingsFile(name, tiers, defaultTier, port); + + // Save composite config to unified config + const compositeConfig: CompositeVariantConfig = { + type: 'composite', + default_tier: defaultTier, + tiers, + settings: settingsPath, + port, + }; + saveCompositeVariantUnified(name, compositeConfig); + + return { + success: true, + settingsPath, + variant: { + provider: tiers[defaultTier].provider, + type: 'composite', + default_tier: defaultTier, + tiers, + port, + }, + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} + +/** Update options for composite variant */ +export interface UpdateCompositeVariantOptions { + defaultTier?: 'opus' | 'sonnet' | 'haiku'; + tiers?: Partial>; +} + +/** + * Update an existing composite CLIProxy variant. + * Merges changes with existing config and regenerates settings file. + */ +export function updateCompositeVariant( + name: string, + updates: UpdateCompositeVariantOptions +): VariantOperationResult { + if (!isUnifiedMode()) { + throw new Error('Composite variants require unified config (config.yaml).'); + } + + try { + const variants = listVariantsFromConfig(); + const existing = variants[name]; + + if (!existing) { + return { success: false, error: `Variant '${name}' not found` }; + } + + if (existing.type !== 'composite' || !existing.tiers) { + return { success: false, error: `Variant '${name}' is not a composite variant` }; + } + + // Deep merge tiers to preserve optional fields (fallback, thinking, account) + const mergedTiers = { + opus: { ...existing.tiers.opus, ...updates.tiers?.opus }, + sonnet: { ...existing.tiers.sonnet, ...updates.tiers?.sonnet }, + haiku: { ...existing.tiers.haiku, ...updates.tiers?.haiku }, + }; + + const newDefaultTier = updates.defaultTier ?? existing.default_tier ?? 'sonnet'; + const validationError = validateCompositeTiers(mergedTiers, { + defaultTier: newDefaultTier, + requireAllTiers: true, + }); + if (validationError) { + return { success: false, error: validationError }; + } + + // Validate all tier providers against backend compatibility + const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku']; + for (const tier of tierNames) { + const backendError = validateProviderBackend(mergedTiers[tier].provider); + if (backendError) { + return { success: false, error: `${tier} tier: ${backendError}` }; + } + } + + // Preserve existing settings path when configured; otherwise use default path. + const settingsRef = + existing.settings || path.join(getCcsDir(), `composite-${name}.settings.json`); + + // Create new settings file with updated config + const settingsPath = createCompositeSettingsFile( + name, + mergedTiers, + newDefaultTier, + existing.port, + settingsRef + ); + + // Save updated composite config to unified config + const compositeConfig: CompositeVariantConfig = { + type: 'composite', + default_tier: newDefaultTier, + tiers: mergedTiers, + settings: settingsRef, + port: existing.port, + }; + saveCompositeVariantUnified(name, compositeConfig); + + return { + success: true, + settingsPath, + variant: { + provider: mergedTiers[newDefaultTier].provider, + type: 'composite', + default_tier: newDefaultTier, + tiers: mergedTiers, + port: existing.port, + settings: settingsRef, + }, + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index d50c0cf2..551a2f89 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -13,11 +13,15 @@ import { getCcsDir } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIProxyProvider } from '../types'; +import { CompositeTierConfig } from '../../config/unified-config-types'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { getEffectiveApiKey } from '../auth-token-manager'; +import { warn } from '../../utils/ui'; /** Environment settings structure */ interface SettingsEnv { + [key: string]: string; ANTHROPIC_BASE_URL: string; ANTHROPIC_AUTH_TOKEN: string; ANTHROPIC_MODEL: string; @@ -27,7 +31,23 @@ interface SettingsEnv { } interface SettingsFile { - env: SettingsEnv; + env: Record; + [key: string]: unknown; +} + +const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; + +function hasCodexEffortSuffix(model: string): boolean { + return CODEX_EFFORT_SUFFIX_REGEX.test(model); +} + +function normalizeCodexTierModel( + provider: CLIProxyProfileName | undefined, + model: string, + fallbackEffort: 'medium' | 'high' | 'xhigh' +): string { + if (provider !== 'codex') return model; + return hasCodexEffortSuffix(model) ? model : `${model}-${fallbackEffort}`; } /** @@ -39,14 +59,22 @@ function buildSettingsEnv( port: number = CLIPROXY_DEFAULT_PORT ): SettingsEnv { const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, port); + const defaultModel = normalizeCodexTierModel(provider, model, 'xhigh'); + const opusModel = normalizeCodexTierModel(provider, model, 'xhigh'); + const sonnetModel = normalizeCodexTierModel(provider, model, 'high'); + const haikuModel = normalizeCodexTierModel( + provider, + baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model, + 'medium' + ); return { ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model, + ANTHROPIC_MODEL: defaultModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel, }; } @@ -60,10 +88,13 @@ function ensureDir(dir: string): void { } /** - * Write settings file atomically + * Write settings file atomically using temp file + rename. + * The renameSync is atomic on POSIX systems, preventing partial writes on crash. */ function writeSettings(filePath: string, settings: SettingsFile): void { - fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + const tempPath = `${filePath}.tmp.${process.pid}`; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, filePath); } /** @@ -144,6 +175,104 @@ export function createSettingsFileUnified( return settingsPath; } +/** + * Build settings env object for a composite variant. + * Uses root URL (no /api/provider/ path) for model-based routing. + */ +function buildCompositeSettingsEnv( + tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig }, + defaultTier: 'opus' | 'sonnet' | 'haiku', + port: number = CLIPROXY_DEFAULT_PORT +): SettingsEnv { + const defaultModel = tiers[defaultTier].model; + + return { + // Root URL — CLIProxyAPI routes based on model name, no provider prefix + ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, + ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), + ANTHROPIC_MODEL: defaultModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: tiers.opus.model, + ANTHROPIC_DEFAULT_SONNET_MODEL: tiers.sonnet.model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: tiers.haiku.model, + }; +} + +/** + * Create settings.json file for a composite variant. + */ +export function createCompositeSettingsFile( + name: string, + tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig }, + defaultTier: 'opus' | 'sonnet' | 'haiku', + port: number = CLIPROXY_DEFAULT_PORT, + settingsPathOverride?: string +): string { + const ccsDir = getCcsDir(); + const defaultSettingsPath = path.join(ccsDir, `composite-${name}.settings.json`); + const settingsPath = settingsPathOverride + ? (() => { + const expanded = expandPath(settingsPathOverride); + return path.isAbsolute(expanded) ? expanded : path.join(ccsDir, expanded); + })() + : defaultSettingsPath; + const settingsDir = path.dirname(settingsPath); + + const coreEnv = buildCompositeSettingsEnv(tiers, defaultTier, port); + let settings: SettingsFile = { env: coreEnv }; + + // Preserve non-core env vars and non-env fields (hooks/presets/etc.) when regenerating. + if (fs.existsSync(settingsPath)) { + try { + const content = fs.readFileSync(settingsPath, 'utf-8'); + const parsed = JSON.parse(content) as SettingsFile; + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const existingEnv = + parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env) + ? (parsed.env as Record) + : {}; + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ANTHROPIC_MODEL: _model, + ANTHROPIC_DEFAULT_OPUS_MODEL: _opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku, + ...extraEnv + } = existingEnv; + + settings = { + ...parsed, + env: { + ...extraEnv, + ...coreEnv, + }, + }; + } + } catch { + // Invalid JSON — overwrite with a clean settings object. + } + } + + ensureDir(settingsDir); + writeSettings(settingsPath, settings); + + // Hook injectors target ~/.ccs/.settings.json; only run for default path. + if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) { + ensureProfileHooks(`composite-${name}`); + ensureImageAnalyzerHooks(`composite-${name}`); + } + + return settingsPath; +} + +/** + * Get relative settings path for a composite variant + */ +export function getCompositeRelativeSettingsPath(name: string): string { + return `~/.ccs/composite-${name}.settings.json`; +} + /** * Delete settings file if it exists. * Uses expandPath() for cross-platform path handling. @@ -160,7 +289,19 @@ export function deleteSettingsFile(settingsPath: string): boolean { /** * Update model in an existing settings file */ -export function updateSettingsModel(settingsPath: string, model: string): void { +export function updateSettingsModel( + settingsPath: string, + model: string, + provider?: CLIProxyProfileName +): void { + const fileName = path.basename(settingsPath); + if (fileName.startsWith('composite-')) { + console.log( + warn('Cannot update model for composite variant. Edit config.yaml tiers directly.') + ); + return; + } + const resolvedPath = settingsPath.replace(/^~/, os.homedir()); if (!fs.existsSync(resolvedPath)) { return; @@ -172,16 +313,87 @@ export function updateSettingsModel(settingsPath: string, model: string): void { if (model) { settings.env = settings.env || ({} as SettingsEnv); - settings.env.ANTHROPIC_MODEL = model; - settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = model; - settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = model; + settings.env.ANTHROPIC_MODEL = normalizeCodexTierModel(provider, model, 'xhigh'); + settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizeCodexTierModel(provider, model, 'xhigh'); + settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizeCodexTierModel( + provider, + model, + 'high' + ); + if (provider === 'codex' && settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) { + settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = normalizeCodexTierModel( + provider, + settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, + 'medium' + ); + } } else { // Clear model settings to use defaults delete (settings.env as unknown as Record).ANTHROPIC_MODEL; } - fs.writeFileSync(resolvedPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + const tempPath = `${resolvedPath}.tmp.${process.pid}`; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, resolvedPath); } catch { // Ignore errors - settings file may be invalid } } + +/** + * Update provider + model core env vars in an existing single-provider settings file. + * Preserves non-core env vars and top-level settings keys (hooks, presets, etc.). + */ +export function updateSettingsProviderAndModel( + settingsPath: string, + provider: CLIProxyProfileName, + model: string, + port: number = CLIPROXY_DEFAULT_PORT +): void { + const resolvedPath = expandPath(settingsPath); + const fileName = path.basename(resolvedPath); + if (fileName.startsWith('composite-')) { + console.log( + warn('Cannot update provider/model for composite variant. Edit config.yaml tiers directly.') + ); + return; + } + + const coreEnv = buildSettingsEnv(provider, model, port); + let settings: SettingsFile = { env: coreEnv }; + + if (fs.existsSync(resolvedPath)) { + try { + const content = fs.readFileSync(resolvedPath, 'utf8'); + const parsed = JSON.parse(content) as SettingsFile; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const existingEnv = + parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env) + ? (parsed.env as Record) + : {}; + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ANTHROPIC_MODEL: _model, + ANTHROPIC_DEFAULT_OPUS_MODEL: _opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku, + ...extraEnv + } = existingEnv; + + settings = { + ...parsed, + env: { + ...extraEnv, + ...coreEnv, + }, + }; + } + } catch { + // Keep default and overwrite malformed file. + } + } + + ensureDir(path.dirname(resolvedPath)); + writeSettings(resolvedPath, settings); +} diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 3ecc56bb..8db9142c 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -3,6 +3,8 @@ * Types for CLIProxyAPI binary management and execution */ +import { CompositeTierConfig } from '../config/unified-config-types'; + /** * Supported operating systems */ @@ -181,6 +183,18 @@ export interface ExecutorConfig { pollInterval: number; /** Custom settings path for user-defined CLIProxy variants */ customSettingsPath?: string; + /** Composite variant: true when mixing providers per tier */ + isComposite?: boolean; + /** Composite variant: per-tier provider+model mappings */ + compositeTiers?: { + opus: CompositeTierConfig; + sonnet: CompositeTierConfig; + haiku: CompositeTierConfig; + }; + /** Composite variant: which tier is the default */ + compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; + /** Original profile/variant name (e.g., "my-mix" for composite variants) */ + profileName?: string; } /** diff --git a/src/commands/cliproxy/auth-subcommand.ts b/src/commands/cliproxy/auth-subcommand.ts index fbdaa353..76ab6730 100644 --- a/src/commands/cliproxy/auth-subcommand.ts +++ b/src/commands/cliproxy/auth-subcommand.ts @@ -43,8 +43,9 @@ export async function handleList(): Promise { console.log(subheader('Custom Variants')); const rows = variantNames.map((name) => { const variant = variants[name]; + const providerDisplay = variant.type === 'composite' ? 'composite' : variant.provider; const portStr = variant.port ? String(variant.port) : '-'; - return [name, variant.provider, portStr, variant.settings || '-']; + return [name, providerDisplay, portStr, variant.settings || '-']; }); console.log( table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] }) diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 1c970232..7971c701 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -26,6 +26,8 @@ export async function showHelp(): Promise { 'Profile Commands:', [ ['create [name]', 'Create new CLIProxy variant profile'], + ['create --composite', 'Create composite variant (mix providers per tier)'], + ['edit [name]', 'Edit an existing CLIProxy variant profile'], ['list', 'List all CLIProxy variant profiles'], ['remove ', 'Remove a CLIProxy variant profile'], ], @@ -59,6 +61,8 @@ export async function showHelp(): Promise { [ 'Proxy Lifecycle:', [ + ['start', 'Start CLIProxy instance in background'], + ['restart', 'Restart CLIProxy instance'], ['status', 'Show running CLIProxy status'], ['stop', 'Stop running CLIProxy instance'], ['doctor', 'Quota diagnostics and shared project detection'], diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index dc87d32d..308f9838 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -19,8 +19,13 @@ import { handlePauseAccount, handleResumeAccount, } from './quota-subcommand'; -import { handleCreate, handleRemove } from './variant-subcommand'; -import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand'; +import { handleCreate, handleRemove, handleEdit } from './variant-subcommand'; +import { + handleProxyStatus, + handleStart, + handleStop, + handleRestart, +} from './proxy-lifecycle-subcommand'; import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand'; import { showHelp } from './help-subcommand'; import { @@ -161,6 +166,11 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + if (command === 'edit') { + await handleEdit(remainingArgs.slice(1), effectiveBackend); + return; + } + if (command === 'list' || command === 'ls') { await handleList(); return; @@ -193,11 +203,21 @@ export async function handleCliproxyCommand(args: string[]): Promise { } // Proxy lifecycle commands + if (command === 'start') { + await handleStart(verbose); + return; + } + if (command === 'stop') { await handleStop(); return; } + if (command === 'restart') { + await handleRestart(verbose); + return; + } + if (command === 'status') { await handleProxyStatus(); return; diff --git a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts index aea76afb..1dad5b5a 100644 --- a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts +++ b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts @@ -2,19 +2,87 @@ * CLIProxy Lifecycle Management * * Handles: + * - ccs cliproxy start + * - ccs cliproxy restart * - ccs cliproxy status * - ccs cliproxy stop */ import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui'; -import { getProxyStatus, stopProxy } from '../../cliproxy/services'; +import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services'; +import { detectRunningProxy } from '../../cliproxy/proxy-detector'; +import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + +/** + * Resolve the local CLIProxy lifecycle port from unified config. + * Falls back to default port when unset/invalid. + */ +export function resolveLifecyclePort(): number { + const config = loadOrCreateUnifiedConfig(); + return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); +} + +export async function handleStart(verbose = false): Promise { + await initUI(); + console.log(header('Start CLIProxy')); + console.log(''); + + const port = resolveLifecyclePort(); + const result = await startProxy(port, verbose); + if (result.started) { + if (result.alreadyRunning) { + console.log(info(`CLIProxy already running on port ${result.port}`)); + if (result.configRegenerated) { + console.log(warn('Config updated - restart CLIProxy to apply changes')); + } + } else { + console.log(ok(`CLIProxy started on port ${result.port}`)); + } + console.log(dim('To stop: ccs cliproxy stop')); + } else { + console.log(warn(result.error || 'Failed to start CLIProxy')); + } + console.log(''); +} + +export async function handleRestart(verbose = false): Promise { + await initUI(); + console.log(header('Restart CLIProxy')); + console.log(''); + + const port = resolveLifecyclePort(); + const stopResult = await stopProxy(port); + if (stopResult.stopped) { + console.log(ok(`CLIProxy stopped (PID ${stopResult.pid})`)); + } else if (stopResult.error === 'No active CLIProxy session found') { + console.log(info('No active CLIProxy session found, starting a new instance')); + } else { + console.log(warn(stopResult.error || 'Failed to stop existing CLIProxy')); + console.log(info('Attempting to start a fresh instance...')); + } + + const startResult = await startProxy(port, verbose); + if (startResult.started) { + if (startResult.alreadyRunning) { + console.log(info(`CLIProxy already running on port ${startResult.port}`)); + } else { + console.log(ok(`CLIProxy started on port ${startResult.port}`)); + } + } else { + console.log(warn(startResult.error || 'Failed to restart CLIProxy')); + } + + console.log(''); +} export async function handleProxyStatus(): Promise { await initUI(); console.log(header('CLIProxy Status')); console.log(''); - const status = getProxyStatus(); + const port = resolveLifecyclePort(); + const status = getProxyStatus(port); if (status.running) { console.log(` Status: ${color('Running', 'success')}`); console.log(` PID: ${status.pid}`); @@ -26,9 +94,23 @@ export async function handleProxyStatus(): Promise { console.log(''); console.log(dim('To stop: ccs cliproxy stop')); } else { - console.log(` Status: ${color('Not running', 'warning')}`); - console.log(''); - console.log(dim('CLIProxy starts automatically when you run ccs gemini, codex, etc.')); + // Fallback: detect untracked/orphaned proxy process (e.g. detached session without lock file). + const detected = await detectRunningProxy(port); + if (detected.running && detected.verified) { + console.log(` Status: ${color('Running', 'success')}`); + console.log(` PID: ${detected.pid ?? 'unknown'}`); + console.log(` Port: ${port}`); + console.log(` Sessions: ${detected.sessionCount || 0} active`); + if (!detected.sessionCount) { + console.log(dim(' Note: Detected running proxy without local session lock')); + } + console.log(''); + console.log(dim('To stop: ccs cliproxy stop')); + } else { + console.log(` Status: ${color('Not running', 'warning')}`); + console.log(''); + console.log(dim('CLIProxy starts automatically when you run ccs gemini, codex, etc.')); + } } console.log(''); } @@ -38,7 +120,8 @@ export async function handleStop(): Promise { console.log(header('Stop CLIProxy')); console.log(''); - const result = await stopProxy(); + const port = resolveLifecyclePort(); + const result = await stopProxy(port); if (result.stopped) { console.log(ok(`CLIProxy stopped (PID ${result.pid})`)); if (result.sessionCount && result.sessionCount > 0) { diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index c1dfa9a3..f426e490 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -78,6 +78,134 @@ function formatResetTimeISO(isoTime: string): string { return formatResetTime(seconds); } +type CodexWindowKind = + | 'usage-5h' + | 'usage-weekly' + | 'code-review-5h' + | 'code-review-weekly' + | 'code-review' + | 'unknown'; + +function getCodexWindowKind(label: string): CodexWindowKind { + const lower = (label || '').toLowerCase(); + const isCodeReview = lower.includes('code review') || lower.includes('code_review'); + const isPrimary = lower.includes('primary'); + const isSecondary = lower.includes('secondary'); + + if (isCodeReview) { + if (isPrimary) return 'code-review-5h'; + if (isSecondary) return 'code-review-weekly'; + return 'code-review'; + } + + if (isPrimary) return 'usage-5h'; + if (isSecondary) return 'usage-weekly'; + return 'unknown'; +} + +type CodexWindowSummary = Pick; + +function inferCodeReviewCadence( + window: CodexWindowSummary, + allWindows: CodexWindowSummary[] +): '5h' | 'weekly' | null { + const kind = getCodexWindowKind(window.label); + if (kind === 'code-review-weekly') return 'weekly'; + + const reset = window.resetAfterSeconds; + if (typeof reset !== 'number' || !isFinite(reset) || reset <= 0) return null; + + const usage5h = allWindows.find( + (w) => + getCodexWindowKind(w.label) === 'usage-5h' && + typeof w.resetAfterSeconds === 'number' && + isFinite(w.resetAfterSeconds) && + w.resetAfterSeconds > 0 + ); + const usageWeekly = allWindows.find( + (w) => + getCodexWindowKind(w.label) === 'usage-weekly' && + typeof w.resetAfterSeconds === 'number' && + isFinite(w.resetAfterSeconds) && + w.resetAfterSeconds > 0 + ); + + if (!usage5h || !usageWeekly) return null; + + const diffTo5h = Math.abs(reset - (usage5h.resetAfterSeconds as number)); + const diffToWeekly = Math.abs(reset - (usageWeekly.resetAfterSeconds as number)); + return diffToWeekly <= diffTo5h ? 'weekly' : '5h'; +} + +function getCodexWindowDisplayLabel( + window: CodexWindowSummary, + allWindows: CodexWindowSummary[] = [] +): string { + const context = allWindows.length > 0 ? allWindows : [window]; + + switch (getCodexWindowKind(window.label)) { + case 'usage-5h': + return '5h usage limit'; + case 'usage-weekly': + return 'Weekly usage limit'; + case 'code-review-5h': + case 'code-review-weekly': + case 'code-review': { + const inferred = inferCodeReviewCadence(window, context); + if (inferred === '5h') return 'Code review (5h)'; + if (inferred === 'weekly') return 'Code review (weekly)'; + return 'Code review'; + } + case 'unknown': + return window.label; + } +} + +function getCodexCoreUsageWindows(windows: CodexQuotaResult['windows']): { + fiveHourWindow: CodexQuotaResult['windows'][number] | null; + weeklyWindow: CodexQuotaResult['windows'][number] | null; +} { + let fiveHourWindow: CodexQuotaResult['windows'][number] | null = null; + let weeklyWindow: CodexQuotaResult['windows'][number] | null = null; + const nonCodeReviewWindows: CodexQuotaResult['windows'] = []; + + for (const window of windows) { + const kind = getCodexWindowKind(window.label); + if (kind === 'usage-5h') { + if (!fiveHourWindow) fiveHourWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'usage-weekly') { + if (!weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'unknown') { + nonCodeReviewWindows.push(window); + } + } + + if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) { + const withReset = nonCodeReviewWindows + .filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0) + .sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0)); + + if (!fiveHourWindow) { + fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null; + } + + if (!weeklyWindow) { + weeklyWindow = + withReset.length > 1 + ? withReset[withReset.length - 1] + : nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null; + } + } + + return { fiveHourWindow, weeklyWindow }; +} + function displayAntigravityQuotaSection( quotaResult: Awaited> ): void { @@ -143,22 +271,32 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR continue; } + const { fiveHourWindow, weeklyWindow } = getCodexCoreUsageWindows(quota.windows); + const coreUsageWindows = [fiveHourWindow, weeklyWindow].filter( + (w, index, arr): w is NonNullable => !!w && arr.indexOf(w) === index + ); + const statusWindows = coreUsageWindows.length > 0 ? coreUsageWindows : quota.windows; + const avgQuota = - quota.windows.length > 0 - ? quota.windows.reduce((sum, w) => sum + w.remainingPercent, 0) / quota.windows.length + statusWindows.length > 0 + ? statusWindows.reduce((sum, w) => sum + w.remainingPercent, 0) / statusWindows.length : 0; const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : ''; console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`); - for (const window of quota.windows) { + const orderedWindows = [fiveHourWindow, weeklyWindow, ...quota.windows].filter( + (w, index, arr): w is NonNullable => !!w && arr.indexOf(w) === index + ); + + for (const window of orderedWindows) { const bar = formatQuotaBar(window.remainingPercent); const resetLabel = window.resetAfterSeconds ? dim(` Resets ${formatResetTime(window.resetAfterSeconds)}`) : ''; console.log( - ` ${window.label.padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` + ` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` ); } console.log(''); diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 24d7ba0c..fa395445 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -20,9 +20,12 @@ import { variantExists, listVariants, createVariant, + createCompositeVariant, + updateCompositeVariant, removeVariant, } from '../../cliproxy/services'; import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; +import { CompositeTierConfig } from '../../config/unified-config-types'; interface CliproxyProfileArgs { name?: string; @@ -31,6 +34,7 @@ interface CliproxyProfileArgs { account?: string; force?: boolean; yes?: boolean; + composite?: boolean; } function parseProfileArgs(args: string[]): CliproxyProfileArgs { @@ -47,6 +51,8 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { result.force = true; } else if (arg === '--yes' || arg === '-y') { result.yes = true; + } else if (arg === '--composite') { + result.composite = true; } else if (!arg.startsWith('-') && !result.name) { result.name = arg; } @@ -68,6 +74,71 @@ function getBackendLabel(backend: CLIProxyBackend): string { return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; } +/** + * Interactive prompt to select provider + model for a single tier. + * Returns a CompositeTierConfig, or null if user cancelled auth. + */ +async function selectTierConfig( + tierName: string, + verbose: boolean +): Promise { + console.log(header(`${tierName.charAt(0).toUpperCase() + tierName.slice(1)} Tier`)); + + // Select provider + const providerOptions = CLIPROXY_PROFILES.map((p) => ({ + id: p, + label: p.charAt(0).toUpperCase() + p.slice(1), + })); + const provider = (await InteractivePrompt.selectFromList( + `Provider for ${tierName}:`, + providerOptions + )) as CLIProxyProfileName; + + // Check auth + const providerAccounts = getProviderAccounts(provider as CLIProxyProvider); + if (providerAccounts.length === 0) { + console.log(''); + console.log(warn(`No accounts authenticated for ${provider}`)); + const shouldAuth = await InteractivePrompt.confirm(`Authenticate with ${provider} now?`, { + default: true, + }); + if (!shouldAuth) { + console.log(info(`Skipping auth. Run: ${color(`ccs ${provider} --auth`, 'command')}`)); + return null; + } + const newAccount = await triggerOAuth(provider as CLIProxyProvider, { + add: true, + verbose, + }); + if (!newAccount) { + console.log(fail('Authentication failed')); + process.exit(1); + } + console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`)); + } + + // Select model + let model: string | undefined; + if (supportsModelConfig(provider as CLIProxyProvider)) { + const catalog = getProviderCatalog(provider as CLIProxyProvider); + if (catalog) { + const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) })); + const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); + model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, { + defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, + }); + } + } + if (!model) { + model = await InteractivePrompt.input(`Model name for ${tierName}`, { + validate: (val) => (val ? null : 'Model is required'), + }); + } + + console.log(''); + return { provider, model }; +} + export async function handleCreate( args: string[], backend: CLIProxyBackend = DEFAULT_BACKEND @@ -97,6 +168,71 @@ export async function handleCreate( process.exit(1); } + // Composite mode: select provider+model per tier + if (parsedArgs.composite) { + console.log(info('Composite variant — select provider and model for each tier')); + console.log(''); + + const verbose = args.includes('--verbose'); + const opus = await selectTierConfig('opus', verbose); + if (!opus) { + return; // User cancelled auth + } + const sonnet = await selectTierConfig('sonnet', verbose); + if (!sonnet) { + return; // User cancelled auth + } + const haiku = await selectTierConfig('haiku', verbose); + if (!haiku) { + return; // User cancelled auth + } + + // Select default tier + const tierOptions = [ + { id: 'opus' as const, label: `Opus (${opus.provider}: ${opus.model})` }, + { id: 'sonnet' as const, label: `Sonnet (${sonnet.provider}: ${sonnet.model})` }, + { id: 'haiku' as const, label: `Haiku (${haiku.provider}: ${haiku.model})` }, + ]; + const defaultTier = (await InteractivePrompt.selectFromList( + 'Default tier (ANTHROPIC_MODEL):', + tierOptions + )) as 'opus' | 'sonnet' | 'haiku'; + + console.log(''); + console.log(info(`Creating composite ${getBackendLabel(backend)} variant...`)); + const result = createCompositeVariant({ + name, + defaultTier, + tiers: { opus, sonnet, haiku }, + }); + + if (!result.success) { + console.log(fail(`Failed to create composite variant: ${result.error}`)); + process.exit(1); + } + + console.log(''); + const tiers = result.variant?.tiers; + const tierSummary = tiers + ? `Opus: ${tiers.opus.provider} / ${tiers.opus.model}\n` + + `Sonnet: ${tiers.sonnet.provider} / ${tiers.sonnet.model}\n` + + `Haiku: ${tiers.haiku.provider} / ${tiers.haiku.model}\n` + + `Default: ${defaultTier}` + : ''; + const portInfo = result.variant?.port ? `\nPort: ${result.variant.port}` : ''; + console.log( + infoBox( + `Variant: ${name} (composite)\n${tierSummary}${portInfo}\nConfig: ~/.ccs/config.yaml`, + 'Composite Variant Created' + ) + ); + console.log(''); + console.log(header('Usage')); + console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + console.log(''); + return; + } + // Step 2: Provider selection let provider = parsedArgs.provider; if (!provider) { @@ -260,7 +396,11 @@ export async function handleRemove(args: string[]): Promise { console.log(header('Remove CLIProxy Variant')); console.log(''); console.log('Available variants:'); - variantNames.forEach((n, i) => console.log(` ${i + 1}. ${n} (${variants[n].provider})`)); + variantNames.forEach((n, i) => { + const v = variants[n]; + const label = v.type === 'composite' ? 'composite' : v.provider; + console.log(` ${i + 1}. ${n} (${label})`); + }); console.log(''); name = await InteractivePrompt.input('Variant name to remove', { validate: (val) => { @@ -282,7 +422,16 @@ export async function handleRemove(args: string[]): Promise { const variant = variants[name]; console.log(''); console.log(`Variant '${color(name, 'command')}' will be removed.`); - console.log(` Provider: ${variant.provider}`); + if (variant.type === 'composite') { + console.log(` Type: composite`); + if (variant.tiers) { + console.log(` Opus: ${variant.tiers.opus.provider} / ${variant.tiers.opus.model}`); + console.log(` Sonnet: ${variant.tiers.sonnet.provider} / ${variant.tiers.sonnet.model}`); + console.log(` Haiku: ${variant.tiers.haiku.provider} / ${variant.tiers.haiku.model}`); + } + } else { + console.log(` Provider: ${variant.provider}`); + } if (variant.port) { console.log(` Port: ${variant.port}`); } @@ -305,3 +454,215 @@ export async function handleRemove(args: string[]): Promise { console.log(ok(`Variant removed: ${name}`)); console.log(''); } + +export async function handleEdit( + args: string[], + backend: CLIProxyBackend = DEFAULT_BACKEND +): Promise { + await initUI(); + const parsedArgs = parseProfileArgs(args); + const variants = listVariants(); + const variantNames = Object.keys(variants); + + if (variantNames.length === 0) { + console.log(warn('No CLIProxy variants to edit')); + process.exit(0); + } + + let name = parsedArgs.name; + if (!name) { + console.log(header(`Edit ${getBackendLabel(backend)} Variant`)); + console.log(''); + console.log('Available variants:'); + variantNames.forEach((n, i) => { + const v = variants[n]; + const label = v.type === 'composite' ? 'composite' : v.provider; + console.log(` ${i + 1}. ${n} (${label})`); + }); + console.log(''); + name = await InteractivePrompt.input('Variant name to edit', { + validate: (val) => { + if (!val) return 'Variant name is required'; + if (!variantNames.includes(val)) return `Variant '${val}' not found`; + return null; + }, + }); + } + + if (!variantNames.includes(name)) { + console.log(fail(`Variant '${name}' not found`)); + console.log(''); + console.log('Available variants:'); + variantNames.forEach((n) => console.log(` - ${n}`)); + process.exit(1); + } + + const variant = variants[name]; + + // If not composite, use existing updateVariant() flow (interactive prompts) + if (variant.type !== 'composite') { + console.log(header(`Edit Variant: ${name}`)); + console.log(''); + console.log(`Current provider: ${variant.provider}`); + if (variant.model) { + console.log(`Current model: ${variant.model}`); + } + console.log(''); + + const changeProvider = await InteractivePrompt.confirm('Change provider?', { default: false }); + let newProvider: CLIProxyProfileName | undefined = undefined; + if (changeProvider) { + const providerOptions = CLIPROXY_PROFILES.map((p) => ({ + id: p, + label: p.charAt(0).toUpperCase() + p.slice(1), + })); + newProvider = (await InteractivePrompt.selectFromList( + 'Select new provider:', + providerOptions + )) as CLIProxyProfileName; + } + + const providerChanged = !!(newProvider && newProvider !== variant.provider); + if (providerChanged) { + console.log(info('Provider changed. Model selection is required.')); + } + + const changeModel = providerChanged + ? true + : await InteractivePrompt.confirm('Change model?', { default: false }); + let newModel = variant.model || ''; + if (changeModel) { + const providerForModel = newProvider || (variant.provider as CLIProxyProfileName); + if (supportsModelConfig(providerForModel as CLIProxyProvider)) { + const catalog = getProviderCatalog(providerForModel as CLIProxyProvider); + if (catalog) { + const modelOptions = catalog.models.map((m) => ({ + id: m.id, + label: formatModelOption(m), + })); + const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); + newModel = await InteractivePrompt.selectFromList('Select new model:', modelOptions, { + defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, + }); + } + } else { + newModel = await InteractivePrompt.input('New model name', { + validate: (val) => (val ? null : 'Model is required'), + }); + } + } + + console.log(''); + console.log(info(`Updating ${getBackendLabel(backend)} variant...`)); + // Use existing updateVariant from variant-service for single-provider variants + const { updateVariant } = await import('../../cliproxy/services/variant-service'); + const result = updateVariant(name, { + provider: newProvider, + model: changeModel ? newModel : undefined, + }); + + if (!result.success) { + console.log(fail(`Failed to update variant: ${result.error}`)); + process.exit(1); + } + + console.log(''); + console.log(ok(`Variant updated: ${name}`)); + console.log(''); + return; + } + + // Composite variant edit flow + console.log(header(`Edit Composite Variant: ${name}`)); + console.log(''); + if (!variant.tiers) { + console.log(fail('Invalid composite variant: missing tier configuration')); + process.exit(1); + } + + console.log(info('Current tier configuration:')); + console.log(` Opus: ${variant.tiers.opus.provider} / ${variant.tiers.opus.model}`); + console.log(` Sonnet: ${variant.tiers.sonnet.provider} / ${variant.tiers.sonnet.model}`); + console.log(` Haiku: ${variant.tiers.haiku.provider} / ${variant.tiers.haiku.model}`); + console.log(` Default: ${variant.default_tier}`); + console.log(''); + + const verbose = args.includes('--verbose'); + const updatedTiers: Partial> = {}; + + // Ask per-tier edits + for (const tierName of ['opus', 'sonnet', 'haiku'] as const) { + const shouldEdit = await InteractivePrompt.confirm(`Edit ${tierName} tier?`, { + default: false, + }); + if (shouldEdit) { + const newConfig = await selectTierConfig(tierName, verbose); + if (!newConfig) { + console.log(fail('Edit cancelled')); + process.exit(0); + } + updatedTiers[tierName] = newConfig; + } + } + + // Ask for default tier change + let newDefaultTier = variant.default_tier; + const changeDefault = await InteractivePrompt.confirm('Change default tier?', { default: false }); + if (changeDefault) { + const finalTiers = { + opus: updatedTiers.opus ?? variant.tiers.opus, + sonnet: updatedTiers.sonnet ?? variant.tiers.sonnet, + haiku: updatedTiers.haiku ?? variant.tiers.haiku, + }; + const tierOptions = [ + { + id: 'opus' as const, + label: `Opus (${finalTiers.opus.provider}: ${finalTiers.opus.model})`, + }, + { + id: 'sonnet' as const, + label: `Sonnet (${finalTiers.sonnet.provider}: ${finalTiers.sonnet.model})`, + }, + { + id: 'haiku' as const, + label: `Haiku (${finalTiers.haiku.provider}: ${finalTiers.haiku.model})`, + }, + ]; + newDefaultTier = (await InteractivePrompt.selectFromList( + 'Default tier (ANTHROPIC_MODEL):', + tierOptions + )) as 'opus' | 'sonnet' | 'haiku'; + } + + console.log(''); + console.log(info(`Updating composite ${getBackendLabel(backend)} variant...`)); + const result = updateCompositeVariant(name, { + tiers: updatedTiers, + defaultTier: changeDefault ? newDefaultTier : undefined, + }); + + if (!result.success) { + console.log(fail(`Failed to update composite variant: ${result.error}`)); + process.exit(1); + } + + console.log(''); + const finalVariant = result.variant; + if (finalVariant && finalVariant.tiers) { + const tierSummary = + `Opus: ${finalVariant.tiers.opus.provider} / ${finalVariant.tiers.opus.model}\n` + + `Sonnet: ${finalVariant.tiers.sonnet.provider} / ${finalVariant.tiers.sonnet.model}\n` + + `Haiku: ${finalVariant.tiers.haiku.provider} / ${finalVariant.tiers.haiku.model}\n` + + `Default: ${finalVariant.default_tier}`; + const portInfo = finalVariant.port ? `\nPort: ${finalVariant.port}` : ''; + console.log( + infoBox( + `Variant: ${name} (composite)\n${tierSummary}${portInfo}\nConfig: ~/.ccs/config.yaml`, + 'Composite Variant Updated' + ) + ); + } else { + console.log(ok(`Composite variant updated: ${name}`)); + } + console.log(''); +} diff --git a/src/commands/command-execution-contract.ts b/src/commands/command-execution-contract.ts new file mode 100644 index 00000000..3fff0098 --- /dev/null +++ b/src/commands/command-execution-contract.ts @@ -0,0 +1,30 @@ +/** + * Command Execution Contract + * + * Standardized lifecycle: parse -> validate -> execute -> render + * for CLI command handlers. + */ + +export interface CommandExecutionContract { + parse(rawArgs: string[]): TParsedArgs; + validate(parsedArgs: TParsedArgs): void | Promise; + execute(parsedArgs: TParsedArgs): Promise | TExecutionResult; + render( + result: TExecutionResult, + context: { rawArgs: string[]; parsedArgs: TParsedArgs } + ): Promise | void; +} + +/** + * Run a command through the standard lifecycle. + */ +export async function runCommandWithContract( + rawArgs: string[], + contract: CommandExecutionContract +): Promise<{ parsedArgs: TParsedArgs; result: TExecutionResult }> { + const parsedArgs = contract.parse(rawArgs); + await contract.validate(parsedArgs); + const result = await contract.execute(parsedArgs); + await contract.render(result, { rawArgs, parsedArgs }); + return { parsedArgs, result }; +} diff --git a/src/commands/cursor-command-display.ts b/src/commands/cursor-command-display.ts new file mode 100644 index 00000000..34260d88 --- /dev/null +++ b/src/commands/cursor-command-display.ts @@ -0,0 +1,115 @@ +import type { CursorAuthStatus, CursorDaemonStatus, CursorModel } from '../cursor/types'; +import type { CursorConfig } from '../config/unified-config-types'; +import { color } from '../utils/ui'; + +function printLines(lines: string[]): void { + for (const line of lines) { + console.log(line); + } +} + +export function renderCursorHelp(): number { + printLines([ + 'Cursor IDE Integration', + '', + 'Usage: ccs cursor [options]', + '', + 'Subcommands:', + ' auth Import Cursor IDE authentication token', + ' status Show integration, authentication, and daemon status', + ' models List available models', + ' start Start cursor daemon', + ' stop Stop cursor daemon', + ' enable Enable cursor integration in unified config', + ' disable Disable cursor integration in unified config', + ' help Show this help message', + '', + 'Auth options:', + ' ccs cursor auth # Auto-detect from Cursor SQLite', + ' ccs cursor auth --manual --token --machine-id ', + '', + 'Quick start:', + ' 1. ccs cursor enable # Enable integration', + ' 2. ccs cursor auth # Import Cursor IDE token', + ' 3. ccs cursor start # Start daemon', + '', + 'Or use the web UI: ccs config -> Cursor page', + '', + ]); + + return 0; +} + +export function renderCursorStatus( + cursorConfig: CursorConfig, + authStatus: CursorAuthStatus, + daemonStatus: CursorDaemonStatus +): void { + console.log('Cursor IDE Status'); + console.log('─────────────────'); + console.log(''); + + const enabledIcon = cursorConfig.enabled ? color('[OK]', 'success') : color('[X]', 'error'); + console.log(`Integration: ${enabledIcon} ${cursorConfig.enabled ? 'Enabled' : 'Disabled'}`); + + const authIcon = authStatus.authenticated ? color('[OK]', 'success') : color('[X]', 'error'); + const expiredSuffix = authStatus.authenticated && authStatus.expired ? ' (expired)' : ''; + const authText = authStatus.authenticated ? `Authenticated${expiredSuffix}` : 'Not authenticated'; + console.log(`Authentication: ${authIcon} ${authText}`); + + if (authStatus.authenticated && authStatus.tokenAge !== undefined) { + console.log(` Token age: ${authStatus.tokenAge} hours`); + if (authStatus.credentials?.authMethod) { + console.log(` Method: ${authStatus.credentials.authMethod}`); + } + } + + const daemonIcon = daemonStatus.running ? color('[OK]', 'success') : color('[X]', 'error'); + console.log(`Daemon: ${daemonIcon} ${daemonStatus.running ? 'Running' : 'Not running'}`); + if (daemonStatus.pid) { + console.log(` PID: ${daemonStatus.pid}`); + } + + console.log(''); + console.log('Configuration:'); + console.log(` Port: ${cursorConfig.port}`); + console.log(` Auto-start: ${cursorConfig.auto_start ? 'Yes' : 'No'}`); + console.log(` Ghost mode: ${cursorConfig.ghost_mode ? 'On' : 'Off'}`); + console.log(''); + + if ( + cursorConfig.enabled && + authStatus.authenticated && + !authStatus.expired && + daemonStatus.running + ) { + return; + } + + console.log('Next steps:'); + if (!cursorConfig.enabled) { + console.log(' - Enable: ccs cursor enable'); + } + if (!authStatus.authenticated || authStatus.expired) { + console.log(' - Auth: ccs cursor auth'); + } + if (!daemonStatus.running) { + console.log(' - Start: ccs cursor start'); + } +} + +export function renderCursorModels(models: CursorModel[], defaultModel: string): void { + console.log('Available Cursor Models'); + console.log('───────────────────────'); + console.log(''); + + for (const model of models) { + const defaultMark = model.id === defaultModel ? ' [DEFAULT]' : ''; + console.log(` ${model.id}${defaultMark}`); + console.log(` Provider: ${model.provider}`); + } + + console.log(''); + console.log('Model selection is request-driven by the calling client.'); + console.log('Dashboard: ccs config -> Cursor page'); +} diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts new file mode 100644 index 00000000..585c395e --- /dev/null +++ b/src/commands/cursor-command.ts @@ -0,0 +1,274 @@ +/** + * Cursor CLI Command + * + * Handles `ccs cursor ` commands. + */ + +import { + autoDetectTokens, + validateToken, + saveCredentials, + checkAuthStatus, + startDaemon, + stopDaemon, + getDaemonStatus, + getAvailableModels, + getDefaultModel, +} from '../cursor'; +import { + getCursorConfig, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../config/unified-config-loader'; +import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types'; +import { renderCursorHelp, renderCursorModels, renderCursorStatus } from './cursor-command-display'; +import { ok, fail, info } from '../utils/ui'; + +/** Valid cursor subcommands — imported by ccs.ts for routing */ +export const CURSOR_SUBCOMMANDS = [ + 'auth', + 'status', + 'models', + 'start', + 'stop', + 'enable', + 'disable', + 'help', + '--help', + '-h', +] as const; + +/** + * Handle cursor subcommand. + */ +export async function handleCursorCommand(args: string[]): Promise { + const subcommand = args[0]; + + switch (subcommand) { + case 'auth': + return handleAuth(args.slice(1)); + case 'status': + return handleStatus(); + case 'models': + return handleModels(); + case 'start': + return handleStart(); + case 'stop': + return handleStop(); + case 'enable': + return handleEnable(); + case 'disable': + return handleDisable(); + case undefined: + case 'help': + case '--help': + case '-h': + return handleHelp(); + default: + console.error(fail(`Unknown subcommand: ${subcommand}`)); + console.error(''); + void handleHelp(); // Print help but keep exit code 1 + return 1; + } +} + +function handleHelp(): number { + return renderCursorHelp(); +} + +function parseOptionValue(args: string[], key: string): string | undefined { + const exactIndex = args.findIndex((arg) => arg === key); + if (exactIndex !== -1 && args[exactIndex + 1]) { + return args[exactIndex + 1]; + } + + const prefix = `${key}=`; + const withEquals = args.find((arg) => arg.startsWith(prefix)); + if (withEquals) { + return withEquals.slice(prefix.length); + } + + return undefined; +} + +/** + * Handle auth subcommand. + */ +async function handleAuth(args: string[]): Promise { + const manual = args.includes('--manual'); + + if (manual) { + const accessToken = + parseOptionValue(args, '--token') ?? parseOptionValue(args, '--access-token') ?? ''; + const machineId = + parseOptionValue(args, '--machine-id') ?? parseOptionValue(args, '--machineId') ?? ''; + + if (!accessToken || !machineId) { + console.error( + fail( + 'Manual auth requires both token and machine ID.\n\nExample:\n ccs cursor auth --manual --token --machine-id ' + ) + ); + return 1; + } + + if (!validateToken(accessToken, machineId)) { + console.error(fail('Invalid token or machine ID format')); + return 1; + } + + saveCredentials({ + accessToken, + machineId, + authMethod: 'manual', + importedAt: new Date().toISOString(), + }); + + console.log(ok('Cursor credentials imported (manual mode)')); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Enable integration: ccs cursor enable'); + console.log(' 2. Start daemon: ccs cursor start'); + return 0; + } + + console.log(info('Importing Cursor IDE authentication...')); + console.log(''); + console.log(info('Attempting auto-detection...')); + + const autoResult = autoDetectTokens(); + + if (autoResult.found && autoResult.accessToken && autoResult.machineId) { + saveCredentials({ + accessToken: autoResult.accessToken, + machineId: autoResult.machineId, + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }); + + console.log(ok('Auto-detected Cursor credentials')); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Enable integration: ccs cursor enable'); + console.log(' 2. Start daemon: ccs cursor start'); + console.log(' 3. Check status: ccs cursor status'); + return 0; + } + + console.log(''); + console.error(fail(`Auto-detection failed: ${autoResult.error ?? 'Unknown error'}`)); + console.log(''); + console.log('Manual fallback:'); + console.log(' ccs cursor auth --manual --token --machine-id '); + console.log(''); + + return 1; +} + +async function handleStatus(): Promise { + const cursorConfig = getCursorConfig(); + const authStatus = checkAuthStatus(); + const daemonStatus = await getDaemonStatus(cursorConfig.port); + renderCursorStatus(cursorConfig, authStatus, daemonStatus); + return 0; +} + +async function handleModels(): Promise { + const cursorConfig = getCursorConfig(); + const models = await getAvailableModels(cursorConfig.port); + const defaultModel = getDefaultModel(); + renderCursorModels(models, defaultModel); + return 0; +} + +/** + * Handle start subcommand. + */ +async function handleStart(): Promise { + const cursorConfig = getCursorConfig(); + + if (!cursorConfig.enabled) { + console.error(fail('Cursor integration is disabled. Run: ccs cursor enable')); + return 1; + } + + const authStatus = checkAuthStatus(); + if (!authStatus.authenticated) { + console.error(fail('Not authenticated. Run: ccs cursor auth')); + return 1; + } + if (authStatus.expired) { + console.error(fail('Credentials expired. Run: ccs cursor auth')); + return 1; + } + + console.log(info(`Starting cursor daemon on port ${cursorConfig.port}...`)); + + const result = await startDaemon({ + port: cursorConfig.port, + ghost_mode: cursorConfig.ghost_mode, + }); + + if (result.success) { + console.log(ok(`Daemon started (PID: ${result.pid})`)); + return 0; + } else { + console.error(fail(result.error || 'Failed to start daemon')); + return 1; + } +} + +/** + * Handle stop subcommand. + */ +async function handleStop(): Promise { + console.log(info('Stopping cursor daemon...')); + + const result = await stopDaemon(); + + if (result.success) { + console.log(ok('Daemon stopped')); + return 0; + } else { + console.error(fail(result.error || 'Failed to stop daemon')); + return 1; + } +} + +/** + * Handle enable subcommand. + */ +async function handleEnable(): Promise { + const config = loadOrCreateUnifiedConfig(); + + if (!config.cursor) { + config.cursor = { ...DEFAULT_CURSOR_CONFIG }; + } + + config.cursor.enabled = true; + saveUnifiedConfig(config); + + console.log(ok('Cursor integration enabled')); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Authenticate: ccs cursor auth'); + console.log(' 2. Start daemon: ccs cursor start'); + console.log(' 3. Check status: ccs cursor status'); + + return 0; +} + +/** + * Handle disable subcommand. + */ +async function handleDisable(): Promise { + const config = loadOrCreateUnifiedConfig(); + + if (config.cursor) { + config.cursor.enabled = false; + saveUnifiedConfig(config); + } + + console.log(ok('Cursor integration disabled')); + return 0; +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 50fc685b..ecfdbd0a 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -168,7 +168,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ], [ ['ccs gemini', 'Google Gemini (gemini-2.5-pro or 3-pro)'], - ['ccs codex', 'OpenAI Codex (gpt-5.1-codex-max)'], + ['ccs codex', 'OpenAI Codex (supports -medium/-high/-xhigh model suffixes)'], ['ccs agy', 'Antigravity (Claude/Gemini models)'], ['ccs qwen', 'Qwen Code (qwen3-coder)'], ['ccs kiro', 'Kiro (AWS CodeWhisperer Claude models)'], @@ -187,11 +187,16 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'ccs --thinking ', 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', ], + ['ccs codex --effort ', 'Set codex reasoning effort (medium/high/xhigh)'], ['ccs --1m', 'Enable 1M token context window'], ['ccs --no-1m', 'Disable 1M context (use 200K default)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], ['ccs --port-forward', 'Force port-forwarding mode (skip prompt)'], + ['ccs kiro --auth --kiro-auth-method aws', 'Kiro via AWS Builder ID (device code)'], + ['ccs kiro --auth --kiro-auth-method aws-authcode', 'Kiro via AWS auth code flow'], + ['ccs kiro --auth --kiro-auth-method google', 'Kiro via Google OAuth'], + ['ccs kiro --auth --kiro-auth-method github', 'Kiro via GitHub OAuth (Dashboard flow)'], ['ccs kiro --import', 'Import token from Kiro IDE'], ['ccs kiro --incognito', 'Use incognito browser (default: normal)'], ['ccs codex "explain code"', 'Use with prompt'], @@ -220,6 +225,28 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ] ); + // ═══════════════════════════════════════════════════════════════════════════ + // MAJOR SECTION 5: Cursor IDE Integration + // ═══════════════════════════════════════════════════════════════════════════ + printMajorSection( + 'Cursor IDE Integration', + [ + 'Use Cursor IDE with Claude Code via cursor proxy daemon', + 'Auto-detects token from Cursor installation', + ], + [ + ['ccs cursor ', 'Use Cursor IDE integration'], + ['ccs cursor auth', 'Import Cursor token'], + ['ccs cursor auth --manual --token --machine-id ', 'Manual token import'], + ['ccs cursor status', 'Show connection status'], + ['ccs cursor models', 'List available models'], + ['ccs cursor start', 'Start proxy daemon'], + ['ccs cursor stop', 'Stop proxy daemon'], + ['ccs cursor enable', 'Enable cursor integration'], + ['ccs cursor disable', 'Disable cursor integration'], + ] + ); + // ═══════════════════════════════════════════════════════════════════════════ // SUB-SECTIONS (simpler styling) // ═══════════════════════════════════════════════════════════════════════════ @@ -313,7 +340,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ]); // W3: Thinking Budget explanation - printSubSection('Extended Thinking (--thinking)', [ + printSubSection('Extended Thinking / Reasoning', [ ['--thinking off', 'Disable extended thinking'], ['--thinking auto', 'Let model decide dynamically'], ['--thinking low', '1K tokens - Quick responses'], @@ -322,8 +349,13 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['--thinking xhigh', '32K tokens - Maximum depth'], ['--thinking ', 'Custom token budget (512-100000)'], ['', ''], + ['--effort ', 'Codex alias for reasoning effort (medium/high/xhigh)'], + ['--effort xhigh', 'Pin Codex effort to xhigh for this run'], + ['', ''], ['Note:', 'Extended thinking allocates compute for step-by-step reasoning'], - ['', 'before responding. Supported: agy, gemini (thinking models).'], + ['', 'before responding.'], + ['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'], + ['', 'Codex model suffixes also pin effort: -medium / -high / -xhigh.'], ]); // Extended Context (1M) diff --git a/src/commands/shell-completion-command.ts b/src/commands/shell-completion-command.ts index f01de47c..65fe4c58 100644 --- a/src/commands/shell-completion-command.ts +++ b/src/commands/shell-completion-command.ts @@ -5,6 +5,72 @@ */ import { initUI, header, ok, fail, color } from '../utils/ui'; +import { + runCommandWithContract, + type CommandExecutionContract, +} from './command-execution-contract'; + +type ShellTarget = 'bash' | 'zsh' | 'fish' | 'powershell' | null; + +interface ShellCompletionParsedArgs { + targetShell: ShellTarget; + force: boolean; +} + +interface ShellCompletionInstallResult { + success: boolean; + alreadyInstalled?: boolean; + message?: string; + reload?: string; +} + +interface ShellCompletionInstallerLike { + install(shell: ShellTarget, options: { force: boolean }): ShellCompletionInstallResult; +} + +export function parseShellCompletionArgs(args: string[]): ShellCompletionParsedArgs { + let targetShell: ShellTarget = null; + const force = args.includes('--force') || args.includes('-f'); + + if (args.includes('--bash')) targetShell = 'bash'; + else if (args.includes('--zsh')) targetShell = 'zsh'; + else if (args.includes('--fish')) targetShell = 'fish'; + else if (args.includes('--powershell')) targetShell = 'powershell'; + + return { targetShell, force }; +} + +export function createShellCompletionCommandContract( + installer: ShellCompletionInstallerLike +): CommandExecutionContract { + return { + parse: parseShellCompletionArgs, + validate: () => { + // No validation at this stage to preserve existing behavior exactly. + }, + execute: (parsed) => installer.install(parsed.targetShell, { force: parsed.force }), + render: (result, context) => { + if (result.alreadyInstalled && !context.parsedArgs.force) { + console.log(ok('Shell completion already installed')); + console.log(` Use ${color('--force', 'warning')} to reinstall`); + console.log(''); + return; + } + + console.log(ok('Shell completion installed successfully!')); + console.log(''); + console.log(result.message); + console.log(''); + console.log(color('To activate:', 'info')); + console.log(` ${result.reload}`); + console.log(''); + console.log(color('Then test:', 'info')); + console.log(' ccs # See available profiles'); + console.log(' ccs auth # See auth subcommands'); + console.log(''); + }, + }; +} /** * Handle shell completion command @@ -16,38 +82,10 @@ export async function handleShellCompletionCommand(args: string[]): Promise # See available profiles'); - console.log(' ccs auth # See auth subcommands'); - console.log(''); + const contract = createShellCompletionCommandContract(installer); + await runCommandWithContract(args, contract); } catch (error) { const err = error as Error; console.error(fail(`Error: ${err.message}`)); diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index 7e53f4c3..8705d63a 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -11,6 +11,8 @@ export const RESERVED_PROFILE_NAMES = [ 'iflow', // Copilot API (GitHub Copilot proxy) 'copilot', + // Cursor IDE (Cursor proxy daemon) + 'cursor', // CLI commands and special names 'default', 'config', diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 2fb49f77..5f639d47 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -15,6 +15,7 @@ import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION, DEFAULT_COPILOT_CONFIG, + DEFAULT_CURSOR_CONFIG, DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, @@ -25,7 +26,9 @@ import { ThinkingConfig, DashboardAuthConfig, ImageAnalysisConfig, + CursorConfig, } from './unified-config-types'; +import { validateCompositeTiers } from '../cliproxy/composite-validator'; import { isUnifiedConfigEnabled } from './feature-flags'; const CONFIG_YAML = 'config.yaml'; @@ -204,6 +207,27 @@ export function loadUnifiedConfig(): UnifiedConfig | null { } } +/** + * Validate composite variant provider strings. + * Warns about invalid providers in composite variant configurations. + */ +function validateCompositeVariants(config: UnifiedConfig): void { + const variants = config.cliproxy?.variants; + if (!variants) return; + + for (const [name, variant] of Object.entries(variants)) { + if ('type' in variant && variant.type === 'composite') { + const error = validateCompositeTiers(variant.tiers, { + defaultTier: variant.default_tier, + requireAllTiers: true, + }); + if (error) { + console.warn(`[!] Variant '${name}': invalid composite config (${error})`); + } + } + } +} + /** * Merge partial config with defaults. * Preserves existing data while filling in missing sections. @@ -276,6 +300,13 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit, model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, }, + // Cursor config - disabled by default, merge with defaults + cursor: { + enabled: partial.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, + port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, + auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start, + ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode, + }, // Global env - injected into all non-Claude subscription profiles global_env: { enabled: partial.global_env?.enabled ?? true, @@ -404,7 +435,10 @@ export function loadOrCreateUnifiedConfig(): UnifiedConfig { const existing = loadUnifiedConfig(); if (existing) { // Merge with defaults to fill any missing sections - return mergeWithDefaults(existing); + const merged = mergeWithDefaults(existing); + // Validate composite variant provider strings + validateCompositeVariants(merged); + return merged; } // Create empty config @@ -559,6 +593,23 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Cursor section (Cursor IDE proxy daemon) + if (config.cursor) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Cursor: Cursor IDE proxy daemon'); + lines.push('# Enables Cursor IDE integration via local proxy daemon.'); + lines.push('#'); + lines.push('# enabled: Enable/disable Cursor integration (default: false)'); + lines.push('# port: Port for cursor proxy daemon (default: 20129)'); + lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)'); + lines.push('# ghost_mode: Disable telemetry for privacy (default: true)'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + // Global env section if (config.global_env) { lines.push('# ----------------------------------------------------------------------------'); @@ -587,7 +638,9 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push('# Thinking: Extended thinking/reasoning budget configuration'); lines.push('# Controls reasoning depth for supported providers (agy, gemini, codex).'); lines.push('#'); - lines.push('# Modes: auto (use tier_defaults), off (disable), manual (--thinking flag only)'); + lines.push( + '# Modes: auto (use tier_defaults), off (disable), manual (--thinking/--effort flags)' + ); lines.push('# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto'); lines.push('# Override: Set global override value (number or level name)'); lines.push('# Provider overrides: Per-provider tier defaults'); @@ -900,3 +953,12 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig { config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, }; } + +/** + * Get cursor configuration. + * Returns defaults if not configured. + */ +export function getCursorConfig(): CursorConfig { + const config = loadOrCreateUnifiedConfig(); + return config.cursor ?? { ...DEFAULT_CURSOR_CONFIG }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1cdc945f..a9faf6d6 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -21,6 +21,21 @@ */ export const UNIFIED_CONFIG_VERSION = 8; +/** + * Supported CLIProxy providers. + * Includes all OAuth-based providers supported by CLIProxyAPI. + */ +export const CLIPROXY_SUPPORTED_PROVIDERS = [ + 'gemini', + 'codex', + 'agy', + 'qwen', + 'iflow', + 'kiro', + 'ghcp', + 'claude', +] as const; + /** * Account configuration (formerly in profiles.json). * Represents an isolated Claude instance via CLAUDE_CONFIG_DIR. @@ -72,6 +87,51 @@ export interface CLIProxyVariantConfig { auth?: CLIProxyAuthConfig; } +/** + * Per-tier provider+model mapping for composite variants. + */ +export interface CompositeTierConfig { + /** Provider for this tier */ + provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp' | 'claude'; + /** Model ID to use for this tier */ + model: string; + /** Account nickname (optional, references oauth_accounts) */ + account?: string; + /** Fallback provider+model if primary fails */ + fallback?: { + provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp' | 'claude'; + model: string; + account?: string; + }; + /** Per-tier thinking budget override (e.g. 'xhigh', 'medium', 'off') */ + thinking?: string; +} + +/** + * Composite variant configuration. + * Mixes different providers per Claude tier (opus, sonnet, haiku) in a single profile. + * Uses CLIProxyAPI root endpoints (/v1/messages) for model-based routing + * instead of provider-specific endpoints (/api/provider/{provider}). + */ +export interface CompositeVariantConfig { + /** Discriminator for composite type */ + type: 'composite'; + /** Which tier ANTHROPIC_MODEL equals (default must be one of the three) */ + default_tier: 'opus' | 'sonnet' | 'haiku'; + /** Per-tier provider+model mapping */ + tiers: { + opus: CompositeTierConfig; + sonnet: CompositeTierConfig; + haiku: CompositeTierConfig; + }; + /** Path to settings file */ + settings?: string; + /** Shared port for the composite profile */ + port?: number; + /** Per-variant auth override (optional) */ + auth?: CLIProxyAuthConfig; +} + /** * CLIProxy authentication configuration. * Allows customization of API key and management secret for CLIProxyAPI. @@ -122,8 +182,8 @@ export interface CLIProxyConfig { oauth_accounts: OAuthAccounts; /** Built-in providers (read-only, for reference) */ providers: readonly string[]; - /** User-defined provider variants */ - variants: Record; + /** User-defined provider variants (single-provider or composite) */ + variants: Record; /** Logging configuration (disabled by default) */ logging?: CLIProxyLoggingConfig; /** Kiro: disable incognito browser mode (use normal browser to save credentials) */ @@ -231,6 +291,21 @@ export interface CopilotConfig { haiku_model?: string; } +/** + * Cursor IDE integration configuration. + * Enables Cursor IDE usage via cursor proxy daemon. + */ +export interface CursorConfig { + /** Enable Cursor integration (default: false) */ + enabled: boolean; + /** Port for cursor proxy daemon (default: 20129) */ + port: number; + /** Auto-start daemon when CCS starts (default: false) */ + auto_start: boolean; + /** Enable ghost mode to disable telemetry (default: true) */ + ghost_mode: boolean; +} + /** * Remote proxy configuration. * Connect to a remote CLIProxyAPI instance instead of spawning local binary. @@ -610,6 +685,8 @@ export interface UnifiedConfig { global_env?: GlobalEnvConfig; /** Copilot API configuration (GitHub Copilot proxy) */ copilot?: CopilotConfig; + /** Cursor IDE configuration (Cursor proxy daemon) */ + cursor?: CursorConfig; /** CLIProxy server configuration for remote/local mode */ cliproxy_server?: CliproxyServerConfig; /** Quota management configuration (v7+) */ @@ -637,6 +714,17 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { model: 'gpt-4.1', // Free tier compatible }; +/** + * Default Cursor configuration. + * Disabled by default, ghost mode enabled for privacy. + */ +export const DEFAULT_CURSOR_CONFIG: CursorConfig = { + enabled: false, + port: 20129, + auto_start: false, + ghost_mode: true, +}; + /** * Default CLIProxy server configuration. * Local mode by default - remote must be explicitly enabled. @@ -672,7 +760,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { cliproxy: { backend: 'plus', oauth_accounts: {}, - providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'], + providers: [...CLIPROXY_SUPPORTED_PROVIDERS], variants: {}, logging: { enabled: false, @@ -709,6 +797,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { env: { ...DEFAULT_GLOBAL_ENV }, }, copilot: { ...DEFAULT_COPILOT_CONFIG }, + cursor: { ...DEFAULT_CURSOR_CONFIG }, cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG }, quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG }, thinking: { ...DEFAULT_THINKING_CONFIG }, diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts new file mode 100644 index 00000000..7440e983 --- /dev/null +++ b/src/cursor/cursor-auth.ts @@ -0,0 +1,314 @@ +/** + * Cursor IDE Authentication Handler + * + * Handles token import and authentication for Cursor IDE integration. + * Supports auto-detection from Cursor's SQLite database. + * + * Token Location: + * - Linux: ~/.config/Cursor/User/globalStorage/state.vscdb + * - macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb + * - Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb + * + * Database Keys: + * - cursorAuth/accessToken: Access token + * - storage.serviceMachineId: Machine ID for checksum + */ + +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { CursorCredentials, CursorAuthStatus, AutoDetectResult } from './types'; +import { getCcsDir } from '../utils/config-manager'; + +/** + * Get platform-specific path to Cursor's state.vscdb + */ +export function getTokenStoragePath(): string { + const platform = process.platform; + const home = os.homedir(); + + if (platform === 'win32') { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + return path.join(appData, 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + } else if (platform === 'darwin') { + return path.join( + home, + 'Library', + 'Application Support', + 'Cursor', + 'User', + 'globalStorage', + 'state.vscdb' + ); + } else { + // Linux + return path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + } +} + +/** + * Query Cursor's SQLite database using sqlite3 CLI + */ +function queryStateDb(dbPath: string, key: string): string | null { + try { + // Escape single quotes to prevent SQL injection + const sanitizedKey = key.replace(/'/g, "''"); + const result = execFileSync( + 'sqlite3', + [dbPath, `SELECT value FROM itemTable WHERE key='${sanitizedKey}'`], + { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'ignore'] } + ).trim(); + return result || null; + } catch (err) { + // Check if sqlite3 is not installed + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + // sqlite3 not found - could log this if needed + return null; + } + return null; + } +} + +/** + * Auto-detect tokens from Cursor's SQLite database + */ +export function autoDetectTokens(): AutoDetectResult { + // sqlite3 CLI is not bundled with Windows + if (process.platform === 'win32') { + return { + found: false, + error: + 'Auto-detection is not supported on Windows. Please import tokens manually using ccs cursor auth --manual.', + }; + } + + const dbPath = getTokenStoragePath(); + + // Check if database exists + if (!fs.existsSync(dbPath)) { + return { + found: false, + error: + 'Cursor state database not found. Make sure Cursor IDE is installed and you are logged in.', + }; + } + + // Try to query access token + const accessToken = queryStateDb(dbPath, 'cursorAuth/accessToken'); + if (!accessToken) { + return { + found: false, + error: 'Access token not found in database. Please log in to Cursor IDE first.', + }; + } + + // Try to query machine ID + const machineId = queryStateDb(dbPath, 'storage.serviceMachineId'); + if (!machineId) { + return { + found: false, + error: 'Machine ID not found in database.', + }; + } + + return { + found: true, + accessToken, + machineId, + }; +} + +/** + * Validate token and machine ID format + */ +export function validateToken(accessToken: string, machineId: string): boolean { + // Basic validation + if (!accessToken || typeof accessToken !== 'string') { + return false; + } + + if (!machineId || typeof machineId !== 'string') { + return false; + } + + // Token format validation (Cursor tokens are typically long strings) + if (accessToken.length < 50) { + return false; + } + + // Machine ID format validation (UUID without hyphens = exactly 32 hex chars) + const hexRegex = /^[a-f0-9]{32}$/i; + if (!hexRegex.test(machineId.replace(/-/g, ''))) { + return false; + } + + return true; +} + +/** + * Extract user info from token if possible + * Cursor tokens may contain encoded user info as JWT + */ +export function extractUserInfo( + accessToken: string +): { email?: string; userId?: string; exp?: number } | null { + try { + // Try to decode as JWT + const parts = accessToken.split('.'); + if (parts.length === 3) { + let payload = parts[1]; + // Add padding if needed + while (payload.length % 4) { + payload += '='; + } + const decoded = JSON.parse( + Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString() + ) as Record; + + const email = typeof decoded.email === 'string' ? decoded.email : undefined; + const userId = + typeof decoded.sub === 'string' + ? decoded.sub + : typeof decoded.user_id === 'string' + ? decoded.user_id + : undefined; + const exp = typeof decoded.exp === 'number' ? decoded.exp : undefined; + + // If all claims are undefined, treat as if JWT parsing failed + if (!email && !userId && exp === undefined) return null; + + return { email, userId, exp }; + } + } catch { + // Token is not a JWT, that's okay + } + + return null; +} + +/** + * Get path to credentials file + */ +export function getCredentialsPath(): string { + return path.join(getCcsDir(), 'cursor', 'credentials.json'); +} + +/** + * Save credentials to CCS config directory + */ +export function saveCredentials(credentials: CursorCredentials): void { + const credPath = getCredentialsPath(); + const dir = path.dirname(credPath); + + // Ensure directory exists with restrictive permissions + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Write credentials with restrictive permissions + fs.writeFileSync(credPath, JSON.stringify(credentials, null, 2), { + encoding: 'utf8', + mode: 0o600, + }); +} + +/** + * Load credentials from CCS config directory + */ +export function loadCredentials(): CursorCredentials | null { + const credPath = getCredentialsPath(); + + if (!fs.existsSync(credPath)) { + return null; + } + + try { + const raw = fs.readFileSync(credPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + + // Basic validation + if ( + typeof parsed === 'object' && + parsed !== null && + 'accessToken' in parsed && + 'machineId' in parsed && + 'authMethod' in parsed && + 'importedAt' in parsed + ) { + // Type validation + if ( + typeof parsed.accessToken !== 'string' || + typeof parsed.machineId !== 'string' || + typeof parsed.importedAt !== 'string' || + (parsed.authMethod !== 'auto-detect' && parsed.authMethod !== 'manual') + ) { + return null; + } + + return parsed as CursorCredentials; + } + + return null; + } catch { + return null; + } +} + +/** + * Check authentication status + */ +export function checkAuthStatus(): CursorAuthStatus { + const credentials = loadCredentials(); + + if (!credentials) { + return { authenticated: false }; + } + + // Validate credentials are still valid format + if (!validateToken(credentials.accessToken, credentials.machineId)) { + return { authenticated: false }; + } + + // Try to get token expiry from JWT exp claim + let tokenAge: number | undefined; + let expired = false; + const userInfo = extractUserInfo(credentials.accessToken); + + if (userInfo?.exp) { + // Use JWT exp claim for expiry detection + const now = Math.floor(Date.now() / 1000); + expired = now >= userInfo.exp; + } + + // Always use importedAt for tokenAge (more reliable than reverse-engineering JWT lifetime) + const TOKEN_EXPIRY_HOURS = 24; + const importedDate = new Date(credentials.importedAt); + if (!isNaN(importedDate.getTime())) { + const now = new Date(); + tokenAge = Math.floor((now.getTime() - importedDate.getTime()) / (1000 * 60 * 60)); + // Only set expired from importedAt if JWT exp was not available + if (userInfo?.exp === undefined) { + expired = tokenAge >= TOKEN_EXPIRY_HOURS; + } + } + + return { + authenticated: true, + credentials, + tokenAge, + expired, + }; +} + +/** + * Delete credentials file + */ +export function deleteCredentials(): boolean { + try { + fs.unlinkSync(getCredentialsPath()); + return true; + } catch { + return false; + } +} diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts new file mode 100644 index 00000000..2fcf0afd --- /dev/null +++ b/src/cursor/cursor-daemon-entry.ts @@ -0,0 +1,283 @@ +/** + * Cursor Daemon Entry + * + * Dedicated child-process entrypoint for local OpenAI-compatible Cursor proxy. + */ + +import * as http from 'http'; +import { Readable } from 'stream'; +import { CursorExecutor } from './cursor-executor'; +import { checkAuthStatus } from './cursor-auth'; +import { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS } from './cursor-models'; +import type { CursorTool } from './cursor-protobuf-schema'; + +interface DaemonRuntimeOptions { + port: number; + ghostMode: boolean; +} + +interface OpenAIMessage { + role: string; + content?: string | Array<{ type: string; text?: string }> | null; + name?: string; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; +} + +interface NormalizedOpenAIMessage { + role: string; + content: string | Array<{ type: string; text?: string }>; + name?: string; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; +} + +interface OpenAIChatRequest { + model?: string; + stream?: boolean; + reasoning_effort?: string; + tools?: CursorTool[]; + messages?: OpenAIMessage[]; +} + +const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB + +function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +function readJsonBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > MAX_BODY_SIZE) { + req.destroy(); + reject(new Error('Request body too large (max 10MB)')); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) { + resolve({}); + return; + } + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error('Invalid JSON in request body')); + } + }); + + req.on('error', reject); + }); +} + +function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] { + if (!Array.isArray(raw)) { + throw new Error('messages must be an array'); + } + + return raw.map((message, index) => { + if (typeof message !== 'object' || message === null) { + throw new Error(`messages[${index}] must be an object`); + } + + const m = message as Record; + if (typeof m.role !== 'string' || !m.role) { + throw new Error(`messages[${index}].role must be a non-empty string`); + } + + const content = m.content; + if ( + content !== undefined && + content !== null && + typeof content !== 'string' && + !Array.isArray(content) + ) { + throw new Error(`messages[${index}].content must be string, array, or null`); + } + + return { + role: m.role, + content: (content ?? '') as NormalizedOpenAIMessage['content'], + name: typeof m.name === 'string' ? m.name : undefined, + tool_call_id: typeof m.tool_call_id === 'string' ? m.tool_call_id : undefined, + tool_calls: Array.isArray(m.tool_calls) + ? (m.tool_calls as NormalizedOpenAIMessage['tool_calls']) + : undefined, + }; + }); +} + +async function pipeWebResponseToNode(response: Response, res: http.ServerResponse): Promise { + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + if (!response.body) { + res.end(); + return; + } + + const nodeStream = Readable.fromWeb(response.body as unknown as ReadableStream); + + await new Promise((resolve, reject) => { + nodeStream.on('error', reject); + nodeStream.on('end', resolve); + nodeStream.pipe(res); + }); +} + +function parseArgs(argv: string[]): DaemonRuntimeOptions { + let port = 20129; + let ghostMode = true; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--port' && argv[i + 1]) { + const parsed = parseInt(argv[i + 1], 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + port = parsed; + } + i++; + continue; + } + + if (arg === '--ghost-mode' && argv[i + 1]) { + const value = argv[i + 1]; + ghostMode = value !== 'false' && value !== '0'; + i++; + continue; + } + } + + return { port, ghostMode }; +} + +export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Server { + const executor = new CursorExecutor(); + + const server = http.createServer(async (req, res) => { + try { + const method = req.method || 'GET'; + const requestUrl = req.url || '/'; + + if (method === 'GET' && requestUrl === '/health') { + writeJson(res, 200, { ok: true, service: 'cursor-daemon' }); + return; + } + + if (method === 'GET' && requestUrl === '/v1/models') { + const data = DEFAULT_CURSOR_MODELS.map((model) => ({ + id: model.id, + object: 'model', + created: 0, + owned_by: model.provider, + })); + writeJson(res, 200, { object: 'list', data }); + return; + } + + if (method !== 'POST' || requestUrl !== '/v1/chat/completions') { + writeJson(res, 404, { error: 'Not found' }); + return; + } + + const parsedBody = (await readJsonBody(req)) as OpenAIChatRequest; + const messages = normalizeMessages(parsedBody.messages); + const model = + typeof parsedBody.model === 'string' && parsedBody.model + ? parsedBody.model + : DEFAULT_CURSOR_MODEL; + const stream = parsedBody.stream === true; + + const authStatus = checkAuthStatus(); + if (!authStatus.authenticated || !authStatus.credentials) { + writeJson(res, 401, { + error: { + type: 'authentication_error', + message: 'Cursor credentials not found. Run `ccs cursor auth` first.', + }, + }); + return; + } + + if (authStatus.expired) { + writeJson(res, 401, { + error: { + type: 'authentication_error', + message: 'Cursor credentials expired. Run `ccs cursor auth` again.', + }, + }); + return; + } + + const abortController = new AbortController(); + req.on('close', () => { + if (!res.writableEnded) { + abortController.abort(); + } + }); + + const result = await executor.execute({ + model, + stream, + signal: abortController.signal, + credentials: { + accessToken: authStatus.credentials.accessToken, + machineId: authStatus.credentials.machineId, + ghostMode: options.ghostMode, + }, + body: { + messages, + tools: Array.isArray(parsedBody.tools) ? parsedBody.tools : undefined, + reasoning_effort: + typeof parsedBody.reasoning_effort === 'string' + ? parsedBody.reasoning_effort + : undefined, + }, + }); + + await pipeWebResponseToNode(result.response, res); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + writeJson(res, 400, { + error: { + type: 'invalid_request_error', + message, + }, + }); + } + }); + + server.listen(options.port, '127.0.0.1'); + return server; +} + +if (require.main === module) { + const options = parseArgs(process.argv.slice(2)); + const server = startCursorDaemonServer(options); + + const shutdown = () => { + server.close(); + }; + + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +} diff --git a/src/cursor/cursor-daemon-pid.ts b/src/cursor/cursor-daemon-pid.ts new file mode 100644 index 00000000..fceef47a --- /dev/null +++ b/src/cursor/cursor-daemon-pid.ts @@ -0,0 +1,49 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../utils/config-manager'; + +function getCursorDir(): string { + return path.join(getCcsDir(), 'cursor'); +} + +function getPidFilePath(): string { + return path.join(getCursorDir(), 'daemon.pid'); +} + +export function getPidFromFile(): number | null { + const pidFile = getPidFilePath(); + try { + if (fs.existsSync(pidFile)) { + const content = fs.readFileSync(pidFile, 'utf8').trim(); + const pid = parseInt(content, 10); + return isNaN(pid) ? null : pid; + } + } catch { + // Ignore errors + } + return null; +} + +export function writePidToFile(pid: number): void { + const pidFile = getPidFilePath(); + try { + const dir = path.dirname(pidFile); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(pidFile, pid.toString(), { mode: 0o600 }); + } catch { + // Ignore errors + } +} + +export function removePidFile(): void { + const pidFile = getPidFilePath(); + try { + if (fs.existsSync(pidFile)) { + fs.unlinkSync(pidFile); + } + } catch { + // Ignore errors + } +} diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts new file mode 100644 index 00000000..697b6df0 --- /dev/null +++ b/src/cursor/cursor-daemon.ts @@ -0,0 +1,312 @@ +/** + * Cursor Daemon Manager + * + * Manages the cursor daemon lifecycle (start/stop/status). + * Uses CursorExecutor for OpenAI-compatible API proxy to Cursor backend. + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as http from 'http'; +import type { CursorDaemonConfig, CursorDaemonStatus } from './types'; +import { getPidFromFile, writePidToFile, removePidFile } from './cursor-daemon-pid'; +import { verifyDaemonOwnership } from './daemon-process-ownership'; +export { getPidFromFile, writePidToFile, removePidFile } from './cursor-daemon-pid'; + +/** + * Resolve daemon entrypoint candidates for current runtime. + * - Dist runtime always uses JS artifact. + * - Bun source runtime prefers TS, then falls back to JS. + */ +function getDaemonEntrypointCandidates(): string[] { + const jsEntry = path.join(__dirname, 'cursor-daemon-entry.js'); + const tsEntry = path.join(__dirname, 'cursor-daemon-entry.ts'); + const isBunRuntime = process.execPath.toLowerCase().includes('bun'); + const runningFromDist = __filename.endsWith('.js'); + + if (runningFromDist) { + return [jsEntry]; + } + + if (isBunRuntime) { + return [tsEntry, jsEntry]; + } + + return [jsEntry]; +} + +async function resolveDaemonEntrypoint(): Promise { + for (const candidate of getDaemonEntrypointCandidates()) { + try { + await fs.promises.access(candidate, fs.constants.R_OK); + return candidate; + } catch { + // Try next candidate + } + } + + return null; +} + +/** + * Check if cursor daemon is running on the specified port. + * Uses 127.0.0.1 instead of localhost for more reliable local connections. + */ +export async function isDaemonRunning(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/health', + method: 'GET', + timeout: 3000, + }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + + res.on('data', (chunk) => { + body += chunk; + }); + + res.on('end', () => { + if (res.statusCode !== 200) { + resolve(false); + return; + } + + try { + const payload = JSON.parse(body) as { service?: string }; + resolve(payload.service === 'cursor-daemon'); + } catch { + resolve(false); + } + }); + } + ); + + req.on('error', () => { + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +} + +/** + * Get daemon status. + */ +export async function getDaemonStatus(port: number): Promise { + const running = await isDaemonRunning(port); + const pid = getPidFromFile(); + + return { + running, + port, + pid: running ? (pid ?? undefined) : undefined, + }; +} + +/** + * Start the cursor daemon. + * + * @param config Cursor configuration + * @returns Promise that resolves when daemon is ready + */ +export async function startDaemon( + config: CursorDaemonConfig +): Promise<{ success: boolean; pid?: number; error?: string }> { + // Check if already running + if (await isDaemonRunning(config.port)) { + return { success: true, pid: getPidFromFile() ?? undefined }; + } + + // Validate port before interpolation (prevents injection) + if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) { + return { success: false, error: `Invalid port: ${config.port}` }; + } + + const daemonEntry = await resolveDaemonEntrypoint(); + if (!daemonEntry) { + return { + success: false, + error: 'Cursor daemon entrypoint not found. Run `bun run build` and retry.', + }; + } + + return new Promise((resolve) => { + let proc: ChildProcess; + let resolved = false; + + const safeResolve = (result: { success: boolean; pid?: number; error?: string }) => { + if (resolved) return; + resolved = true; + if (checkTimeout) clearTimeout(checkTimeout); + if (!result.success) removePidFile(); + resolve(result); + }; + + let checkTimeout: NodeJS.Timeout | null = null; + + try { + const args = [ + daemonEntry, + '--port', + String(config.port), + '--ghost-mode', + String(config.ghost_mode !== false), + '--ccs-daemon', + ]; + + proc = spawn(process.execPath, args, { + stdio: 'ignore', + detached: true, + }); + + // Unref so parent can exit + proc.unref(); + + if (proc.pid) { + writePidToFile(proc.pid); + } + + // Wait for daemon to be ready (poll for up to 30 seconds) + let attempts = 0; + const maxAttempts = 30; + const pollHealth = async () => { + attempts++; + + if (await isDaemonRunning(config.port)) { + safeResolve({ success: true, pid: proc.pid }); + } else if (attempts >= maxAttempts) { + // Kill orphaned process + if (proc.pid) { + try { + process.kill(proc.pid, 'SIGTERM'); + } catch { + /* already dead */ + } + } + safeResolve({ + success: false, + error: 'Daemon did not start within 30 seconds', + }); + } else { + checkTimeout = setTimeout(pollHealth, 1000); + } + }; + checkTimeout = setTimeout(pollHealth, 1000); + + proc.on('error', (err) => { + safeResolve({ + success: false, + error: `Failed to start daemon: ${err.message}`, + }); + }); + + proc.on('exit', (code, signal) => { + if (code === null) { + safeResolve({ + success: false, + error: `Daemon process was killed by signal ${signal}`, + }); + } else if (code === 0) { + safeResolve({ + success: false, + error: 'Daemon process exited unexpectedly with code 0', + }); + } else { + safeResolve({ + success: false, + error: `Daemon process exited with code ${code}`, + }); + } + }); + } catch (err) { + safeResolve({ + success: false, + error: `Failed to spawn daemon: ${(err as Error).message}`, + }); + } + }); +} + +/** + * Stop the cursor daemon. + */ +export async function stopDaemon(): Promise<{ success: boolean; error?: string }> { + const pid = getPidFromFile(); + + if (!pid) { + // No PID file — daemon is not running or was already stopped + return { success: true }; + } + + try { + const ownership = verifyDaemonOwnership(pid); + if (ownership === 'not-running') { + removePidFile(); + return { success: true }; + } + + if (ownership === 'not-owned') { + // PID was reused by an unrelated process. + removePidFile(); + return { success: true }; + } + + if (ownership === 'unknown') { + return { + success: false, + error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`, + }; + } + + // Send SIGTERM to the process + process.kill(pid, 'SIGTERM'); + + // Wait for process to exit (up to 5 seconds) + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + // Check if process still exists (kill(pid, 0) throws if not) + process.kill(pid, 0); + attempts++; + } catch { + // Process no longer exists + break; + } + } + + // Escalate to SIGKILL only if SIGTERM wait loop exhausted + if (attempts >= 10) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already dead — good + } + } + + removePidFile(); + return { success: true }; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ESRCH') { + // Process doesn't exist + removePidFile(); + return { success: true }; + } + return { + success: false, + error: `Failed to stop daemon: ${error.message}`, + }; + } +} diff --git a/src/cursor/cursor-executor.ts b/src/cursor/cursor-executor.ts new file mode 100644 index 00000000..43ec4d5e --- /dev/null +++ b/src/cursor/cursor-executor.ts @@ -0,0 +1,1048 @@ +/** + * Cursor Executor + * Handles HTTP/2 requests to Cursor API with protobuf encoding/decoding + */ + +import * as crypto from 'crypto'; +import type { IncomingHttpHeaders } from 'http'; +import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js'; +import { buildCursorRequest } from './cursor-translator.js'; +import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js'; + +import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js'; + +/** Executor parameters */ +interface ExecutorParams { + model: string; + body: { + messages: Array<{ + role: string; + content: string | Array<{ type: string; text?: string }>; + name?: string; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + }>; + tools?: CursorTool[]; + reasoning_effort?: string; + }; + stream: boolean; + credentials: CursorCredentials; + signal?: AbortSignal; +} + +/** HTTP/2 response structure */ +interface Http2Response { + status: number; + headers: IncomingHttpHeaders; + body: Buffer; +} + +/** Lazy import http2 */ +let http2Module: typeof import('http2') | null = null; +async function getHttp2() { + if (http2Module) return http2Module; + try { + http2Module = await import('http2'); + return http2Module; + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] http2 module not available, falling back to fetch:', err); + } + return null; + } +} + +/** + * Create error response from JSON error + */ +function createErrorResponse(jsonError: { + error?: { + code?: string; + message?: string; + details?: Array<{ debug?: { details?: { title?: string; detail?: string }; error?: string } }>; + }; +}): Response { + const errorMsg = + jsonError?.error?.details?.[0]?.debug?.details?.title || + jsonError?.error?.details?.[0]?.debug?.details?.detail || + jsonError?.error?.message || + 'API Error'; + + const isRateLimit = jsonError?.error?.code === 'resource_exhausted'; + + return new Response( + JSON.stringify({ + error: { + message: errorMsg, + type: isRateLimit ? 'rate_limit_error' : 'api_error', + code: jsonError?.error?.details?.[0]?.debug?.error || 'unknown', + }, + }), + { + status: isRateLimit ? 429 : 400, + headers: { 'Content-Type': 'application/json' }, + } + ); +} + +export class CursorExecutor { + private readonly baseUrl = 'https://api2.cursor.sh'; + private readonly chatPath = '/aiserver.v1.AiService/StreamChat'; + private readonly CURSOR_CLIENT_VERSION = '2.3.41'; + private readonly CURSOR_USER_AGENT = 'connect-es/1.6.1'; + + buildUrl(): string { + return `${this.baseUrl}${this.chatPath}`; + } + + /** + * Generate checksum using Jyh cipher (time-based XOR with rolling key seed=165) + */ + generateChecksum(machineId: string): string { + const timestamp = Math.floor(Date.now() / 1000000); + // JS bitwise shifts wrap modulo 32, so >>40 and >>32 give wrong results. + // Use Math.trunc division for upper bytes that exceed 32-bit range. + const byteArray = new Uint8Array([ + Math.trunc(timestamp / 2 ** 40) & 0xff, + Math.trunc(timestamp / 2 ** 32) & 0xff, + (timestamp >>> 24) & 0xff, + (timestamp >>> 16) & 0xff, + (timestamp >>> 8) & 0xff, + timestamp & 0xff, + ]); + + let t = 165; + for (let i = 0; i < byteArray.length; i++) { + byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xff; + t = byteArray[i]; + } + + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + let encoded = ''; + + for (let i = 0; i < byteArray.length; i += 3) { + const a = byteArray[i]; + const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0; + const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0; + + encoded += alphabet[a >> 2]; + encoded += alphabet[((a & 3) << 4) | (b >> 4)]; + + if (i + 1 < byteArray.length) { + encoded += alphabet[((b & 15) << 2) | (c >> 6)]; + } + if (i + 2 < byteArray.length) { + encoded += alphabet[c & 63]; + } + } + + return `${encoded}${machineId}`; + } + + buildHeaders(credentials: CursorCredentials): Record { + const accessToken = credentials.accessToken; + const machineId = credentials.machineId; + const ghostMode = credentials.ghostMode !== false; + + if (!machineId) { + throw new Error('Machine ID is required for Cursor API'); + } + + const delimIdx = accessToken.indexOf('::'); + const cleanToken = delimIdx !== -1 ? accessToken.slice(delimIdx + 2) : accessToken; + + if (!cleanToken) { + throw new Error('Access token is empty after parsing'); + } + + return { + authorization: `Bearer ${cleanToken}`, + 'connect-accept-encoding': 'gzip', + 'connect-protocol-version': '1', + 'content-type': 'application/connect+proto', + 'user-agent': this.CURSOR_USER_AGENT, + 'x-amzn-trace-id': `Root=${crypto.randomUUID()}`, + 'x-client-key': crypto.createHash('sha256').update(cleanToken).digest('hex'), + 'x-cursor-checksum': this.generateChecksum(machineId), + 'x-cursor-client-version': this.CURSOR_CLIENT_VERSION, + 'x-cursor-client-type': 'ide', + 'x-cursor-client-os': + process.platform === 'win32' + ? 'windows' + : process.platform === 'darwin' + ? 'macos' + : 'linux', + 'x-cursor-client-arch': process.arch === 'arm64' ? 'aarch64' : 'x64', + 'x-cursor-client-device-type': 'desktop', + 'x-cursor-config-version': crypto.randomUUID(), + 'x-cursor-timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + 'x-ghost-mode': ghostMode ? 'true' : 'false', + 'x-request-id': crypto.randomUUID(), + 'x-session-id': crypto.createHash('sha256').update(cleanToken).digest('hex').substring(0, 36), + }; + } + + transformRequest( + model: string, + body: ExecutorParams['body'], + stream: boolean, + credentials: CursorCredentials + ): Uint8Array { + const translatedBody = buildCursorRequest(model, body, stream, credentials); + const messages = translatedBody.messages || []; + const tools = (translatedBody.tools || body.tools || []) as CursorTool[]; + const reasoningEffort = body.reasoning_effort || null; + return generateCursorBody(messages, model, tools, reasoningEffort); + } + + async makeFetchRequest( + url: string, + headers: Record, + body: Uint8Array, + signal?: AbortSignal + ): Promise { + const response = await fetch(url, { + method: 'POST', + headers, + body, + signal, + }); + + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + return { + status: response.status, + headers: responseHeaders, + body: Buffer.from(await response.arrayBuffer()), + }; + } + + async makeHttp2Request( + url: string, + headers: Record, + body: Uint8Array, + signal?: AbortSignal + ): Promise { + const http2 = await getHttp2(); + if (!http2) { + throw new Error('http2 module not available'); + } + + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const client = http2.connect(`https://${urlObj.host}`); + const chunks: Buffer[] = []; + let responseHeaders: IncomingHttpHeaders = {}; + + client.on('error', (err) => { + client.close(); + reject(err); + }); + + const req = client.request({ + ':method': 'POST', + ':path': urlObj.pathname, + ':authority': urlObj.host, + ':scheme': 'https', + ...headers, + }); + + req.on('response', (hdrs) => { + responseHeaders = hdrs; + }); + req.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + req.on('end', () => { + client.close(); + resolve({ + status: Number(responseHeaders[':status']) || 500, + headers: responseHeaders, + body: Buffer.concat(chunks), + }); + }); + req.on('error', (err) => { + client.close(); + reject(err); + }); + + if (signal) { + const onAbort = () => { + req.close(); + client.close(); + reject(new Error('Request aborted')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + + const cleanup = () => { + signal.removeEventListener('abort', onAbort); + }; + req.on('end', cleanup); + req.on('error', cleanup); + } + + req.write(body); + req.end(); + }); + } + + async execute(params: ExecutorParams): Promise<{ + response: Response; + url: string; + headers: Record; + transformedBody: ExecutorParams['body']; + }> { + const { model, body, stream, credentials, signal } = params; + const url = this.buildUrl(); + const headers = this.buildHeaders(credentials); + const transformedBody = this.transformRequest(model, body, stream, credentials); + + try { + // Streaming requests use incremental HTTP/2 → SSE pipeline + if (stream) { + const response = await this.executeStreaming( + url, + headers, + transformedBody, + model, + body, + signal + ); + return { response, url, headers, transformedBody: body }; + } + + // Non-streaming: buffer entire response then transform to JSON + const http2 = await getHttp2(); + const response = http2 + ? await this.makeHttp2Request(url, headers, transformedBody, signal) + : await this.makeFetchRequest(url, headers, transformedBody, signal); + + if (response.status !== 200) { + const errorText = response.body?.toString() || 'Unknown error'; + const errorResponse = new Response( + JSON.stringify({ + error: { + message: `[${response.status}]: ${errorText}`, + type: 'invalid_request_error', + code: '', + }, + }), + { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + } + ); + return { response: errorResponse, url, headers, transformedBody: body }; + } + + const transformedResponse = this.transformProtobufToJSON(response.body, model, body); + return { response: transformedResponse, url, headers, transformedBody: body }; + } catch (error) { + const errorResponse = new Response( + JSON.stringify({ + error: { + message: (error as Error).message, + type: 'connection_error', + code: '', + }, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ); + return { response: errorResponse, url, headers, transformedBody: body }; + } + } + + /** + * Execute a streaming request, piping HTTP/2 data events through + * StreamingFrameParser for incremental SSE output. + * Falls back to buffered transformProtobufToSSE when http2 is unavailable. + */ + private async executeStreaming( + url: string, + headers: Record, + body: Uint8Array, + model: string, + requestBody: ExecutorParams['body'], + signal?: AbortSignal + ): Promise { + const http2 = await getHttp2(); + if (!http2) { + const response = await this.makeFetchRequest(url, headers, body, signal); + if (response.status !== 200) { + const errorText = response.body?.toString() || 'Unknown error'; + return new Response( + JSON.stringify({ + error: { + message: `[${response.status}]: ${errorText}`, + type: response.status === 429 ? 'rate_limit_error' : 'invalid_request_error', + code: '', + }, + }), + { status: response.status, headers: { 'Content-Type': 'application/json' } } + ); + } + return this.transformProtobufToSSE(response.body, model, requestBody); + } + + const responseId = `chatcmpl-cursor-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const client = http2.connect(`https://${urlObj.host}`); + + client.on('error', (err) => { + client.close(); + reject(err); + }); + + const req = client.request({ + ':method': 'POST', + ':path': urlObj.pathname, + ':authority': urlObj.host, + ':scheme': 'https', + ...headers, + }); + + // Register abort before response headers arrive so cancellation works at all stages + let streamClosed = false; + let streamController: ReadableStreamDefaultController | null = null; + if (signal) { + const onAbort = () => { + streamClosed = true; + // Close the ReadableStream controller so consumers don't hang on reader.read() + if (streamController) { + try { + streamController.close(); + } catch { + /* already closed */ + } + } + req.close(); + client.close(); + }; + signal.addEventListener('abort', onAbort, { once: true }); + const cleanup = () => signal.removeEventListener('abort', onAbort); + req.on('end', cleanup); + req.on('error', cleanup); + } + + req.on('response', (hdrs) => { + const status = Number(hdrs[':status']) || 500; + + if (status !== 200) { + const errorChunks: Buffer[] = []; + req.on('data', (c: Buffer) => errorChunks.push(c)); + req.on('end', () => { + client.close(); + const errorText = Buffer.concat(errorChunks).toString(); + resolve( + new Response( + JSON.stringify({ + error: { + message: `[${status}]: ${errorText}`, + type: 'invalid_request_error', + code: '', + }, + }), + { status, headers: { 'Content-Type': 'application/json' } } + ) + ); + }); + return; + } + + // Status 200: set up incremental streaming pipeline + const parser = new StreamingFrameParser(); + const enc = new TextEncoder(); + const toolCallsMap = new Map< + string, + { + id: string; + type: string; + function: { name: string; arguments: string }; + isLast: boolean; + index: number; + } + >(); + let chunkCount = 0; + let toolCallCount = 0; + + const readable = new ReadableStream({ + start(controller) { + streamController = controller; + + const emitSSE = (data: string) => { + if (streamClosed) return; + controller.enqueue(enc.encode(`data: ${data}\n\n`)); + }; + + const closeStream = () => { + if (streamClosed) return; + streamClosed = true; + try { + controller.close(); + } catch { + /* already closed */ + } + client.close(); + }; + + const buildChunk = (delta: Record, finishReason: string | null) => + JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + }); + + req.on('data', (chunk: Buffer) => { + if (streamClosed) return; + for (const frame of parser.push(chunk)) { + if (frame.type === 'error') { + emitSSE( + JSON.stringify({ + error: { message: frame.message, type: frame.errorType, code: '' }, + }) + ); + emitSSE('[DONE]'); + closeStream(); + return; + } + + if (frame.type === 'toolCall') { + const tc = frame.toolCall; + + // Emit role chunk on first content + if (chunkCount === 0) { + emitSSE(buildChunk({ role: 'assistant', content: '' }, null)); + chunkCount++; + } + + if (toolCallsMap.has(tc.id)) { + const existing = toolCallsMap.get(tc.id); + if (!existing) continue; + existing.function.arguments += tc.function.arguments; + existing.isLast = tc.isLast; + if (tc.function.arguments) { + emitSSE( + buildChunk( + { + tool_calls: [ + { + index: existing.index, + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + }, + ], + }, + null + ) + ); + chunkCount++; + } + } else { + const idx = toolCallCount++; + toolCallsMap.set(tc.id, { ...tc, index: idx }); + emitSSE( + buildChunk( + { + tool_calls: [ + { + index: idx, + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + }, + ], + }, + null + ) + ); + chunkCount++; + } + } + + if (frame.type === 'text') { + const delta = + chunkCount === 0 && toolCallCount === 0 + ? { role: 'assistant', content: frame.text } + : { content: frame.text }; + emitSSE(buildChunk(delta, null)); + chunkCount++; + } + } + }); + + req.on('end', () => { + if (streamClosed) return; + if (chunkCount === 0 && toolCallCount === 0) { + emitSSE(buildChunk({ role: 'assistant', content: '' }, null)); + } + emitSSE( + JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: toolCallCount > 0 ? 'tool_calls' : 'stop', + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }) + ); + emitSSE('[DONE]'); + closeStream(); + }); + + req.on('error', (err) => { + if (!streamClosed) { + try { + controller.error(err); + } catch { + /* already closed */ + } + } + client.close(); + }); + }, + }); + + resolve( + new Response(readable, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) + ); + }); + + req.on('error', (err) => { + client.close(); + reject(err); + }); + + req.write(body); + req.end(); + }); + } + + /** + * Parse protobuf buffer into frames and extract text/toolcalls. + * Shared logic between JSON and SSE transformers. + */ + private *parseProtobufFrames(buffer: Buffer): Generator< + | { type: 'error'; response: Response } + | { type: 'text'; text: string } + | { + type: 'toolCall'; + toolCall: { + id: string; + type: string; + function: { name: string; arguments: string }; + isLast: boolean; + }; + } + > { + let offset = 0; + + while (offset < buffer.length) { + if (offset + 5 > buffer.length) break; + + const flags = buffer[offset]; + const length = buffer.readUInt32BE(offset + 1); + + if (offset + 5 + length > buffer.length) break; + + let payload = buffer.slice(offset + 5, offset + 5 + length); + offset += 5 + length; + + payload = decompressPayload(payload, flags); + + // Check for JSON error format + try { + const text = payload.toString('utf-8'); + if (text.startsWith('{') && text.includes('"error"')) { + yield { type: 'error', response: createErrorResponse(JSON.parse(text)) }; + return; + } + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] parseProtobufFrames error parsing failed:', err); + } + } + + const result = extractTextFromResponse(new Uint8Array(payload)); + + // Check for protobuf-decoded error + if (result.error) { + const errorLower = result.error.toLowerCase(); + const isRateLimit = + errorLower.includes('rate limit') || + errorLower.includes('resource_exhausted') || + errorLower.includes('too many requests'); + yield { + type: 'error', + response: new Response( + JSON.stringify({ + error: { + message: result.error, + type: isRateLimit ? 'rate_limit_error' : 'server_error', + code: isRateLimit ? 'rate_limited' : 'cursor_error', + }, + }), + { + status: isRateLimit ? 429 : 400, + headers: { 'Content-Type': 'application/json' }, + } + ), + }; + return; + } + + if (result.toolCall) { + yield { type: 'toolCall', toolCall: result.toolCall }; + } + + if (result.text) { + yield { type: 'text', text: result.text }; + } + } + } + + transformProtobufToJSON(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response { + const responseId = `chatcmpl-cursor-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + let totalContent = ''; + const toolCalls: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }> = []; + const toolCallsMap = new Map< + string, + { + id: string; + type: string; + function: { name: string; arguments: string }; + isLast: boolean; + index: number; + } + >(); + + for (const frame of this.parseProtobufFrames(buffer)) { + if (frame.type === 'error') { + return frame.response; + } + + if (frame.type === 'toolCall') { + const tc = frame.toolCall; + + if (toolCallsMap.has(tc.id)) { + const existing = toolCallsMap.get(tc.id); + if (!existing) continue; + existing.function.arguments += tc.function.arguments; + existing.isLast = tc.isLast; + } else { + toolCallsMap.set(tc.id, { + ...tc, + index: toolCallsMap.size, + }); + } + + if (tc.isLast) { + const finalToolCall = toolCallsMap.get(tc.id); + if (!finalToolCall) continue; + toolCalls.push({ + id: finalToolCall.id, + type: finalToolCall.type, + function: { + name: finalToolCall.function.name, + arguments: finalToolCall.function.arguments, + }, + }); + } + } + + if (frame.type === 'text') { + totalContent += frame.text; + } + } + + // Finalize remaining tool calls + for (const id of Array.from(toolCallsMap.keys())) { + const tc = toolCallsMap.get(id); + if (!tc) continue; + if (!toolCalls.find((t) => t.id === id)) { + toolCalls.push({ + id: tc.id, + type: tc.type, + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + }); + } + } + + const message: { + role: string; + content: string | null; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + } = { + role: 'assistant', + content: totalContent || null, + }; + + if (toolCalls.length > 0) { + message.tool_calls = toolCalls; + } + + const completion = { + id: responseId, + object: 'chat.completion', + created, + model, + choices: [ + { + index: 0, + message, + finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop', + }, + ], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + }; + + return new Response(JSON.stringify(completion), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + /** @deprecated Use executeStreaming() for real-time SSE. Retained for fetch fallback. */ + transformProtobufToSSE(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response { + const responseId = `chatcmpl-cursor-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const chunks: string[] = []; + const toolCalls: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + index: number; + }> = []; + const toolCallsMap = new Map< + string, + { + id: string; + type: string; + function: { name: string; arguments: string }; + isLast: boolean; + index: number; + } + >(); + + for (const frame of this.parseProtobufFrames(buffer)) { + if (frame.type === 'error') { + return frame.response; + } + + if (frame.type === 'toolCall') { + const tc = frame.toolCall; + + if (chunks.length === 0) { + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [ + { + index: 0, + delta: { role: 'assistant', content: '' }, + finish_reason: null, + }, + ], + })}\n\n` + ); + } + + if (toolCallsMap.has(tc.id)) { + const existing = toolCallsMap.get(tc.id); + if (!existing) continue; + existing.function.arguments += tc.function.arguments; + existing.isLast = tc.isLast; + + if (tc.function.arguments) { + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: existing.index, + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n` + ); + } + } else { + const toolCallIndex = toolCalls.length; + toolCalls.push({ ...tc, index: toolCallIndex }); + toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex }); + + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: toolCallIndex, + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n` + ); + } + } + + if (frame.type === 'text') { + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [ + { + index: 0, + delta: + chunks.length === 0 && toolCalls.length === 0 + ? { role: 'assistant', content: frame.text } + : { content: frame.text }, + finish_reason: null, + }, + ], + })}\n\n` + ); + } + } + + if (chunks.length === 0 && toolCalls.length === 0) { + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [ + { + index: 0, + delta: { role: 'assistant', content: '' }, + finish_reason: null, + }, + ], + })}\n\n` + ); + } + + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop', + }, + ], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + })}\n\n` + ); + chunks.push('data: [DONE]\n\n'); + + return new Response(chunks.join(''), { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); + } +} + +export default CursorExecutor; diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts new file mode 100644 index 00000000..de878cdc --- /dev/null +++ b/src/cursor/cursor-models.ts @@ -0,0 +1,189 @@ +/** + * Cursor Model Catalog + * + * Manages available models from Cursor IDE. + * Based on Cursor's supported models catalog. + */ + +import * as http from 'http'; +import type { CursorModel } from './types'; +import { isDaemonRunning } from './cursor-daemon'; + +/** Default daemon port */ +export const DEFAULT_CURSOR_PORT = 20129; + +/** Default model ID */ +export const DEFAULT_CURSOR_MODEL = 'gpt-4.1'; + +/** + * Default models available through Cursor IDE. + * Used as fallback when daemon is not reachable. + * Source: Cursor IDE supported models (Feb 2025) + */ +export const DEFAULT_CURSOR_MODELS: CursorModel[] = [ + // Anthropic Models + { + id: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + provider: 'anthropic', + }, + { + id: 'claude-sonnet-4.5', + name: 'Claude Sonnet 4.5', + provider: 'anthropic', + }, + { + id: 'claude-opus-4', + name: 'Claude Opus 4', + provider: 'anthropic', + }, + + // OpenAI Models + { + id: 'gpt-4.1', + name: 'GPT-4.1', + provider: 'openai', + isDefault: true, + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + provider: 'openai', + }, + { + id: 'o3-mini', + name: 'O3 Mini', + provider: 'openai', + }, + + // Google Models + { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + provider: 'google', + }, + + // Cursor Custom Models + { + id: 'cursor-small', + name: 'Cursor Small', + provider: 'cursor', + }, +]; + +/** + * Fetch available models from running cursor daemon. + * + * @param port The port cursor daemon is running on + * @returns List of available models + */ +export async function fetchModelsFromDaemon(port: number): Promise { + return new Promise((resolve) => { + let resolved = false; + const safeResolve = (models: CursorModel[]) => { + if (resolved) return; + resolved = true; + resolve(models); + }; + + const req = http.request( + { + // Use 127.0.0.1 instead of localhost for more reliable local connections + hostname: '127.0.0.1', + port, + path: '/v1/models', + method: 'GET', + timeout: 5000, + }, + (res) => { + const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + if (data.length > MAX_BODY_SIZE) { + req.destroy(); + safeResolve(DEFAULT_CURSOR_MODELS); + } + }); + + res.on('end', () => { + try { + const response = JSON.parse(data) as { data?: Array<{ id: string }> }; + if (response.data && Array.isArray(response.data)) { + const models: CursorModel[] = response.data.map((m) => ({ + id: m.id, + name: formatModelName(m.id), + provider: detectProvider(m.id), + isDefault: m.id === DEFAULT_CURSOR_MODEL, + })); + safeResolve(models.length > 0 ? models : DEFAULT_CURSOR_MODELS); + } else { + safeResolve(DEFAULT_CURSOR_MODELS); + } + } catch { + safeResolve(DEFAULT_CURSOR_MODELS); + } + }); + } + ); + + req.on('error', () => { + safeResolve(DEFAULT_CURSOR_MODELS); + }); + + req.on('timeout', () => { + req.destroy(); + safeResolve(DEFAULT_CURSOR_MODELS); + }); + + req.end(); + }); +} + +/** + * Get available models (from daemon or defaults). + * Checks daemon health first to avoid 5s timeout when daemon is not running. + */ +export async function getAvailableModels(port: number): Promise { + if (!(await isDaemonRunning(port))) { + return DEFAULT_CURSOR_MODELS; + } + return fetchModelsFromDaemon(port); +} + +/** + * Get the default model. + * Uses gpt-4.1 as it's commonly available. + */ +export function getDefaultModel(): string { + return DEFAULT_CURSOR_MODEL; +} + +/** + * Detect provider from model ID. + */ +export function detectProvider(modelId: string): string { + if (modelId.includes('claude')) return 'anthropic'; + if (modelId.includes('gpt') || /^o[1-9]\d*(-|$)/.test(modelId)) return 'openai'; + if (modelId.includes('gemini')) return 'google'; + if (modelId.includes('cursor')) return 'cursor'; + return 'unknown'; +} + +/** + * Format model ID to human-readable name. + */ +export function formatModelName(modelId: string): string { + // Find model in catalog for metadata + const model = DEFAULT_CURSOR_MODELS.find((m) => m.id === modelId); + if (model) { + return model.name; + } + + // Fallback: convert kebab-case to title case + return modelId + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} diff --git a/src/cursor/cursor-protobuf-decoder.ts b/src/cursor/cursor-protobuf-decoder.ts new file mode 100644 index 00000000..1dde8dd6 --- /dev/null +++ b/src/cursor/cursor-protobuf-decoder.ts @@ -0,0 +1,336 @@ +/** + * Cursor Protobuf Decoder + * Implements ConnectRPC protobuf wire format decoding + */ + +import * as zlib from 'zlib'; +import { WIRE_TYPE, FIELD, COMPRESS_FLAG, type WireType } from './cursor-protobuf-schema.js'; + +/** + * Decode a varint from buffer + * Returns [value, newOffset] + */ +export function decodeVarint(buffer: Uint8Array, offset: number): [number, number] { + let result = 0; + let shift = 0; + let pos = offset; + const maxBytes = 5; + + while (pos < buffer.length && pos - offset < maxBytes) { + const b = buffer[pos]; + result |= (b & 0x7f) << shift; + pos++; + if (!(b & 0x80)) break; + shift += 7; + } + + return [result >>> 0, pos]; // Ensure unsigned +} + +/** + * Decode a single protobuf field + * Returns [fieldNum, wireType, value, newOffset] + */ +export function decodeField( + buffer: Uint8Array, + offset: number +): [number | null, WireType | null, Uint8Array | number | null, number] { + if (offset >= buffer.length) { + return [null, null, null, offset]; + } + + const [tag, pos1] = decodeVarint(buffer, offset); + const fieldNum = tag >> 3; + const wireType = (tag & 0x07) as WireType; + + let value: Uint8Array | number | null; + let pos = pos1; + + if (wireType === WIRE_TYPE.VARINT) { + [value, pos] = decodeVarint(buffer, pos); + } else if (wireType === WIRE_TYPE.LEN) { + const [length, pos2] = decodeVarint(buffer, pos); + if (pos2 + length > buffer.length) { + return [null, null, null, buffer.length]; + } + value = buffer.slice(pos2, pos2 + length); + pos = pos2 + length; + } else if (wireType === WIRE_TYPE.FIXED64) { + if (pos + 8 > buffer.length) { + return [null, null, null, buffer.length]; + } + value = buffer.slice(pos, pos + 8); + pos += 8; + } else if (wireType === WIRE_TYPE.FIXED32) { + if (pos + 4 > buffer.length) { + return [null, null, null, buffer.length]; + } + value = buffer.slice(pos, pos + 4); + pos += 4; + } else { + value = null; + } + + return [fieldNum, wireType, value, pos]; +} + +/** + * Decode a protobuf message into a map of fields + */ +export function decodeMessage( + data: Uint8Array +): Map> { + const fields = new Map>(); + let pos = 0; + + // NOTE: If two fields share the same field number but different wire types, later values overwrite earlier ones. + while (pos < data.length) { + const [fieldNum, wireType, value, newPos] = decodeField(data, pos); + if (fieldNum === null || wireType === null || value === null) break; + + if (!fields.has(fieldNum)) { + fields.set(fieldNum, []); + } + const fieldArray = fields.get(fieldNum); + if (fieldArray) { + fieldArray.push({ wireType, value: value as Uint8Array | number }); + } + pos = newPos; + } + + return fields; +} + +/** + * Parse ConnectRPC frame from buffer + * Returns frame data or null if incomplete + */ +export function parseConnectRPCFrame(buffer: Buffer): { + flags: number; + length: number; + payload: Uint8Array; + consumed: number; +} | null { + if (buffer.length < 5) return null; + + const flags = buffer[0]; + const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4]; + + if (buffer.length < 5 + length) return null; + + let payload = buffer.slice(5, 5 + length); + + // Decompress if gzip + if ( + flags === COMPRESS_FLAG.GZIP || + flags === COMPRESS_FLAG.GZIP_ALT || + flags === COMPRESS_FLAG.GZIP_BOTH + ) { + try { + payload = Buffer.from(zlib.gunzipSync(payload)); + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] parseConnectRPCFrame decompression failed:', err); + } + // Decompression failed, use raw payload + } + } + + return { + flags, + length, + payload: new Uint8Array(payload), + consumed: 5 + length, + }; +} + +/** + * Extract tool call from protobuf data + */ +function extractToolCall(toolCallData: Uint8Array): { + id: string; + type: string; + function: { name: string; arguments: string }; + isLast: boolean; +} | null { + const toolCall = decodeMessage(toolCallData); + let toolCallId = ''; + let toolName = ''; + let rawArgs = ''; + let isLast = false; + + // Extract tool call ID + if (toolCall.has(FIELD.ToolCall.ID)) { + const idField = toolCall.get(FIELD.ToolCall.ID); + if (idField && idField[0]) { + const fullId = new TextDecoder().decode(idField[0].value as Uint8Array); + toolCallId = fullId.split('\n')[0]; // Take first line + } + } + + // Extract tool name + if (toolCall.has(FIELD.ToolCall.NAME)) { + const nameField = toolCall.get(FIELD.ToolCall.NAME); + if (nameField && nameField[0]) { + toolName = new TextDecoder().decode(nameField[0].value as Uint8Array); + } + } + + // Extract is_last flag + if (toolCall.has(FIELD.ToolCall.IS_LAST)) { + const lastField = toolCall.get(FIELD.ToolCall.IS_LAST); + if (lastField && lastField[0]) { + isLast = (lastField[0].value as number) !== 0; + } + } + + // Extract MCP params - nested real tool info + if (toolCall.has(FIELD.ToolCall.MCP_PARAMS)) { + try { + const mcpField = toolCall.get(FIELD.ToolCall.MCP_PARAMS); + if (!mcpField || !mcpField[0]) return null; + + const mcpParams = decodeMessage(mcpField[0].value as Uint8Array); + + if (mcpParams.has(FIELD.McpParams.TOOLS_LIST)) { + const toolsList = mcpParams.get(FIELD.McpParams.TOOLS_LIST); + if (!toolsList || !toolsList[0]) return null; + + const tool = decodeMessage(toolsList[0].value as Uint8Array); + + if (tool.has(FIELD.McpNested.NAME)) { + const nestedName = tool.get(FIELD.McpNested.NAME); + if (nestedName && nestedName[0]) { + toolName = new TextDecoder().decode(nestedName[0].value as Uint8Array); + } + } + + if (tool.has(FIELD.McpNested.PARAMS)) { + const nestedParams = tool.get(FIELD.McpNested.PARAMS); + if (nestedParams && nestedParams[0]) { + rawArgs = new TextDecoder().decode(nestedParams[0].value as Uint8Array); + } + } + } + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] extractToolCall MCP parsing failed:', err); + } + // MCP parse error, continue + } + } + + // Fallback to raw_args + if (!rawArgs && toolCall.has(FIELD.ToolCall.RAW_ARGS)) { + const rawArgsField = toolCall.get(FIELD.ToolCall.RAW_ARGS); + if (rawArgsField && rawArgsField[0]) { + rawArgs = new TextDecoder().decode(rawArgsField[0].value as Uint8Array); + } + } + + if (toolCallId && toolName) { + return { + id: toolCallId, + type: 'function', + function: { + name: toolName, + arguments: rawArgs || '{}', + }, + isLast, + }; + } + + return null; +} + +/** + * Extract text and thinking from response data + */ +function extractTextAndThinking(responseData: Uint8Array): { + text: string | null; + thinking: string | null; +} { + const nested = decodeMessage(responseData); + let text: string | null = null; + let thinking: string | null = null; + + // Extract text + if (nested.has(FIELD.ChatResponse.TEXT)) { + const textField = nested.get(FIELD.ChatResponse.TEXT); + if (textField && textField[0]) { + text = new TextDecoder().decode(textField[0].value as Uint8Array); + } + } + + // Extract thinking + if (nested.has(FIELD.ChatResponse.THINKING)) { + try { + const thinkingField = nested.get(FIELD.ChatResponse.THINKING); + if (thinkingField && thinkingField[0]) { + const thinkingMsg = decodeMessage(thinkingField[0].value as Uint8Array); + if (thinkingMsg.has(FIELD.Thinking.TEXT)) { + const thinkingTextField = thinkingMsg.get(FIELD.Thinking.TEXT); + if (thinkingTextField && thinkingTextField[0]) { + thinking = new TextDecoder().decode(thinkingTextField[0].value as Uint8Array); + } + } + } + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] extractTextAndThinking parsing failed:', err); + } + // Thinking parse error, continue + } + } + + return { text, thinking }; +} + +/** + * Extract text and tool calls from response payload + */ +export function extractTextFromResponse(payload: Uint8Array): { + text: string | null; + error: string | null; + toolCall: { + id: string; + type: string; + function: { name: string; arguments: string }; + isLast: boolean; + } | null; + thinking: string | null; +} { + try { + const fields = decodeMessage(payload); + + // Field 1: ClientSideToolV2Call + if (fields.has(FIELD.Response.TOOL_CALL)) { + const toolCallField = fields.get(FIELD.Response.TOOL_CALL); + if (toolCallField && toolCallField[0]) { + const toolCall = extractToolCall(toolCallField[0].value as Uint8Array); + if (toolCall) { + return { text: null, error: null, toolCall, thinking: null }; + } + } + } + + // Field 2: StreamUnifiedChatResponse + if (fields.has(FIELD.Response.RESPONSE)) { + const responseField = fields.get(FIELD.Response.RESPONSE); + if (responseField && responseField[0]) { + const { text, thinking } = extractTextAndThinking(responseField[0].value as Uint8Array); + + if (text || thinking) { + return { text, error: null, toolCall: null, thinking }; + } + } + } + + return { text: null, error: null, toolCall: null, thinking: null }; + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] extractTextFromResponse parsing failed:', err); + } + return { text: null, error: null, toolCall: null, thinking: null }; + } +} diff --git a/src/cursor/cursor-protobuf-encoder.ts b/src/cursor/cursor-protobuf-encoder.ts new file mode 100644 index 00000000..f0dffb02 --- /dev/null +++ b/src/cursor/cursor-protobuf-encoder.ts @@ -0,0 +1,224 @@ +/** + * Cursor Protobuf Encoder + * Implements ConnectRPC protobuf wire format encoding + */ + +import * as zlib from 'zlib'; +import { + WIRE_TYPE, + FIELD, + COMPRESS_FLAG, + UNIFIED_MODE, + type WireType, + type RoleType, + type CursorTool, + type CursorToolResult, +} from './cursor-protobuf-schema.js'; + +/** + * Encode a varint (variable-length integer) + */ +export function encodeVarint(value: number): Uint8Array { + const bytes: number[] = []; + let val = value >>> 0; // Ensure unsigned + while (val >= 0x80) { + bytes.push((val & 0x7f) | 0x80); + val >>>= 7; + } + bytes.push(val & 0x7f); + return new Uint8Array(bytes); +} + +/** + * Encode a protobuf field (tag + value) + */ +export function encodeField( + fieldNum: number, + wireType: WireType, + value: number | string | Uint8Array +): Uint8Array { + const tag = (fieldNum << 3) | wireType; + const tagBytes = encodeVarint(tag); + + if (wireType === WIRE_TYPE.VARINT) { + const valueBytes = encodeVarint(value as number); + return concatArrays(tagBytes, valueBytes); + } + + if (wireType === WIRE_TYPE.LEN) { + const dataBytes = + typeof value === 'string' + ? new TextEncoder().encode(value) + : value instanceof Uint8Array + ? value + : new Uint8Array(0); + + const lengthBytes = encodeVarint(dataBytes.length); + return concatArrays(tagBytes, lengthBytes, dataBytes); + } + + return new Uint8Array(0); +} + +/** + * Concatenate multiple Uint8Arrays + */ +export function concatArrays(...arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +/** + * Encode a tool result + */ +export function encodeToolResult(toolResult: CursorToolResult): Uint8Array { + const toolCallId = toolResult.tool_call_id || ''; + const toolName = toolResult.name || ''; + const toolIndex = toolResult.index || 0; + const rawArgs = toolResult.raw_args || '{}'; + + return concatArrays( + encodeField(FIELD.ToolResult.CALL_ID, WIRE_TYPE.LEN, toolCallId), + encodeField(FIELD.ToolResult.NAME, WIRE_TYPE.LEN, toolName), + encodeField(FIELD.ToolResult.INDEX, WIRE_TYPE.VARINT, toolIndex), + encodeField(FIELD.ToolResult.RAW_ARGS, WIRE_TYPE.LEN, rawArgs) + ); +} + +/** + * Encode a conversation message + */ +export function encodeMessage( + content: string, + role: RoleType, + messageId: string, + isLast: boolean, + hasTools: boolean, + toolResults: CursorToolResult[] +): Uint8Array { + return concatArrays( + encodeField(FIELD.Message.CONTENT, WIRE_TYPE.LEN, content), + encodeField(FIELD.Message.ROLE, WIRE_TYPE.VARINT, role), + encodeField(FIELD.Message.ID, WIRE_TYPE.LEN, messageId), + ...(toolResults.length > 0 + ? toolResults.map((tr) => + encodeField(FIELD.Message.TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr)) + ) + : []), + encodeField(FIELD.Message.IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0), + encodeField( + FIELD.Message.UNIFIED_MODE, + WIRE_TYPE.VARINT, + hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT + ), + ...(isLast && hasTools + ? [encodeField(FIELD.Message.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] + : []) + ); +} + +/** + * Encode instruction text + */ +export function encodeInstruction(text: string): Uint8Array { + return text ? encodeField(FIELD.Instruction.TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0); +} + +/** + * Encode model information + */ +export function encodeModel(modelName: string): Uint8Array { + return concatArrays( + encodeField(FIELD.Model.NAME, WIRE_TYPE.LEN, modelName), + encodeField(FIELD.Model.EMPTY, WIRE_TYPE.LEN, new Uint8Array(0)) + ); +} + +/** + * Encode cursor settings + */ +export function encodeCursorSetting(): Uint8Array { + const unknown6 = concatArrays( + encodeField(FIELD.Setting.U6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)), + encodeField(FIELD.Setting.U6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0)) + ); + + return concatArrays( + encodeField(FIELD.Setting.PATH, WIRE_TYPE.LEN, 'cursor\\aisettings'), + encodeField(FIELD.Setting.UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)), + encodeField(FIELD.Setting.UNKNOWN_6, WIRE_TYPE.LEN, unknown6), + encodeField(FIELD.Setting.UNKNOWN_8, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.Setting.UNKNOWN_9, WIRE_TYPE.VARINT, 1) + ); +} + +/** + * Encode metadata + */ +export function encodeMetadata(): Uint8Array { + return concatArrays( + encodeField(FIELD.Metadata.PLATFORM, WIRE_TYPE.LEN, process.platform || 'linux'), + encodeField(FIELD.Metadata.ARCH, WIRE_TYPE.LEN, process.arch || 'x64'), + encodeField(FIELD.Metadata.VERSION, WIRE_TYPE.LEN, process.version || 'v20.0.0'), + encodeField(FIELD.Metadata.CWD, WIRE_TYPE.LEN, process.cwd() || '/'), + encodeField(FIELD.Metadata.TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString()) + ); +} + +/** + * Encode message ID + */ +export function encodeMessageId(messageId: string, role: RoleType, summaryId?: string): Uint8Array { + return concatArrays( + encodeField(FIELD.MessageId.ID, WIRE_TYPE.LEN, messageId), + ...(summaryId ? [encodeField(FIELD.MessageId.SUMMARY, WIRE_TYPE.LEN, summaryId)] : []), + encodeField(FIELD.MessageId.ROLE, WIRE_TYPE.VARINT, role) + ); +} + +/** + * Encode MCP tool + */ +export function encodeMcpTool(tool: CursorTool): Uint8Array { + const toolName = tool.function?.name || tool.name || ''; + const toolDesc = tool.function?.description || tool.description || ''; + const inputSchema = tool.function?.parameters || tool.input_schema || {}; + + return concatArrays( + ...(toolName ? [encodeField(FIELD.McpTool.NAME, WIRE_TYPE.LEN, toolName)] : []), + ...(toolDesc ? [encodeField(FIELD.McpTool.DESC, WIRE_TYPE.LEN, toolDesc)] : []), + ...(Object.keys(inputSchema).length > 0 + ? [encodeField(FIELD.McpTool.PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))] + : []), + encodeField(FIELD.McpTool.SERVER, WIRE_TYPE.LEN, 'custom') + ); +} + +/** + * Wrap payload in ConnectRPC frame (5-byte header + payload) + */ +export function wrapConnectRPCFrame(payload: Uint8Array, compress = false): Uint8Array { + let finalPayload = payload; + let flags: number = COMPRESS_FLAG.NONE; + + if (compress) { + finalPayload = new Uint8Array(zlib.gzipSync(Buffer.from(payload))); + flags = COMPRESS_FLAG.GZIP; + } + + const frame = new Uint8Array(5 + finalPayload.length); + frame[0] = flags; + frame[1] = (finalPayload.length >> 24) & 0xff; + frame[2] = (finalPayload.length >> 16) & 0xff; + frame[3] = (finalPayload.length >> 8) & 0xff; + frame[4] = finalPayload.length & 0xff; + frame.set(finalPayload, 5); + + return frame; +} diff --git a/src/cursor/cursor-protobuf-schema.ts b/src/cursor/cursor-protobuf-schema.ts new file mode 100644 index 00000000..01a78fdf --- /dev/null +++ b/src/cursor/cursor-protobuf-schema.ts @@ -0,0 +1,201 @@ +/** + * Cursor Protobuf Schema Constants + * Field definitions and wire types for ConnectRPC protocol + */ + +/** Wire types for protobuf encoding */ +export const WIRE_TYPE = { + VARINT: 0, + FIXED64: 1, + LEN: 2, + FIXED32: 5, +} as const; + +/** Message role constants */ +export const ROLE = { + USER: 1, + ASSISTANT: 2, +} as const; + +/** Unified mode constants */ +export const UNIFIED_MODE = { + CHAT: 1, + AGENT: 2, +} as const; + +/** Thinking level constants */ +export const THINKING_LEVEL = { + UNSPECIFIED: 0, + MEDIUM: 1, + HIGH: 2, +} as const; + +/** Field numbers namespaced by protobuf message type */ +export const FIELD = { + /** StreamUnifiedChatRequestWithTools (top level) */ + Request: { REQUEST: 1 }, + + /** StreamUnifiedChatRequest */ + Chat: { + MESSAGES: 1, + UNKNOWN_2: 2, + INSTRUCTION: 3, + UNKNOWN_4: 4, + MODEL: 5, + WEB_TOOL: 8, + UNKNOWN_13: 13, + CURSOR_SETTING: 15, + UNKNOWN_19: 19, + CONVERSATION_ID: 23, + METADATA: 26, + IS_AGENTIC: 27, + SUPPORTED_TOOLS: 29, + MESSAGE_IDS: 30, + MCP_TOOLS: 34, + LARGE_CONTEXT: 35, + UNKNOWN_38: 38, + UNIFIED_MODE: 46, + UNKNOWN_47: 47, + SHOULD_DISABLE_TOOLS: 48, + THINKING_LEVEL: 49, + UNKNOWN_51: 51, + UNKNOWN_53: 53, + UNIFIED_MODE_NAME: 54, + }, + + /** ConversationMessage */ + Message: { + CONTENT: 1, + ROLE: 2, + ID: 13, + TOOL_RESULTS: 18, + IS_AGENTIC: 29, + UNIFIED_MODE: 47, + SUPPORTED_TOOLS: 51, + }, + + /** ConversationMessage.ToolResult */ + ToolResult: { + CALL_ID: 1, + NAME: 2, + INDEX: 3, + RAW_ARGS: 5, + RESULT: 8, + }, + + /** Model */ + Model: { NAME: 1, EMPTY: 4 }, + + /** Instruction */ + Instruction: { TEXT: 1 }, + + /** CursorSetting (U6_FIELD_* are sub-fields of Unknown6 nested message) */ + Setting: { + PATH: 1, + UNKNOWN_3: 3, + UNKNOWN_6: 6, + UNKNOWN_8: 8, + UNKNOWN_9: 9, + U6_FIELD_1: 1, + U6_FIELD_2: 2, + }, + + /** Metadata */ + Metadata: { PLATFORM: 1, ARCH: 2, VERSION: 3, CWD: 4, TIMESTAMP: 5 }, + + /** MessageId */ + MessageId: { ID: 1, SUMMARY: 2, ROLE: 3 }, + + /** MCPTool */ + McpTool: { NAME: 1, DESC: 2, PARAMS: 3, SERVER: 4 }, + + /** StreamUnifiedChatResponseWithTools (response top-level) */ + Response: { TOOL_CALL: 1, RESPONSE: 2 }, + + /** ClientSideToolV2Call */ + ToolCall: { ID: 3, NAME: 9, RAW_ARGS: 10, IS_LAST: 11, MCP_PARAMS: 27 }, + + /** MCPParams */ + McpParams: { TOOLS_LIST: 1 }, + + /** MCPParams.Tool (nested) */ + McpNested: { NAME: 1, PARAMS: 3 }, + + /** StreamUnifiedChatResponse */ + ChatResponse: { TEXT: 1, THINKING: 25 }, + + /** Thinking */ + Thinking: { TEXT: 1 }, +} as const; + +/** Type definitions */ +export type WireType = (typeof WIRE_TYPE)[keyof typeof WIRE_TYPE]; +export type RoleType = (typeof ROLE)[keyof typeof ROLE]; +export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE]; +export type ThinkingLevelType = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL]; + +/** Cursor credentials structure */ +export interface CursorCredentials { + accessToken: string; + machineId: string; + ghostMode?: boolean; +} + +/** Cursor tool definition */ +export interface CursorTool { + function?: { + name?: string; + description?: string; + parameters?: Record; + }; + name?: string; + description?: string; + input_schema?: Record; +} + +/** Cursor tool result */ +export interface CursorToolResult { + tool_call_id?: string; + name?: string; + index?: number; + raw_args?: string; +} + +/** Cursor message format */ +export interface CursorMessage { + role: string; + content: string; + tool_results?: CursorToolResult[]; + tool_calls?: Array<{ + id: string; + type: string; + function: { + name: string; + arguments: string; + }; + }>; +} + +/** Formatted message for encoding */ +export interface FormattedMessage { + content: string; + role: RoleType; + messageId: string; + isLast: boolean; + hasTools: boolean; + toolResults: CursorToolResult[]; +} + +/** Message ID structure */ +export interface MessageId { + messageId: string; + role: RoleType; +} + +/** Compression flags for ConnectRPC frames */ +export const COMPRESS_FLAG = { + NONE: 0x00, + GZIP: 0x01, + GZIP_ALT: 0x02, + GZIP_BOTH: 0x03, +} as const; diff --git a/src/cursor/cursor-protobuf.ts b/src/cursor/cursor-protobuf.ts new file mode 100644 index 00000000..7579cc42 --- /dev/null +++ b/src/cursor/cursor-protobuf.ts @@ -0,0 +1,186 @@ +/** + * Cursor Protobuf Main Module + * Exports encoder/decoder functions and builds complete requests + */ + +import { randomUUID } from 'crypto'; +import { + ROLE, + UNIFIED_MODE, + THINKING_LEVEL, + FIELD, + type CursorMessage, + type CursorTool, + type FormattedMessage, + type MessageId, + type ThinkingLevelType, +} from './cursor-protobuf-schema.js'; +import { + encodeField, + encodeVarint, + encodeMessage, + encodeInstruction, + encodeModel, + encodeCursorSetting, + encodeMetadata, + encodeMessageId, + encodeMcpTool, + wrapConnectRPCFrame, + concatArrays, +} from './cursor-protobuf-encoder.js'; +import { + decodeVarint, + decodeField, + decodeMessage, + parseConnectRPCFrame, + extractTextFromResponse, +} from './cursor-protobuf-decoder.js'; +import { WIRE_TYPE } from './cursor-protobuf-schema.js'; + +/** + * Build complete chat request protobuf + */ +export function encodeRequest( + messages: CursorMessage[], + modelName: string, + tools: CursorTool[] = [], + reasoningEffort: string | null = null +): Uint8Array { + if (messages.length === 0) { + throw new Error('Messages array must not be empty'); + } + + const hasTools = tools?.length > 0; + const isAgentic = hasTools; + const formattedMessages: FormattedMessage[] = []; + const messageIds: MessageId[] = []; + + // Prepare messages + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const role = msg.role === 'user' ? ROLE.USER : ROLE.ASSISTANT; + const msgId = randomUUID(); + const isLast = i === messages.length - 1; + + formattedMessages.push({ + content: msg.content, + role, + messageId: msgId, + isLast, + hasTools, + toolResults: msg.tool_results || [], + }); + + messageIds.push({ messageId: msgId, role }); + } + + // Map reasoning effort to thinking level + let thinkingLevel: ThinkingLevelType = THINKING_LEVEL.UNSPECIFIED; + if (reasoningEffort === 'medium') thinkingLevel = THINKING_LEVEL.MEDIUM; + else if (reasoningEffort === 'high') thinkingLevel = THINKING_LEVEL.HIGH; + + // Build arrays for messages and tools + const messageFields = formattedMessages.map((fm) => + encodeField( + FIELD.Chat.MESSAGES, + WIRE_TYPE.LEN, + encodeMessage(fm.content, fm.role, fm.messageId, fm.isLast, fm.hasTools, fm.toolResults) + ) + ); + + const messageIdFields = messageIds.map((mid) => + encodeField(FIELD.Chat.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role)) + ); + + const toolFields = + tools?.length > 0 + ? tools.map((tool) => encodeField(FIELD.Chat.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool))) + : []; + + const supportedToolsField = isAgentic + ? [encodeField(FIELD.Chat.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] + : []; + + // Concatenate all parts + const parts: Uint8Array[] = [ + ...messageFields, + encodeField(FIELD.Chat.UNKNOWN_2, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.Chat.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction('')), + encodeField(FIELD.Chat.UNKNOWN_4, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.Chat.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)), + encodeField(FIELD.Chat.WEB_TOOL, WIRE_TYPE.LEN, ''), + encodeField(FIELD.Chat.UNKNOWN_13, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.Chat.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()), + encodeField(FIELD.Chat.UNKNOWN_19, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.Chat.CONVERSATION_ID, WIRE_TYPE.LEN, randomUUID()), + encodeField(FIELD.Chat.METADATA, WIRE_TYPE.LEN, encodeMetadata()), + encodeField(FIELD.Chat.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0), + ...supportedToolsField, + ...messageIdFields, + ...toolFields, + encodeField(FIELD.Chat.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0), + encodeField(FIELD.Chat.UNKNOWN_38, WIRE_TYPE.VARINT, 0), + encodeField( + FIELD.Chat.UNIFIED_MODE, + WIRE_TYPE.VARINT, + isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT + ), + encodeField(FIELD.Chat.UNKNOWN_47, WIRE_TYPE.LEN, ''), + encodeField(FIELD.Chat.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1), + encodeField(FIELD.Chat.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel), + encodeField(FIELD.Chat.UNKNOWN_51, WIRE_TYPE.VARINT, 0), + encodeField(FIELD.Chat.UNKNOWN_53, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.Chat.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? 'Agent' : 'Ask'), + ]; + + return concatArrays(...parts); +} + +/** + * Build chat request wrapped in top-level message + */ +export function buildChatRequest( + messages: CursorMessage[], + modelName: string, + tools: CursorTool[] = [], + reasoningEffort: string | null = null +): Uint8Array { + return encodeField( + FIELD.Request.REQUEST, + WIRE_TYPE.LEN, + encodeRequest(messages, modelName, tools, reasoningEffort) + ); +} + +/** + * Generate complete Cursor request body with ConnectRPC framing + */ +export function generateCursorBody( + messages: CursorMessage[], + modelName: string, + tools: CursorTool[] = [], + reasoningEffort: string | null = null +): Uint8Array { + const protobuf = buildChatRequest(messages, modelName, tools, reasoningEffort); + const framed = wrapConnectRPCFrame(protobuf, false); // Cursor doesn't support compressed requests + return framed; +} + +// Re-export all functions +export { + encodeVarint, + encodeField, + encodeMessage, + encodeInstruction, + encodeModel, + encodeCursorSetting, + encodeMetadata, + encodeMessageId, + encodeMcpTool, + wrapConnectRPCFrame, + decodeVarint, + decodeField, + decodeMessage, + parseConnectRPCFrame, + extractTextFromResponse, +}; diff --git a/src/cursor/cursor-stream-parser.ts b/src/cursor/cursor-stream-parser.ts new file mode 100644 index 00000000..7fa06091 --- /dev/null +++ b/src/cursor/cursor-stream-parser.ts @@ -0,0 +1,136 @@ +/** + * Cursor Streaming Frame Parser + * Incrementally parses ConnectRPC frames from arbitrary chunk boundaries + */ + +import * as zlib from 'zlib'; +import { COMPRESS_FLAG } from './cursor-protobuf-schema.js'; +import { extractTextFromResponse } from './cursor-protobuf-decoder.js'; + +/** Frame parsing result types */ +export type FrameResult = + | { type: 'error'; message: string; status: number; errorType: string } + | { type: 'text'; text: string } + | { type: 'thinking'; text: string } + | { + type: 'toolCall'; + toolCall: { + id: string; + type: string; + function: { name: string; arguments: string }; + isLast: boolean; + }; + }; + +/** + * Decompress payload if gzip-compressed. + * Skips decompression for JSON error payloads. + * NOTE: Uses synchronous gzip for single-request CLI tool. Async not warranted for small payloads. + */ +export function decompressPayload(payload: Buffer, flags: number): Buffer { + if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) { + try { + const text = payload.toString('utf-8'); + if (text.startsWith('{"error"')) return payload; + } catch { + // Not JSON, continue + } + } + + if ( + flags === COMPRESS_FLAG.GZIP || + flags === COMPRESS_FLAG.GZIP_ALT || + flags === COMPRESS_FLAG.GZIP_BOTH + ) { + try { + return zlib.gunzipSync(payload); + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[cursor] gzip decompression failed:', err); + } + return Buffer.alloc(0); + } + } + return payload; +} + +/** + * Incrementally parses ConnectRPC frames from arbitrary chunk boundaries. + * + * Usage: + * const parser = new StreamingFrameParser(); + * req.on('data', (chunk) => { + * for (const frame of parser.push(chunk)) { handle(frame); } + * }); + */ +export class StreamingFrameParser { + private buffer: Buffer = Buffer.alloc(0); + + /** Feed a new chunk. Returns zero or more parsed frames. */ + push(chunk: Buffer): FrameResult[] { + this.buffer = Buffer.concat([this.buffer, chunk]); + const results: FrameResult[] = []; + + while (this.buffer.length >= 5) { + const length = this.buffer.readUInt32BE(1); + const frameSize = 5 + length; + if (this.buffer.length < frameSize) break; + + const flags = this.buffer[0]; + let payload = this.buffer.slice(5, frameSize); + this.buffer = this.buffer.slice(frameSize); + + payload = decompressPayload(payload, flags); + + // Check for JSON error + try { + const text = payload.toString('utf-8'); + if (text.startsWith('{') && text.includes('"error"')) { + const json = JSON.parse(text); + const msg = + json?.error?.details?.[0]?.debug?.details?.title || + json?.error?.details?.[0]?.debug?.details?.detail || + json?.error?.message || + 'API Error'; + const isRateLimit = json?.error?.code === 'resource_exhausted'; + results.push({ + type: 'error', + message: msg, + status: isRateLimit ? 429 : 400, + errorType: isRateLimit ? 'rate_limit_error' : 'api_error', + }); + return results; + } + } catch { + // Not JSON, continue to protobuf parsing + } + + const result = extractTextFromResponse(new Uint8Array(payload)); + + if (result.error) { + const errorLower = result.error.toLowerCase(); + const isRateLimit = + errorLower.includes('rate limit') || + errorLower.includes('resource_exhausted') || + errorLower.includes('too many requests'); + results.push({ + type: 'error', + message: result.error, + status: isRateLimit ? 429 : 400, + errorType: isRateLimit ? 'rate_limit_error' : 'server_error', + }); + return results; + } + + if (result.toolCall) results.push({ type: 'toolCall', toolCall: result.toolCall }); + if (result.text) results.push({ type: 'text', text: result.text }); + if (result.thinking) results.push({ type: 'thinking', text: result.thinking }); + } + + return results; + } + + hasPartial(): boolean { + return this.buffer.length > 0; + } +} diff --git a/src/cursor/cursor-translator.ts b/src/cursor/cursor-translator.ts new file mode 100644 index 00000000..f709ab43 --- /dev/null +++ b/src/cursor/cursor-translator.ts @@ -0,0 +1,155 @@ +/** + * OpenAI to Cursor Request Translator + * Converts OpenAI messages to Cursor format + */ + +import type { CursorMessage, CursorToolResult, CursorTool } from './cursor-protobuf-schema.js'; + +/** OpenAI message format */ +interface OpenAIMessage { + role: string; + content: string | Array<{ type: string; text?: string }>; + name?: string; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; +} + +/** OpenAI request body */ +interface OpenAIRequestBody { + messages: OpenAIMessage[]; + tools?: CursorTool[]; + reasoning_effort?: string; +} + +/** + * Convert OpenAI messages to Cursor format with native tool_results support + * - system → user with [System Instructions] prefix + * - tool → accumulate into tool_results array for next user/assistant message + * - assistant with tool_calls → keep tool_calls structure (Cursor supports it natively) + */ +function convertMessages(messages: OpenAIMessage[]): CursorMessage[] { + const result: CursorMessage[] = []; + let pendingToolResults: CursorToolResult[] = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + + if (msg.role === 'system') { + let content = ''; + if (typeof msg.content === 'string') { + content = msg.content; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'text' && part.text) content += part.text; + } + } + result.push({ + role: 'user', + content: `[System Instructions]\n${content}`, + }); + continue; + } + + if (msg.role === 'tool') { + let toolContent = ''; + if (typeof msg.content === 'string') { + toolContent = msg.content; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'text' && part.text) { + toolContent += part.text; + } + } + } + + const toolName = msg.name || 'tool'; + const toolCallId = msg.tool_call_id || ''; + + // Accumulate tool result + pendingToolResults.push({ + tool_call_id: toolCallId, + name: toolName, + index: pendingToolResults.length, + raw_args: toolContent, + }); + continue; + } + + if (msg.role === 'user' || msg.role === 'assistant') { + let content = ''; + + if (typeof msg.content === 'string') { + content = msg.content; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'text' && part.text) { + content += part.text; + } + } + } + + // Keep tool_calls structure for assistant messages + if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) { + const assistantMsg: CursorMessage = { role: 'assistant', content: '' }; + if (content) { + assistantMsg.content = content; + } + assistantMsg.tool_calls = msg.tool_calls; + + // Attach pending tool results to assistant message with tool_calls + if (pendingToolResults.length > 0) { + assistantMsg.tool_results = pendingToolResults; + pendingToolResults = []; + } + + result.push(assistantMsg); + } else if (content || pendingToolResults.length > 0) { + const msgObj: CursorMessage = { + role: msg.role, + content: content || '', + }; + + // Attach pending tool results to this message + if (pendingToolResults.length > 0) { + msgObj.tool_results = pendingToolResults; + pendingToolResults = []; + } + + result.push(msgObj); + } + continue; + } + + // Unknown role - skip with debug warning + if (process.env.CCS_DEBUG) { + console.error(`[cursor] Unknown message role: ${msg.role}, skipping`); + } + } + + return result; +} + +/** + * Transform OpenAI request to Cursor format + * Returns modified body with converted messages + */ +export function buildCursorRequest( + _model: string, + body: OpenAIRequestBody, + _stream: boolean, + _credentials: unknown +): { + messages: CursorMessage[]; + tools?: CursorTool[]; +} { + const messages = convertMessages(body.messages || []); + + return { + ...body, + messages, + }; +} diff --git a/src/cursor/daemon-process-ownership.ts b/src/cursor/daemon-process-ownership.ts new file mode 100644 index 00000000..c9c0eddf --- /dev/null +++ b/src/cursor/daemon-process-ownership.ts @@ -0,0 +1,75 @@ +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; + +export type DaemonOwnershipStatus = 'owned' | 'not-owned' | 'not-running' | 'unknown'; + +function getProcessCommandLine(pid: number): string | null { + if (process.platform === 'linux') { + try { + // /proc cmdline uses null separators between arguments. + return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim(); + } catch { + return null; + } + } + + if (process.platform === 'darwin') { + try { + const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf8', + }); + if (result.error || result.status !== 0) { + return null; + } + return result.stdout.trim(); + } catch { + return null; + } + } + + if (process.platform === 'win32') { + const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" | Select-Object -ExpandProperty CommandLine)`; + const shells = ['powershell.exe', 'powershell', 'pwsh.exe', 'pwsh']; + for (const shell of shells) { + try { + const result = spawnSync(shell, ['-NoProfile', '-Command', command], { + encoding: 'utf8', + }); + if (result.error) { + continue; + } + if (result.status !== 0) { + return null; + } + return result.stdout.trim(); + } catch { + // Try next shell candidate + } + } + return null; + } + + return null; +} + +export function verifyDaemonOwnership(pid: number): DaemonOwnershipStatus { + try { + process.kill(pid, 0); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ESRCH') { + return 'not-running'; + } + return 'unknown'; + } + + const commandLine = getProcessCommandLine(pid); + if (!commandLine) { + return 'unknown'; + } + + const looksLikeCursorDaemon = + commandLine.includes('--ccs-daemon') && commandLine.includes('cursor-daemon-entry'); + + return looksLikeCursorDaemon ? 'owned' : 'not-owned'; +} diff --git a/src/cursor/index.ts b/src/cursor/index.ts new file mode 100644 index 00000000..59fd6d74 --- /dev/null +++ b/src/cursor/index.ts @@ -0,0 +1,44 @@ +/** + * Cursor Module Index + * + * Central exports for Cursor IDE integration. + */ + +// Types +export * from './types'; + +// Auth +export { + autoDetectTokens, + validateToken, + saveCredentials, + loadCredentials, + deleteCredentials, + checkAuthStatus, +} from './cursor-auth'; + +// Daemon +export { + isDaemonRunning, + getDaemonStatus, + startDaemon, + stopDaemon, + getPidFromFile, + writePidToFile, + removePidFile, +} from './cursor-daemon'; + +// Models +export { + DEFAULT_CURSOR_MODELS, + DEFAULT_CURSOR_PORT, + DEFAULT_CURSOR_MODEL, + fetchModelsFromDaemon, + getAvailableModels, + getDefaultModel, + detectProvider, + formatModelName, +} from './cursor-models'; + +// Executor +export { CursorExecutor } from './cursor-executor'; diff --git a/src/cursor/types.ts b/src/cursor/types.ts new file mode 100644 index 00000000..df9c152f --- /dev/null +++ b/src/cursor/types.ts @@ -0,0 +1,85 @@ +/** + * Cursor IDE Type Definitions + * + * TypeScript interfaces for the Cursor module. + */ + +/** + * Cursor daemon runtime configuration. + */ +export interface CursorDaemonConfig { + port: number; + ghost_mode?: boolean; +} + +/** + * Cursor authentication credentials + */ +export interface CursorCredentials { + /** Access token from Cursor IDE */ + accessToken: string; + /** Machine ID for checksum generation */ + machineId: string; + /** User email (if available from token) */ + email?: string; + /** User ID (if available from token) */ + userId?: string; + /** How credentials were obtained */ + authMethod: 'auto-detect' | 'manual'; + /** ISO datetime when credentials were imported */ + importedAt: string; +} + +/** + * Cursor authentication status + */ +export interface CursorAuthStatus { + /** Whether user is authenticated */ + authenticated: boolean; + /** Current credentials (if authenticated) */ + credentials?: CursorCredentials; + /** Hours since credentials were imported (if available) */ + tokenAge?: number; + /** Whether token has expired (>24 hours old) */ + expired?: boolean; +} + +/** + * Auto-detection result + */ +export interface AutoDetectResult { + /** Whether tokens were found */ + found: boolean; + /** Access token (if found) */ + accessToken?: string; + /** Machine ID (if found) */ + machineId?: string; + /** Error message (if detection failed) */ + error?: string; +} + +/** + * Cursor daemon/process status + */ +export interface CursorDaemonStatus { + /** Whether daemon is running */ + running: boolean; + /** Port number daemon is listening on */ + port: number; + /** Process ID (if available) */ + pid?: number; +} + +/** + * Cursor AI model + */ +export interface CursorModel { + /** Model ID */ + id: string; + /** Display name */ + name: string; + /** Provider (e.g., 'openai', 'anthropic') */ + provider: string; + /** Whether this is the default model */ + isDefault?: boolean; +} diff --git a/src/management/oauth-port-diagnostics.ts b/src/management/oauth-port-diagnostics.ts index d50c16cf..8a13628d 100644 --- a/src/management/oauth-port-diagnostics.ts +++ b/src/management/oauth-port-diagnostics.ts @@ -9,7 +9,7 @@ * - Codex: 1455 * - Agy: 51121 * - iFlow: 11451 - * - Kiro: 9876 + * - Kiro: Device Code Flow (no port needed) * - Claude: 54545 * - Qwen: Device Code Flow (no port needed) * - GHCP: Device Code Flow (no port needed) @@ -26,40 +26,44 @@ import { } from '../utils/port-utils'; import { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROFILES } from '../auth/profile-detector'; +import { + CLIPROXY_PROVIDER_IDS, + getOAuthCallbackPort, + getOAuthFlowType, + type OAuthFlowType as ProviderOAuthFlowType, +} from '../cliproxy/provider-capabilities'; /** - * OAuth callback ports for each provider - * Extracted from CLIProxyAPI source + * Build provider-indexed records from canonical provider capabilities. + * Keeps diagnostics in sync with runtime OAuth flow metadata. */ -export const OAUTH_CALLBACK_PORTS: Record = { - gemini: 8085, - codex: 1455, - agy: 51121, - qwen: null, // Device Code Flow - no callback port - iflow: 11451, // Authorization Code Flow - kiro: 9876, // Authorization Code Flow - ghcp: null, // Device Code Flow - no callback port - claude: 54545, // Authorization Code Flow (Anthropic OAuth) -}; +function buildProviderMap( + valueFor: (provider: CLIProxyProvider) => T +): Record { + return CLIPROXY_PROVIDER_IDS.reduce( + (acc, provider) => { + acc[provider] = valueFor(provider); + return acc; + }, + {} as Record + ); +} + +export const OAUTH_CALLBACK_PORTS: Record = buildProviderMap( + (provider) => getOAuthCallbackPort(provider) +); /** * OAuth flow types */ -export type OAuthFlowType = 'authorization_code' | 'device_code'; +export type OAuthFlowType = ProviderOAuthFlowType; /** * OAuth flow type per provider */ -export const OAUTH_FLOW_TYPES: Record = { - gemini: 'authorization_code', - codex: 'authorization_code', - agy: 'authorization_code', - qwen: 'device_code', - iflow: 'authorization_code', - kiro: 'authorization_code', - ghcp: 'device_code', - claude: 'authorization_code', -}; +export const OAUTH_FLOW_TYPES: Record = buildProviderMap( + (provider) => getOAuthFlowType(provider) +); /** * Port diagnostic result diff --git a/src/types/index.ts b/src/types/index.ts index 5ed78646..4adba8b4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,6 +12,7 @@ export type { EnvValue, ProfileMetadata, ProfilesRegistry, + CLIProxyVariantConfig, CLIProxyVariantsConfig, } from './config'; export { isConfig, isSettings } from './config'; diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 2c5fecfd..28b09105 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -1,7 +1,14 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { Config, isConfig, Settings, isSettings, CLIProxyVariantsConfig } from '../types'; +import { + Config, + isConfig, + Settings, + isSettings, + CLIProxyVariantsConfig, + CLIProxyVariantConfig, +} from '../types'; import { expandPath, error } from './helpers'; import { info } from './ui'; import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; @@ -191,12 +198,22 @@ export function loadConfigSafe(): Config { if (unifiedConfig.cliproxy?.variants) { cliproxy = {}; for (const [name, variant] of Object.entries(unifiedConfig.cliproxy.variants)) { - cliproxy[name] = { - provider: variant.provider, - settings: variant.settings, - account: variant.account, - port: variant.port, - }; + if ('type' in variant && variant.type === 'composite') { + // Composite variants: use default tier's provider + cliproxy[name] = { + provider: variant.tiers[variant.default_tier].provider, + settings: variant.settings, + port: variant.port, + }; + } else { + const single = variant as CLIProxyVariantConfig; + cliproxy[name] = { + provider: single.provider, + settings: single.settings, + account: single.account, + port: single.port, + }; + } } } diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index cb8aa98a..85d0caf6 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -146,7 +146,7 @@ export async function parseProjectDirectory(projectDir: string): Promise f.endsWith('.jsonl')); // Parse files sequentially within a project to avoid too many open handles diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 2e8b4e84..20362ff6 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -17,28 +17,20 @@ import { soloAccount, } from '../../cliproxy/account-manager'; import type { CLIProxyProvider } from '../../cliproxy/types'; -import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; +import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; const router = Router(); const registry = new ProfileRegistry(); -/** Valid CLIProxy providers - derived from canonical CLIPROXY_PROFILES */ -const VALID_PROVIDERS: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; - -/** Check if provider is valid */ -function isValidProvider(provider: string): provider is CLIProxyProvider { - return VALID_PROVIDERS.includes(provider as CLIProxyProvider); -} - /** Parse CLIProxy account key format: "provider:accountId" */ function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null { const colonIndex = key.indexOf(':'); if (colonIndex === -1) return null; - const provider = key.slice(0, colonIndex) as CLIProxyProvider; + const provider = key.slice(0, colonIndex); const accountId = key.slice(colonIndex + 1); - if (!isValidProvider(provider) || !accountId) return null; + if (!isCLIProxyProvider(provider) || !accountId) return null; return { provider, accountId }; } @@ -239,7 +231,7 @@ router.post('/bulk-pause', (req: Request, res: Response): void => { return; } - if (!isValidProvider(provider)) { + if (!isCLIProxyProvider(provider)) { res.status(400).json({ error: `Invalid provider: ${provider}` }); return; } @@ -276,7 +268,7 @@ router.post('/bulk-resume', (req: Request, res: Response): void => { return; } - if (!isValidProvider(provider)) { + if (!isCLIProxyProvider(provider)) { res.status(400).json({ error: `Invalid provider: ${provider}` }); return; } @@ -313,7 +305,7 @@ router.post('/solo', async (req: Request, res: Response): Promise => { return; } - if (!isValidProvider(provider)) { + if (!isCLIProxyProvider(provider)) { res.status(400).json({ error: `Invalid provider: ${provider}` }); return; } diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 302323ba..a940a117 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -37,7 +37,13 @@ import { getProviderTokenDir } from '../../cliproxy/auth/token-manager'; import { CLIPROXY_CALLBACK_PROVIDER_MAP, CLIPROXY_AUTH_URL_PROVIDER_MAP, + isKiroAuthMethod, + isKiroDeviceCodeMethod, + KiroAuthMethod, + normalizeKiroAuthMethod, + toKiroManagementMethod, } from '../../cliproxy/auth/auth-types'; +import { getOAuthFlowType } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; @@ -46,6 +52,44 @@ const router = Router(); // Valid providers list - derived from canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; +function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { + if (raw === undefined || raw === null) { + return { method: normalizeKiroAuthMethod(), invalid: false }; + } + if (typeof raw !== 'string') { + return { method: normalizeKiroAuthMethod(), invalid: true }; + } + if (raw.trim() === '') { + return { method: normalizeKiroAuthMethod(), invalid: false }; + } + const normalized = raw.trim().toLowerCase(); + if (!isKiroAuthMethod(normalized)) { + return { method: normalizeKiroAuthMethod(), invalid: true }; + } + return { method: normalizeKiroAuthMethod(normalized), invalid: false }; +} + +export function getStartUrlUnsupportedReason( + provider: CLIProxyProvider, + options?: { kiroMethod?: KiroAuthMethod } +): string | null { + if (provider === 'kiro') { + const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod(); + if (kiroMethod === 'aws-authcode') { + return "Kiro method 'aws-authcode' uses CLI auth flow. Use /api/cliproxy/auth/kiro/start instead."; + } + if (isKiroDeviceCodeMethod(kiroMethod)) { + return "Kiro method 'aws' uses Device Code flow. Use /api/cliproxy/auth/kiro/start instead."; + } + return null; + } + + if (getOAuthFlowType(provider) === 'device_code') { + return `Provider '${provider}' uses Device Code flow. Use /api/cliproxy/auth/${provider}/start instead.`; + } + return null; +} + /** * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers @@ -343,9 +387,14 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons */ router.post('/:provider/start', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { nickname: nicknameRaw, noIncognito: noIncognitoBody } = req.body; + const { + nickname: nicknameRaw, + noIncognito: noIncognitoBody, + kiroMethod: kiroMethodRaw, + } = req.body; // Trim nickname for consistency with CLI (oauth-handler.ts trims input) const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw; + const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Validate provider if (!validProviders.includes(provider as CLIProxyProvider)) { @@ -353,6 +402,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise */ router.post('/:provider/start-url', async (req: Request, res: Response): Promise => { const { provider } = req.params; + const { kiroMethod: kiroMethodRaw } = req.body ?? {}; + const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Check remote mode const target = getProxyTarget(); @@ -558,14 +618,34 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + if (provider === 'kiro' && invalidKiroMethod) { + res.status(400).json({ + error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github', + code: 'INVALID_KIRO_METHOD', + }); + return; + } + + const unsupportedReason = getStartUrlUnsupportedReason(provider as CLIProxyProvider, { + kiroMethod: provider === 'kiro' ? kiroMethod : undefined, + }); + if (unsupportedReason) { + res.status(400).json({ error: unsupportedReason }); + return; + } + try { const authUrlProvider = CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; + const kiroQuery = + provider === 'kiro' + ? `&method=${encodeURIComponent(toKiroManagementMethod(kiroMethod))}` + : ''; // Call CLIProxyAPI to start OAuth and get auth URL // CLIProxyAPI management routes are under /v0/management prefix const response = await fetch( - buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true`), + buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}`), { headers: buildManagementHeaders(target) } ); @@ -575,18 +655,27 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } - const data = (await response.json()) as { url?: string; auth_url?: string; state?: string }; + const data = (await response.json()) as { + url?: string; + auth_url?: string; + state?: string; + method?: string; + }; const authUrl = data.url || data.auth_url; - if (!authUrl) { - res.status(500).json({ error: 'No authorization URL received from CLIProxyAPI' }); + // Some upstream flows return state first and provide auth_url in subsequent status polling. + if (!authUrl && !data.state) { + res + .status(500) + .json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' }); return; } res.json({ success: true, - authUrl, - state: data.state, + authUrl: authUrl || null, + state: data.state || null, + method: data.method || null, }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to start OAuth'; diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index bae7f14c..4fd41249 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -46,6 +46,37 @@ import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; const router = Router(); +/** + * Cache only stable failures; avoid pinning transient network failures (timeouts, 429s). + */ +function shouldCacheCodexQuotaResult(result: CodexQuotaResult): boolean { + if (result.success) return true; + if (result.needsReauth || result.isForbidden) return true; + + const msg = (result.error || '').toLowerCase(); + if (!msg) return false; + if (msg.includes('timeout')) return false; + if (msg.includes('rate limited')) return false; + if (msg.includes('api error: 5')) return false; + if (msg.includes('fetch failed')) return false; + + return false; +} + +function shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): boolean { + if (result.success) return true; + if (result.needsReauth) return true; + + const msg = (result.error || '').toLowerCase(); + if (!msg) return false; + if (msg.includes('timeout')) return false; + if (msg.includes('rate limited')) return false; + if (msg.includes('api error: 5')) return false; + if (msg.includes('fetch failed')) return false; + + return false; +} + /** Get configured backend from config */ function getConfiguredBackend() { try { @@ -548,8 +579,8 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi // Fetch from external API const result = await fetchCodexQuota(accountId); - // Cache successful results (don't cache errors that need reauth) - if (result.success || !result.needsReauth) { + // Cache successful and stable failure states; skip transient network failures. + if (shouldCacheCodexQuotaResult(result)) { setCachedQuota('codex', accountId, result); } @@ -589,8 +620,8 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom // Fetch from external API const result = await fetchGeminiCliQuota(accountId); - // Cache successful results (don't cache errors that need reauth) - if (result.success || !result.needsReauth) { + // Cache successful and stable failure states; skip transient network failures. + if (shouldCacheGeminiQuotaResult(result)) { setCachedQuota('gemini', accountId, result); } diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts new file mode 100644 index 00000000..0ba13344 --- /dev/null +++ b/src/web-server/routes/cursor-routes.ts @@ -0,0 +1,204 @@ +/** + * Cursor Routes - Cursor IDE integration via cursor proxy daemon + */ + +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import { + getDaemonStatus, + getAvailableModels, + getDefaultModel, + startDaemon, + stopDaemon, + checkAuthStatus, + autoDetectTokens, + saveCredentials, + validateToken, +} from '../../cursor'; +import { getCursorConfig } from '../../config/unified-config-loader'; +import cursorSettingsRoutes from './cursor-settings-routes'; + +const router = Router(); + +interface DaemonStartPreconditionInput { + enabled: boolean; + authenticated: boolean; + tokenExpired?: boolean; +} + +interface DaemonStartPreconditionError { + status: number; + error: string; +} + +export function getDaemonStartPreconditionError( + input: DaemonStartPreconditionInput +): DaemonStartPreconditionError | null { + if (!input.enabled) { + return { + status: 400, + error: 'Cursor integration is disabled. Enable it before starting daemon.', + }; + } + + if (!input.authenticated) { + return { + status: 401, + error: 'Cursor authentication required. Import credentials before starting daemon.', + }; + } + + if (input.tokenExpired) { + return { + status: 401, + error: 'Cursor credentials expired. Re-authenticate before starting daemon.', + }; + } + + return null; +} + +// Mount settings sub-routes +router.use('/settings', cursorSettingsRoutes); + +/** + * GET /api/cursor/status - Get Cursor status (auth + daemon) + */ +router.get('/status', async (_req: Request, res: Response): Promise => { + try { + const cursorConfig = getCursorConfig(); + const authStatus = checkAuthStatus(); + const daemonStatus = await getDaemonStatus(cursorConfig.port); + + res.json({ + enabled: cursorConfig.enabled, + authenticated: authStatus.authenticated, + auth_method: authStatus.credentials?.authMethod ?? null, + token_age: authStatus.tokenAge ?? null, + token_expired: authStatus.expired ?? false, + daemon_running: daemonStatus.running, + port: cursorConfig.port, + auto_start: cursorConfig.auto_start, + ghost_mode: cursorConfig.ghost_mode, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/auth/import - Import Cursor token manually + */ +router.post('/auth/import', async (req: Request, res: Response): Promise => { + try { + const { accessToken, machineId } = req.body; + if (!accessToken || !machineId) { + res.status(400).json({ error: 'Missing accessToken or machineId' }); + return; + } + + // Validate token format + if (!validateToken(accessToken, machineId)) { + res.status(400).json({ error: 'Invalid token or machine ID format' }); + return; + } + + // Save credentials + saveCredentials({ + accessToken, + machineId, + authMethod: 'manual', + importedAt: new Date().toISOString(), + }); + + res.json({ success: true, message: 'Token imported successfully' }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/auth/auto-detect - Auto-detect token from SQLite + */ +router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise => { + try { + const result = autoDetectTokens(); + + if (!result.found || !result.accessToken || !result.machineId) { + res.status(404).json({ error: result.error ?? 'Token not found' }); + return; + } + + // Save credentials + saveCredentials({ + accessToken: result.accessToken, + machineId: result.machineId, + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }); + + res.json({ success: true, message: 'Token auto-detected and imported' }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cursor/models - List available models + */ +router.get('/models', async (_req: Request, res: Response): Promise => { + try { + const cursorConfig = getCursorConfig(); + const models = await getAvailableModels(cursorConfig.port); + res.json({ models, current: getDefaultModel() }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/daemon/start - Start cursor proxy daemon + * Path matches copilot convention: /api/{provider}/daemon/{action} + */ +router.post('/daemon/start', async (_req: Request, res: Response): Promise => { + try { + const cursorConfig = getCursorConfig(); + const authStatus = checkAuthStatus(); + const preconditionError = getDaemonStartPreconditionError({ + enabled: cursorConfig.enabled, + authenticated: authStatus.authenticated, + tokenExpired: authStatus.expired ?? false, + }); + + if (preconditionError) { + res.status(preconditionError.status).json({ + success: false, + error: preconditionError.error, + }); + return; + } + + const result = await startDaemon({ + port: cursorConfig.port, + ghost_mode: cursorConfig.ghost_mode, + }); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/daemon/stop - Stop cursor proxy daemon + * Path matches copilot convention: /api/{provider}/daemon/{action} + */ +router.post('/daemon/stop', async (_req: Request, res: Response): Promise => { + try { + const result = await stopDaemon(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/cursor-settings-routes.ts b/src/web-server/routes/cursor-settings-routes.ts new file mode 100644 index 00000000..ce1d229a --- /dev/null +++ b/src/web-server/routes/cursor-settings-routes.ts @@ -0,0 +1,205 @@ +/** + * Cursor Settings Routes - Settings editor and raw settings for Cursor IDE + */ + +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types'; +import { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + getCursorConfig, +} from '../../config/unified-config-loader'; + +const router = Router(); + +function parseLocalCursorPort(settings: unknown): number | null { + if (typeof settings !== 'object' || settings === null) return null; + const env = (settings as { env?: unknown }).env; + if (typeof env !== 'object' || env === null) return null; + const baseUrl = (env as { ANTHROPIC_BASE_URL?: unknown }).ANTHROPIC_BASE_URL; + if (typeof baseUrl !== 'string' || !baseUrl) return null; + + try { + const parsed = new URL(baseUrl); + if (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') return null; + const port = Number(parsed.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + return port; + } catch { + return null; + } +} + +/** + * GET /api/cursor/settings - Get cursor config (port, auto_start, ghost_mode) + */ +router.get('/', (_req: Request, res: Response): void => { + try { + const cursorConfig = getCursorConfig(); + res.json(cursorConfig); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/cursor/settings - Update cursor config + */ +router.put('/', (req: Request, res: Response): void => { + try { + const updates = req.body; + + // Reject non-object bodies + if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { + res.status(400).json({ error: 'Request body must be a JSON object' }); + return; + } + + // Validate input types + if ('port' in updates) { + if (typeof updates.port !== 'number' || !Number.isInteger(updates.port)) { + res.status(400).json({ error: 'port must be an integer' }); + return; + } + if (updates.port < 1 || updates.port > 65535) { + res.status(400).json({ error: 'port must be between 1 and 65535' }); + return; + } + } + if ('enabled' in updates && typeof updates.enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + if ('auto_start' in updates && typeof updates.auto_start !== 'boolean') { + res.status(400).json({ error: 'auto_start must be a boolean' }); + return; + } + if ('ghost_mode' in updates && typeof updates.ghost_mode !== 'boolean') { + res.status(400).json({ error: 'ghost_mode must be a boolean' }); + return; + } + + const config = loadOrCreateUnifiedConfig(); + + // Merge updates with existing config + // Only known fields are merged — unknown properties are ignored + config.cursor = { + enabled: updates.enabled ?? config.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, + port: updates.port ?? config.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, + auto_start: + updates.auto_start ?? config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start, + ghost_mode: + updates.ghost_mode ?? config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode, + }; + + saveUnifiedConfig(config); + res.json({ success: true, cursor: config.cursor }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cursor/settings/raw - Get raw cursor.settings.json + * Returns the raw JSON content for editing in the code editor + */ +router.get('/raw', (_req: Request, res: Response): void => { + try { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const cursorConfig = getCursorConfig(); + + // If file doesn't exist, return default structure + if (!fs.existsSync(settingsPath)) { + // Create settings structure matching Cursor pattern + // Use 127.0.0.1 instead of localhost for more reliable local connections + const defaultSettings = { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorConfig.port}`, + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + }, + }; + + res.json({ + settings: defaultSettings, + mtime: Date.now(), + path: '~/.ccs/cursor.settings.json', + exists: false, + }); + return; + } + + const content = fs.readFileSync(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + const stat = fs.statSync(settingsPath); + + res.json({ + settings, + mtime: stat.mtimeMs, + path: '~/.ccs/cursor.settings.json', + exists: true, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/cursor/settings/raw - Save raw cursor.settings.json + * Saves the raw JSON content from the code editor + */ +router.put('/raw', (req: Request, res: Response): void => { + try { + const { settings, expectedMtime } = req.body; + + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + res.status(400).json({ error: 'settings must be a JSON object' }); + return; + } + + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + + // For existing files, expectedMtime is required to prevent blind overwrite races. + if (fs.existsSync(settingsPath)) { + const stat = fs.statSync(settingsPath); + if (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)) { + res.status(409).json({ + error: 'File metadata not loaded. Refresh and retry.', + mtime: stat.mtimeMs, + }); + return; + } + + if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) { + res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs }); + return; + } + } + + // Write settings file atomically + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + // Keep unified config aligned with raw settings edits (parity with Copilot raw editor). + const parsedPort = parseLocalCursorPort(settings); + if (parsedPort) { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { + ...(config.cursor ?? DEFAULT_CURSOR_CONFIG), + port: parsedPort, + }; + saveUnifiedConfig(config); + } + + const stat = fs.statSync(settingsPath); + res.json({ success: true, mtime: stat.mtimeMs }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index c488a9c9..f9517635 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -20,6 +20,7 @@ import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; import copilotRoutes from './copilot-routes'; +import cursorRoutes from './cursor-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; @@ -63,6 +64,9 @@ apiRoutes.use('/websearch', websearchRoutes); // ==================== Copilot ==================== apiRoutes.use('/copilot', copilotRoutes); +// ==================== Cursor ==================== +apiRoutes.use('/cursor', cursorRoutes); + // ==================== CLIProxy Server Settings ==================== apiRoutes.use('/cliproxy-server', cliproxyServerRoutes); diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index 0feee3d7..c21429ba 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -13,7 +13,13 @@ import { listVariants, validateProfileName, updateVariant, + createCompositeVariant, + updateCompositeVariant, } from '../../cliproxy/services/variant-service'; +import { + validateCompositeDefaultTier, + validateCompositeTiers, +} from '../../cliproxy/composite-validator'; const router = Router(); @@ -30,6 +36,9 @@ router.get('/', (_req: Request, res: Response) => { account: variant.account || 'default', port: variant.port, // Include port for port isolation model: variant.model, + type: variant.type, + default_tier: variant.default_tier, + tiers: variant.tiers, })); res.json({ variants: variantList }); @@ -40,10 +49,10 @@ router.get('/', (_req: Request, res: Response) => { * Uses variant-service for proper port allocation */ router.post('/', (req: Request, res: Response): void => { - const { name, provider, model, account } = req.body; + const { name, provider, model, account, type, default_tier, tiers } = req.body; - if (!name || !provider) { - res.status(400).json({ error: 'Missing required fields: name, provider' }); + if (!name) { + res.status(400).json({ error: 'Missing required field: name' }); return; } @@ -63,6 +72,53 @@ router.post('/', (req: Request, res: Response): void => { return; } + // Handle composite variant creation + if (type === 'composite') { + if (!default_tier || !tiers) { + res.status(400).json({ error: 'Missing required fields: default_tier, tiers' }); + return; + } + + // Validate tiers shape, providers, and default_tier (all tiers required for create) + const tierError = validateCompositeTiers(tiers, { + defaultTier: default_tier, + requireAllTiers: true, + }); + if (tierError) { + res.status(400).json({ error: tierError }); + return; + } + + let result; + try { + result = createCompositeVariant({ name, defaultTier: default_tier, tiers }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + return; + } + + if (!result.success) { + res.status(409).json({ error: result.error }); + return; + } + + res.status(201).json({ + name, + type: 'composite', + default_tier, + tiers, + settings: result.settingsPath, + port: result.variant?.port, + }); + return; + } + + // Handle single provider variant creation + if (!provider) { + res.status(400).json({ error: 'Missing required field: provider' }); + return; + } + // Require model for variant creation (prevents empty model causing issues) if (!model || !model.trim()) { res.status(400).json({ error: 'Missing required field: model' }); @@ -90,17 +146,78 @@ router.post('/', (req: Request, res: Response): void => { /** * PUT /api/cliproxy/:name - Update cliproxy variant * Uses variant-service for consistent behavior with CLI + * + * TODO: Add file-based locking (e.g., proper-lockfile) to prevent concurrent modification + * Current behavior: last-write-wins if two requests modify same variant simultaneously */ router.put('/:name', (req: Request, res: Response): void => { try { const { name } = req.params; - const { provider, account, model } = req.body; + const { provider, account, model, default_tier, tiers } = req.body; - // Use variant-service for proper update handling + // Check if variant is composite - use updateCompositeVariant if so + const variants = listVariants(); + const existing = variants[name]; + + if (!existing) { + res.status(404).json({ error: `Variant '${name}' not found` }); + return; + } + + if (existing.type === 'composite') { + if (!default_tier && !tiers) { + res.status(400).json({ error: 'Must provide at least default_tier or tiers' }); + return; + } + + // Validate tiers shape, providers, and default_tier if provided + if (tiers) { + const tierError = validateCompositeTiers(tiers, { + defaultTier: default_tier, + }); + if (tierError) { + res.status(400).json({ error: tierError }); + return; + } + } else { + const defaultTierError = validateCompositeDefaultTier(default_tier); + if (defaultTierError) { + res.status(400).json({ + error: defaultTierError, + }); + return; + } + } + + const result = updateCompositeVariant(name, { defaultTier: default_tier, tiers }); + + if (!result.success) { + const status = result.error?.includes('not found') ? 404 : 400; + res.status(status).json({ + error: result.error, + }); + return; + } + + const persisted = result.variant; + res.json({ + name, + type: 'composite', + default_tier: persisted?.default_tier, + tiers: persisted?.tiers, + settings: persisted?.settings, + port: persisted?.port, + updated: true, + }); + return; + } + + // Use variant-service for proper update handling (single provider) const result = updateVariant(name, { provider, account, model }); if (!result.success) { - res.status(404).json({ error: result.error }); + const status = result.error?.includes('not found') ? 404 : 400; + res.status(status).json({ error: result.error }); return; } diff --git a/tests/unit/cliproxy/composite-env-routing.test.ts b/tests/unit/cliproxy/composite-env-routing.test.ts new file mode 100644 index 00000000..18978f21 --- /dev/null +++ b/tests/unit/cliproxy/composite-env-routing.test.ts @@ -0,0 +1,74 @@ +/** + * Composite environment routing tests. + */ + +import { describe, it, expect } from 'bun:test'; +import { buildClaudeEnvironment } from '../../../src/cliproxy/executor/env-resolver'; + +const tiers = { + opus: { provider: 'agy' as const, model: 'claude-opus-4-6-thinking' }, + sonnet: { provider: 'gemini' as const, model: 'gemini-2.5-pro' }, + haiku: { provider: 'codex' as const, model: 'gpt-5.1-codex-mini' }, +}; + +describe('buildClaudeEnvironment - composite remote routing', () => { + it('uses remote base URL and auth token for direct remote composite mode', () => { + const env = buildClaudeEnvironment({ + provider: 'agy', + useRemoteProxy: true, + remoteConfig: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-auth-token', + }, + localPort: 8318, + verbose: false, + isComposite: true, + compositeTiers: tiers, + compositeDefaultTier: 'sonnet', + }); + + expect(env.ANTHROPIC_BASE_URL).toBe('https://remote.example.com:9443'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('remote-auth-token'); + expect(env.ANTHROPIC_BASE_URL).not.toContain('/api/provider/'); + }); + + it('uses local tunnel endpoint for HTTPS remote composite mode', () => { + const env = buildClaudeEnvironment({ + provider: 'agy', + useRemoteProxy: true, + remoteConfig: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-auth-token', + }, + httpsTunnel: {} as never, + tunnelPort: 9911, + localPort: 8318, + verbose: false, + isComposite: true, + compositeTiers: tiers, + compositeDefaultTier: 'sonnet', + }); + + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9911'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('remote-auth-token'); + expect(env.ANTHROPIC_BASE_URL).not.toContain('/api/provider/'); + }); + + it('applies thinking override to resolved env models', () => { + const env = buildClaudeEnvironment({ + provider: 'gemini', + useRemoteProxy: false, + localPort: 8318, + verbose: false, + thinkingOverride: 'high', + }); + + expect(env.ANTHROPIC_MODEL).toContain('gemini'); + // Gemini thinking overrides may be normalized to numeric budgets, and [1m] may be auto-appended. + expect(env.ANTHROPIC_MODEL).toMatch(/\([^)]+\)(\[1m\])?$/); + }); +}); diff --git a/tests/unit/cliproxy/composite-fallback.test.ts b/tests/unit/cliproxy/composite-fallback.test.ts new file mode 100644 index 00000000..338f6a02 --- /dev/null +++ b/tests/unit/cliproxy/composite-fallback.test.ts @@ -0,0 +1,348 @@ +/** + * Composite Variant Fallback Tests + * + * Tests fallback detection and application for composite variants + */ + +import { describe, it, expect } from 'bun:test'; +import { + isProviderError, + detectFailedTier, + PROVIDER_ERROR_PATTERNS, +} from '../../../src/cliproxy/executor/retry-handler'; +import { applyFallback } from '../../../src/cliproxy/executor/env-resolver'; +import { CompositeTierConfig } from '../../../src/config/unified-config-types'; + +// ======================================== +// isProviderError +// ======================================== + +describe('isProviderError', () => { + it('should return false for exit code 0 (success)', () => { + const result = isProviderError(0, 'Error: 500 Internal Server Error'); + expect(result).toBe(false); + }); + + it('should detect 4xx error codes', () => { + const stderr = 'Error: 401 Unauthorized'; + const result = isProviderError(1, stderr); + expect(result).toBe(true); + }); + + it('should detect 5xx error codes', () => { + const stderr = 'Error: 503 Service Unavailable'; + const result = isProviderError(1, stderr); + expect(result).toBe(true); + }); + + it('should detect overloaded errors', () => { + const stderr = 'Provider is currently overloaded. Please try again later.'; + const result = isProviderError(1, stderr); + expect(result).toBe(true); + }); + + it('should detect quota exceeded errors', () => { + const stderr = 'quota has been exceeded for this account'; + const result = isProviderError(1, stderr); + expect(result).toBe(true); + }); + + it('should detect connection refused errors', () => { + const stderr = 'ECONNREFUSED 127.0.0.1:8317'; + const result = isProviderError(1, stderr); + expect(result).toBe(true); + }); + + it('should detect rate limit errors', () => { + const stderr = 'Rate limit exceeded. Please slow down.'; + const result = isProviderError(1, stderr); + expect(result).toBe(true); + }); + + it('should return false for normal user exit', () => { + const stderr = 'User cancelled the operation'; + const result = isProviderError(1, stderr); + expect(result).toBe(false); + }); + + it('should return false for empty stderr with non-zero exit', () => { + const result = isProviderError(1, ''); + expect(result).toBe(false); + }); + + it('should be case insensitive for error patterns', () => { + const stderr = 'ERROR: OVERLOADED'; + const result = isProviderError(1, stderr); + expect(result).toBe(true); + }); + + it('should handle multiple error patterns in same stderr', () => { + const stderr = 'Error: 429 rate limit exceeded'; + const result = isProviderError(1, stderr); + expect(result).toBe(true); + }); +}); + +// ======================================== +// detectFailedTier +// ======================================== + +describe('detectFailedTier', () => { + const tiers: { + opus: CompositeTierConfig; + sonnet: CompositeTierConfig; + haiku: CompositeTierConfig; + } = { + opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, + sonnet: { provider: 'gemini', model: 'gemini-3-pro-preview' }, + haiku: { provider: 'codex', model: 'gpt-4o-mini' }, + }; + + it('should detect opus tier from model name in stderr', () => { + const stderr = 'Error calling model claude-opus-4-6-thinking: 503 overloaded'; + const result = detectFailedTier(stderr, tiers); + expect(result).toBe('opus'); + }); + + it('should detect sonnet tier from model name in stderr', () => { + const stderr = 'Failed to connect: gemini-3-pro-preview returned 500'; + const result = detectFailedTier(stderr, tiers); + expect(result).toBe('sonnet'); + }); + + it('should detect haiku tier from model name in stderr', () => { + const stderr = 'gpt-4o-mini: rate limit exceeded'; + const result = detectFailedTier(stderr, tiers); + expect(result).toBe('haiku'); + }); + + it('should return null when no tier model found in stderr', () => { + const stderr = 'Generic error with no model mention'; + const result = detectFailedTier(stderr, tiers); + expect(result).toBe(null); + }); + + it('should return null for empty stderr', () => { + const result = detectFailedTier('', tiers); + expect(result).toBe(null); + }); + + it('should match first tier when multiple models mentioned', () => { + const stderr = + 'Tried claude-opus-4-6-thinking, then gemini-3-pro-preview, both failed'; + const result = detectFailedTier(stderr, tiers); + expect(result).toBe('opus'); // First match + }); + + it('should handle model names with suffixes (thinking budgets)', () => { + const tiersWithBudget: typeof tiers = { + opus: { provider: 'agy', model: 'claude-opus-4-6-thinking(high)' }, + sonnet: { provider: 'gemini', model: 'gemini-3-pro-preview(medium)' }, + haiku: { provider: 'codex', model: 'gpt-4o-mini' }, + }; + + // Stderr might not include suffix + const stderr = 'Error with claude-opus-4-6-thinking: timeout'; + const result = detectFailedTier(stderr, tiersWithBudget); + // Should still match because model name is substring + expect(result).toBe('opus'); + }); + + it('should strip complex thinking suffix with comma-separated params', () => { + const tiersWithComplexBudget: typeof tiers = { + opus: { provider: 'agy', model: 'claude-opus-4-6-thinking(32768,extended)' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking(high)' }, + haiku: { provider: 'agy', model: 'claude-3-5-haiku' }, + }; + + // Stderr contains base model name without suffix + const stderr = 'Error: claude-opus-4-6-thinking overloaded'; + const result = detectFailedTier(stderr, tiersWithComplexBudget); + // Should match because regex strips (32768,extended) to get base name + expect(result).toBe('opus'); + }); +}); + +// ======================================== +// applyFallback +// ======================================== + +describe('applyFallback', () => { + it('should update opus tier env var', () => { + const env = { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318', + ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyFallback(env, 'opus', { + provider: 'gemini', + model: 'gemini-3-pro-preview', + }); + + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview'); + // Should not modify other tiers + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking'); + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + }); + + it('should update sonnet tier env var', () => { + const env = { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318', + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyFallback(env, 'sonnet', { + provider: 'codex', + model: 'gpt-4o', + }); + + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-4o'); + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + }); + + it('should update haiku tier env var', () => { + const env = { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318', + ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyFallback(env, 'haiku', { + provider: 'gemini', + model: 'gemini-3-flash-preview', + }); + + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gemini-3-flash-preview'); + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking'); + }); + + it('should update ANTHROPIC_MODEL when failed tier is default tier', () => { + const env = { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318', + ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyFallback(env, 'opus', { + provider: 'gemini', + model: 'gemini-3-pro-preview', + }); + + // Both tier model and default model should be updated + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview'); + expect(result.ANTHROPIC_MODEL).toBe('gemini-3-pro-preview'); + }); + + it('should NOT update ANTHROPIC_MODEL when failed tier is not default tier', () => { + const env = { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318', + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', // Sonnet is default + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyFallback(env, 'opus', { + provider: 'gemini', + model: 'gemini-3-pro-preview', + }); + + // Opus tier should be updated but ANTHROPIC_MODEL should remain sonnet + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview'); + expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking'); + }); + + it('should preserve other env vars', () => { + const env = { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318', + ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + CUSTOM_VAR: 'custom_value', + ANOTHER_VAR: '12345', + }; + + const result = applyFallback(env, 'opus', { + provider: 'gemini', + model: 'gemini-3-pro-preview', + }); + + expect(result.CUSTOM_VAR).toBe('custom_value'); + expect(result.ANOTHER_VAR).toBe('12345'); + expect(result.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8318'); + }); + + it('should not mutate original env object', () => { + const env = { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318', + ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const original = { ...env }; + applyFallback(env, 'opus', { + provider: 'gemini', + model: 'gemini-3-pro-preview', + }); + + // Original env should be unchanged + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe(original.ANTHROPIC_DEFAULT_OPUS_MODEL); + }); +}); + +// ======================================== +// PROVIDER_ERROR_PATTERNS +// ======================================== + +describe('PROVIDER_ERROR_PATTERNS', () => { + it('should export error pattern regexes', () => { + expect(PROVIDER_ERROR_PATTERNS).toBeDefined(); + expect(Array.isArray(PROVIDER_ERROR_PATTERNS)).toBe(true); + expect(PROVIDER_ERROR_PATTERNS.length).toBeGreaterThan(0); + }); + + it('should match 4xx errors', () => { + const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('Error: 401')); + expect(pattern).toBeDefined(); + }); + + it('should match 5xx errors', () => { + const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('Error: 503')); + expect(pattern).toBeDefined(); + }); + + it('should match overloaded keyword', () => { + const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('overloaded')); + expect(pattern).toBeDefined(); + }); + + it('should match quota exceeded', () => { + const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('quota exceeded')); + expect(pattern).toBeDefined(); + }); + + it('should match ECONNREFUSED', () => { + const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('ECONNREFUSED')); + expect(pattern).toBeDefined(); + }); + + it('should match rate limit', () => { + const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('rate limit')); + expect(pattern).toBeDefined(); + }); +}); diff --git a/tests/unit/cliproxy/composite-thinking.test.ts b/tests/unit/cliproxy/composite-thinking.test.ts new file mode 100644 index 00000000..f217de64 --- /dev/null +++ b/tests/unit/cliproxy/composite-thinking.test.ts @@ -0,0 +1,535 @@ +/** + * Composite Variant Thinking Configuration Tests + * + * Tests per-tier thinking configuration for composite variants + */ + +import { describe, it, expect } from 'bun:test'; +import { + applyThinkingConfig, + applyThinkingSuffix, + detectTierFromModel, + getThinkingValueForTier, +} from '../../../src/cliproxy/config/thinking-config'; +import { CLIProxyProvider } from '../../../src/cliproxy/types'; + +// ======================================== +// applyThinkingSuffix +// ======================================== + +describe('applyThinkingSuffix', () => { + it('should append level name to model', () => { + const result = applyThinkingSuffix('gemini-3-pro-preview', 'high'); + expect(result).toBe('gemini-3-pro-preview(high)'); + }); + + it('should append numeric budget to model', () => { + const result = applyThinkingSuffix('claude-opus-4-6-thinking', 8192); + expect(result).toBe('claude-opus-4-6-thinking(8192)'); + }); + + it('should not append suffix if model already has one', () => { + const result = applyThinkingSuffix('gemini-3-pro-preview(medium)', 'high'); + expect(result).toBe('gemini-3-pro-preview(medium)'); + }); + + it('should not append suffix if model already has numeric budget', () => { + const result = applyThinkingSuffix('claude-opus-4(8192)', 16384); + expect(result).toBe('claude-opus-4(8192)'); + }); + + it('should handle empty parentheses as NOT having suffix', () => { + const result = applyThinkingSuffix('model()', 'high'); + // Empty parens don't match regex /\([^)]+\)$/ (requires non-empty content) + expect(result).toBe('model()(high)'); + }); + + it('should append to model with hyphens', () => { + const result = applyThinkingSuffix('gpt-4o-mini', 'low'); + expect(result).toBe('gpt-4o-mini(low)'); + }); +}); + +// ======================================== +// detectTierFromModel +// ======================================== + +describe('detectTierFromModel', () => { + it('should detect opus from model name containing "opus"', () => { + const result = detectTierFromModel('claude-opus-4-6-thinking'); + expect(result).toBe('opus'); + }); + + it('should detect sonnet from model name containing "sonnet"', () => { + const result = detectTierFromModel('claude-sonnet-4-5-thinking'); + expect(result).toBe('sonnet'); + }); + + it('should detect haiku from model name containing "haiku"', () => { + const result = detectTierFromModel('claude-haiku-4-5-20251001'); + expect(result).toBe('haiku'); + }); + + it('should default to sonnet for unknown model names', () => { + const result = detectTierFromModel('gpt-4o-mini'); + expect(result).toBe('sonnet'); + }); + + it('should default to sonnet for gemini models', () => { + const result = detectTierFromModel('gemini-3-pro-preview'); + expect(result).toBe('sonnet'); + }); + + it('should be case-insensitive', () => { + const result = detectTierFromModel('CLAUDE-OPUS-4'); + expect(result).toBe('opus'); + }); + + it('should detect haiku even with uppercase', () => { + const result = detectTierFromModel('CLAUDE-HAIKU-4'); + expect(result).toBe('haiku'); + }); +}); + +// ======================================== +// getThinkingValueForTier (mock config) +// ======================================== + +describe('getThinkingValueForTier', () => { + // Note: This function reads from unified config, so we're testing the logic + // For full integration tests, see composite-variant-service.test.ts + + it('should return tier default when no provider override', () => { + const thinkingConfig = { + mode: 'auto' as const, + tier_defaults: { + opus: 'high', + sonnet: 'medium', + haiku: 'low', + }, + }; + + const result = getThinkingValueForTier('opus', 'agy' as CLIProxyProvider, thinkingConfig); + expect(result).toBe('high'); + }); + + it('should return provider-specific override when configured', () => { + const thinkingConfig = { + mode: 'auto' as const, + tier_defaults: { + opus: 'high', + sonnet: 'medium', + haiku: 'low', + }, + provider_overrides: { + gemini: { + opus: 'xhigh', + sonnet: 'high', + haiku: 'medium', + }, + }, + }; + + const result = getThinkingValueForTier('opus', 'gemini' as CLIProxyProvider, thinkingConfig); + expect(result).toBe('xhigh'); + }); + + it('should fall back to tier default when provider has no override for tier', () => { + const thinkingConfig = { + mode: 'auto' as const, + tier_defaults: { + opus: 'high', + sonnet: 'medium', + haiku: 'low', + }, + provider_overrides: { + gemini: { + opus: 'xhigh', + // No sonnet override + }, + }, + }; + + const result = getThinkingValueForTier('sonnet', 'gemini' as CLIProxyProvider, thinkingConfig); + expect(result).toBe('medium'); + }); + + it('should use centralized defaults when tier_defaults undefined', () => { + const thinkingConfig = { + mode: 'auto' as const, + // tier_defaults is optional - centralized defaults kick in + }; + + const result = getThinkingValueForTier('opus', 'agy' as CLIProxyProvider, thinkingConfig); + // DEFAULT_THINKING_TIER_DEFAULTS.opus = 'high' + expect(result).toBe('high'); + }); +}); + +// ======================================== +// applyThinkingConfig with compositeTierThinking +// ======================================== + +describe('applyThinkingConfig - composite variant integration', () => { + it('should apply per-tier thinking from compositeTierThinking parameter', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const compositeTierThinking = { + opus: 'xhigh', + sonnet: 'medium', + haiku: 'low', + }; + + const result = applyThinkingConfig( + envVars, + 'agy' as CLIProxyProvider, + undefined, // No CLI override + compositeTierThinking + ); + + // Tier models use raw compositeTierThinking value (no validation in tier loop) + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(xhigh)'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(medium)'); + // Haiku doesn't support thinking per model-catalog — no suffix + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + // ANTHROPIC_MODEL is validated: 'medium' → 8192 for budget-type models + expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(8192)'); + }); + + it('should apply partial per-tier thinking (only some tiers specified)', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const compositeTierThinking = { + opus: 'xhigh', + // sonnet and haiku will fall back to global config + }; + + const result = applyThinkingConfig( + envVars, + 'agy' as CLIProxyProvider, + undefined, + compositeTierThinking + ); + + // Tier models use raw value (no validation in tier loop) + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(xhigh)'); + // Sonnet gets defaults from global config (mode=auto) + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toContain('claude-sonnet-4-5-thinking'); + // Haiku doesn't support thinking — stays unchanged + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + }); + + it('should allow per-tier thinking to be "off"', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const compositeTierThinking = { + opus: 'high', + sonnet: 'medium', + haiku: 'off', // Explicitly disabled for haiku + }; + + const result = applyThinkingConfig( + envVars, + 'agy' as CLIProxyProvider, + undefined, + compositeTierThinking + ); + + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(high)'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(medium)'); + // Haiku should NOT have suffix because thinking is not supported for haiku + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + }); + + it('should prioritize CLI override over per-tier thinking', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const compositeTierThinking = { + opus: 'xhigh', + sonnet: 'medium', + haiku: 'low', + }; + + const result = applyThinkingConfig( + envVars, + 'agy' as CLIProxyProvider, + 'minimal', // CLI override takes priority + compositeTierThinking + ); + + // All thinking-capable tiers should use CLI override + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(minimal)'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(minimal)'); + // Haiku doesn't support thinking — stays unchanged regardless of CLI override + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + }); + + it('should disable all tier thinking when CLI override is explicitly off', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const compositeTierThinking = { + opus: 'xhigh', + sonnet: 'medium', + haiku: 'low', + }; + + const result = applyThinkingConfig( + envVars, + 'agy' as CLIProxyProvider, + 'off', + compositeTierThinking + ); + + expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking'); + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking'); + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + }); + + it('uses per-tier provider capability checks for mixed-provider composites', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gemini-2.5-pro', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-2.5-pro', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyThinkingConfig( + envVars, + 'gemini' as CLIProxyProvider, + undefined, + { + sonnet: 'high', + }, + { + sonnet: { provider: 'agy' as CLIProxyProvider }, + } + ); + + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); + }); + + it('applies tier thinking even when default model does not support thinking', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gpt-4o-mini', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-4o-mini', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyThinkingConfig( + envVars, + 'codex' as CLIProxyProvider, + undefined, + { + sonnet: 'high', + }, + { + sonnet: { provider: 'agy' as CLIProxyProvider }, + } + ); + + // Default model provider/model do not support thinking. + expect(result.ANTHROPIC_MODEL).toBe('gpt-4o-mini'); + // Supported mixed-provider tier must still receive its configured thinking value. + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); + }); + + it('should handle numeric budgets in per-tier thinking', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const compositeTierThinking = { + opus: '32768', + sonnet: '8192', + haiku: '512', + }; + + const result = applyThinkingConfig( + envVars, + 'agy' as CLIProxyProvider, + undefined, + compositeTierThinking + ); + + // Tier models use raw string values (no validation in tier loop) + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(8192)'); + // Haiku doesn't support thinking — stays unchanged + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + }); + + it('should not apply thinking when mode is off and no override', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const compositeTierThinking = { + opus: 'high', + sonnet: 'medium', + haiku: 'low', + }; + + // Mock thinking config with mode=off by calling without thinkingOverride + // and ensuring global config mode is 'off' + // This test would require mocking getThinkingConfig() + // For now, test the behavior when compositeTierThinking is undefined + + const result = applyThinkingConfig( + envVars, + 'agy' as CLIProxyProvider, + undefined, + undefined // No per-tier thinking + ); + + // When mode=auto and no compositeTierThinking, defaults apply + // This depends on global config - skipping full integration test here + expect(result).toBeDefined(); + }); + + it('should skip thinking for models that do not support it', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gpt-4o-mini', // Model that doesn't support thinking + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-4o-mini', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-4o-mini', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-4o-mini', + }; + + const compositeTierThinking = { + opus: 'high', + sonnet: 'medium', + haiku: 'low', + }; + + const result = applyThinkingConfig( + envVars, + 'codex' as CLIProxyProvider, + undefined, + compositeTierThinking + ); + + // Models should remain unchanged (no thinking suffix) because they don't support it + // Note: This depends on supportsThinking() logic in model-catalog.ts + // For models that don't support thinking, no suffix should be added + expect(result.ANTHROPIC_MODEL).toBe('gpt-4o-mini'); + }); + + it('should update ANTHROPIC_MODEL when it matches a tier model', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', // Matches opus tier + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const compositeTierThinking = { + opus: 'xhigh', + sonnet: 'medium', + haiku: 'low', + }; + + const result = applyThinkingConfig( + envVars, + 'agy' as CLIProxyProvider, + undefined, + compositeTierThinking + ); + + // ANTHROPIC_MODEL is validated: xhigh → 32768 for budget-type models + expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking(32768)'); + // Tier model uses raw value (no validation in tier loop) + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(xhigh)'); + }); + + it('should preserve models that already have thinking suffix', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking(high)', // Already has suffix + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking(high)', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const compositeTierThinking = { + opus: 'xhigh', + sonnet: 'medium', // Won't override existing suffix + haiku: 'low', + }; + + const result = applyThinkingConfig( + envVars, + 'agy' as CLIProxyProvider, + undefined, + compositeTierThinking + ); + + // Existing suffix on main model should be preserved. + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); + expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); + // Other supported tiers still receive configured thinking. + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(xhigh)'); + }); + + it('should use codex effort suffix style when provider is codex', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini', + }; + + const result = applyThinkingConfig( + envVars, + 'codex' as CLIProxyProvider, + 'xhigh', // Explicit CLI override + undefined + ); + + expect(result.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-xhigh'); + }); + + it('should normalize legacy codex parenthesized tier suffix to effort suffix format', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex(high)', + }; + + const result = applyThinkingConfig(envVars, 'codex' as CLIProxyProvider, 'high'); + + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-high'); + }); +}); diff --git a/tests/unit/cliproxy/composite-validator.test.ts b/tests/unit/cliproxy/composite-validator.test.ts new file mode 100644 index 00000000..3701b089 --- /dev/null +++ b/tests/unit/cliproxy/composite-validator.test.ts @@ -0,0 +1,80 @@ +/** + * Unit tests for shared composite variant validation. + */ + +import { describe, it, expect } from 'bun:test'; +import { + validateCompositeDefaultTier, + validateCompositeTiers, +} from '../../../src/cliproxy/composite-validator'; + +const validTier = { + provider: 'agy', + model: 'claude-sonnet-4-5-thinking', +}; + +describe('validateCompositeDefaultTier', () => { + it('accepts valid tier names', () => { + expect(validateCompositeDefaultTier('opus')).toBeNull(); + expect(validateCompositeDefaultTier('sonnet')).toBeNull(); + expect(validateCompositeDefaultTier('haiku')).toBeNull(); + }); + + it('rejects invalid tier names', () => { + const error = validateCompositeDefaultTier('invalid-tier'); + expect(error).toContain("Invalid default_tier 'invalid-tier'"); + }); +}); + +describe('validateCompositeTiers', () => { + it('rejects missing required tiers in create mode', () => { + const error = validateCompositeTiers( + { + opus: validTier, + sonnet: validTier, + }, + { defaultTier: 'sonnet', requireAllTiers: true } + ); + + expect(error).toContain("Missing required tier 'haiku'"); + }); + + it('rejects null tier objects', () => { + const error = validateCompositeTiers( + { + opus: null, + sonnet: validTier, + haiku: validTier, + }, + { defaultTier: 'sonnet', requireAllTiers: true } + ); + + expect(error).toContain("Invalid tier config for 'opus'"); + }); + + it('accepts partial updates in update mode', () => { + const error = validateCompositeTiers( + { + opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, + }, + { defaultTier: 'sonnet' } + ); + + expect(error).toBeNull(); + }); + + it('rejects circular fallback definitions', () => { + const error = validateCompositeTiers( + { + opus: { + provider: 'gemini', + model: 'gemini-2.5-pro', + fallback: { provider: 'gemini', model: 'gemini-2.5-pro' }, + }, + }, + { defaultTier: 'opus' } + ); + + expect(error).toContain("Circular fallback in tier 'opus'"); + }); +}); diff --git a/tests/unit/cliproxy/composite-variant-service.test.ts b/tests/unit/cliproxy/composite-variant-service.test.ts new file mode 100644 index 00000000..22ad8ae8 --- /dev/null +++ b/tests/unit/cliproxy/composite-variant-service.test.ts @@ -0,0 +1,689 @@ +/** + * Unit tests for composite variant service operations + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { + createCompositeVariant, + updateCompositeVariant, +} from '../../../src/cliproxy/services/variant-service'; +import { + saveCompositeVariantUnified, + listVariantsFromConfig, +} from '../../../src/cliproxy/services/variant-config-adapter'; +import { CompositeVariantConfig } from '../../../src/config/unified-config-types'; + +describe('updateCompositeVariant', () => { + let tmpDir: string; + let originalCcsDir: string | undefined; + + beforeEach(() => { + // Create temp directory for isolated config + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-')); + originalCcsDir = process.env.CCS_DIR; + // Use CCS_DIR (not CCS_HOME which appends .ccs) + process.env.CCS_DIR = tmpDir; + + // Create unified config file + const configDir = tmpDir; + const configPath = path.join(configDir, 'config.yaml'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + configPath, + `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +`, + 'utf-8' + ); + }); + + afterEach(() => { + // Restore original CCS_DIR + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + // Clean up temp directory + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should update partial tier config (only opus model)', () => { + // Setup: Create initial composite variant + const initialConfig: CompositeVariantConfig = { + type: 'composite', + default_tier: 'sonnet', + tiers: { + opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + }, + settings: 'cliproxy/composite-test.settings.json', + port: 8318, + }; + saveCompositeVariantUnified('test', initialConfig); + + // Create dummy settings file to avoid deletion error + const settingsDir = path.join(tmpDir, 'cliproxy'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, 'composite-test.settings.json'), + JSON.stringify({ env: {} }), + 'utf-8' + ); + + // Test: Update only opus tier + const result = updateCompositeVariant('test', { + tiers: { + opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, + }, + }); + + expect(result.success).toBe(true); + expect(result.variant?.tiers?.opus.model).toBe('claude-opus-4-6-thinking'); + expect(result.variant?.tiers?.sonnet.model).toBe('claude-sonnet-4-5-thinking'); // Unchanged + expect(result.variant?.tiers?.haiku.model).toBe('claude-haiku-4-5-20251001'); // Unchanged + }); + + it('should update default tier', () => { + // Setup: Create initial composite variant + const initialConfig: CompositeVariantConfig = { + type: 'composite', + default_tier: 'sonnet', + tiers: { + opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + }, + settings: 'cliproxy/composite-test.settings.json', + port: 8318, + }; + saveCompositeVariantUnified('test', initialConfig); + + // Create dummy settings file + const settingsDir = path.join(tmpDir, 'cliproxy'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, 'composite-test.settings.json'), + JSON.stringify({ env: {} }), + 'utf-8' + ); + + // Test: Update default tier + const result = updateCompositeVariant('test', { + defaultTier: 'opus', + }); + + expect(result.success).toBe(true); + expect(result.variant?.default_tier).toBe('opus'); + expect(result.variant?.provider).toBe('gemini'); // Provider from opus tier + }); + + it('should update all tiers', () => { + // Setup: Create initial composite variant + const initialConfig: CompositeVariantConfig = { + type: 'composite', + default_tier: 'sonnet', + tiers: { + opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + }, + settings: 'cliproxy/composite-test.settings.json', + port: 8318, + }; + saveCompositeVariantUnified('test', initialConfig); + + // Create dummy settings file + const settingsDir = path.join(tmpDir, 'cliproxy'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, 'composite-test.settings.json'), + JSON.stringify({ env: {} }), + 'utf-8' + ); + + // Test: Update all tiers + const result = updateCompositeVariant('test', { + tiers: { + opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, + sonnet: { provider: 'codex', model: 'codex-sonnet-4-5' }, + haiku: { provider: 'gemini', model: 'gemini-2.5-flash' }, + }, + }); + + expect(result.success).toBe(true); + expect(result.variant?.tiers?.opus.provider).toBe('agy'); + expect(result.variant?.tiers?.sonnet.provider).toBe('codex'); + expect(result.variant?.tiers?.haiku.provider).toBe('gemini'); + }); + + it('should preserve optional tier fields when updating provider/model only', () => { + const initialConfig: CompositeVariantConfig = { + type: 'composite', + default_tier: 'sonnet', + tiers: { + opus: { + provider: 'agy', + model: 'claude-opus-4-6-thinking', + fallback: { provider: 'gemini', model: 'gemini-2.5-flash' }, + thinking: 'xhigh', + account: 'team-a', + }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + }, + settings: 'cliproxy/composite-test.settings.json', + port: 8318, + }; + saveCompositeVariantUnified('test', initialConfig); + + const settingsDir = path.join(tmpDir, 'cliproxy'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, 'composite-test.settings.json'), + JSON.stringify({ env: {} }), + 'utf-8' + ); + + const result = updateCompositeVariant('test', { + tiers: { + opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, + }, + }); + + expect(result.success).toBe(true); + expect(result.variant?.tiers?.opus.provider).toBe('gemini'); + expect(result.variant?.tiers?.opus.model).toBe('gemini-2.5-pro'); + expect(result.variant?.tiers?.opus.fallback).toEqual({ + provider: 'gemini', + model: 'gemini-2.5-flash', + }); + expect(result.variant?.tiers?.opus.thinking).toBe('xhigh'); + expect(result.variant?.tiers?.opus.account).toBe('team-a'); + }); + + it('should preserve existing custom settings path and custom settings fields', () => { + const customSettingsPath = path.join(tmpDir, 'custom', 'my-composite.settings.json'); + const initialConfig: CompositeVariantConfig = { + type: 'composite', + default_tier: 'sonnet', + tiers: { + opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + }, + settings: customSettingsPath, + port: 8318, + }; + saveCompositeVariantUnified('test', initialConfig); + + fs.mkdirSync(path.dirname(customSettingsPath), { recursive: true }); + fs.writeFileSync( + customSettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + CUSTOM_ENV: 'preserve-this', + }, + hooks: { PreToolUse: [{ matcher: 'WebSearch', hooks: [] }] }, + customPreset: true, + }, + null, + 2 + ), + 'utf-8' + ); + + const result = updateCompositeVariant('test', { + tiers: { + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking(high)' }, + }, + }); + + expect(result.success).toBe(true); + expect(result.variant?.settings).toBe(customSettingsPath); + + const updatedSettings = JSON.parse(fs.readFileSync(customSettingsPath, 'utf-8')) as { + env: Record; + hooks: { PreToolUse: unknown[] }; + customPreset: boolean; + }; + expect(updatedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); + expect(updatedSettings.env.CUSTOM_ENV).toBe('preserve-this'); + expect(updatedSettings.hooks.PreToolUse.length).toBe(1); + expect(updatedSettings.customPreset).toBe(true); + + const variants = listVariantsFromConfig(); + expect(variants.test.settings).toBe(customSettingsPath); + }); + + it('should return error when variant does not exist', () => { + const result = updateCompositeVariant('nonexistent', { + tiers: { + opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + }); + + it('should return error when variant is not composite type', () => { + // Setup: Create non-composite variant in unified config + const configPath = path.join(tmpDir, 'config.yaml'); + const yamlContent = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: + simple: + provider: gemini + settings: cliproxy/simple.settings.json + port: 8318 +`; + fs.writeFileSync(configPath, yamlContent, 'utf-8'); + + const result = updateCompositeVariant('simple', { + tiers: { + opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('not a composite variant'); + }); +}); + +describe('createCompositeVariant', () => { + let tmpDir: string; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-')); + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_DIR = tmpDir; + + const configPath = path.join(tmpDir, 'config.yaml'); + fs.writeFileSync( + configPath, + `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +`, + 'utf-8' + ); + }); + + afterEach(() => { + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns validation error for missing required tier in create flow', () => { + const result = createCompositeVariant({ + name: 'broken', + defaultTier: 'sonnet', + tiers: { + opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + } as unknown as { + opus: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + haiku: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Missing required tier 'haiku'"); + }); + + it('returns validation error for null tier payload in create flow', () => { + const result = createCompositeVariant({ + name: 'broken-null', + defaultTier: 'sonnet', + tiers: { + opus: null, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + } as unknown as { + opus: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + haiku: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid tier config for 'opus'"); + }); + + it('stores composite settings path under active CCS_DIR', () => { + const result = createCompositeVariant({ + name: 'scoped', + defaultTier: 'sonnet', + tiers: { + opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + }, + }); + + expect(result.success).toBe(true); + + const variants = listVariantsFromConfig(); + const expectedPath = path.join(tmpDir, 'composite-scoped.settings.json'); + expect(variants.scoped.settings).toBe(expectedPath); + expect(result.settingsPath).toBe(expectedPath); + expect(fs.existsSync(expectedPath)).toBe(true); + }); +}); + +describe('saveCompositeVariantUnified', () => { + let tmpDir: string; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-')); + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_DIR = tmpDir; + + // Create unified config file + const configPath = path.join(tmpDir, 'config.yaml'); + fs.writeFileSync( + configPath, + `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +`, + 'utf-8' + ); + }); + + afterEach(() => { + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should save composite variant to unified config', () => { + const config: CompositeVariantConfig = { + type: 'composite', + default_tier: 'sonnet', + tiers: { + opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + }, + settings: 'cliproxy/composite-test.settings.json', + port: 8318, + }; + + saveCompositeVariantUnified('test', config); + + const variants = listVariantsFromConfig(); + expect(variants.test).toBeDefined(); + expect(variants.test.type).toBe('composite'); + expect(variants.test.default_tier).toBe('sonnet'); + expect(variants.test.tiers?.opus.model).toBe('gemini-3-pro-preview'); + }); +}); + +describe('listVariantsFromConfig - composite variants', () => { + let tmpDir: string; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-')); + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_DIR = tmpDir; + }); + + afterEach(() => { + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should list composite variants with fallback field', () => { + // Create unified config with composite variant that has fallback + const configPath = path.join(tmpDir, 'config.yaml'); + const yamlContent = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: + test: + type: composite + default_tier: sonnet + tiers: + opus: + provider: agy + model: claude-opus-4-6-thinking + fallback: + provider: gemini + model: gemini-3-pro-preview + sonnet: + provider: agy + model: claude-sonnet-4-5-thinking + haiku: + provider: agy + model: claude-haiku-4-5-20251001 + settings: cliproxy/composite-test.settings.json + port: 8318 +`; + fs.writeFileSync(configPath, yamlContent, 'utf-8'); + + const variants = listVariantsFromConfig(); + expect(variants.test).toBeDefined(); + expect(variants.test.hasFallback).toBe(true); + }); + + it('should list composite variants with thinking field', () => { + // Create unified config with composite variant that has per-tier thinking + const configPath = path.join(tmpDir, 'config.yaml'); + const yamlContent = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: + test: + type: composite + default_tier: sonnet + tiers: + opus: + provider: agy + model: claude-opus-4-6-thinking + thinking: xhigh + sonnet: + provider: agy + model: claude-sonnet-4-5-thinking + thinking: medium + haiku: + provider: agy + model: claude-haiku-4-5-20251001 + thinking: off + settings: cliproxy/composite-test.settings.json + port: 8318 +`; + fs.writeFileSync(configPath, yamlContent, 'utf-8'); + + const variants = listVariantsFromConfig(); + expect(variants.test).toBeDefined(); + expect(variants.test.tiers?.opus.thinking).toBe('xhigh'); + expect(variants.test.tiers?.sonnet.thinking).toBe('medium'); + expect(variants.test.tiers?.haiku.thinking).toBe('off'); + }); + + it('should have hasFallback=false when no fallbacks configured', () => { + // Create unified config with composite variant WITHOUT fallback + const configPath = path.join(tmpDir, 'config.yaml'); + const yamlContent = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: + test: + type: composite + default_tier: sonnet + tiers: + opus: + provider: agy + model: claude-opus-4-6-thinking + sonnet: + provider: agy + model: claude-sonnet-4-5-thinking + haiku: + provider: agy + model: claude-haiku-4-5-20251001 + settings: cliproxy/composite-test.settings.json + port: 8318 +`; + fs.writeFileSync(configPath, yamlContent, 'utf-8'); + + const variants = listVariantsFromConfig(); + expect(variants.test).toBeDefined(); + expect(variants.test.hasFallback).toBe(false); + }); + + it('should skip malformed composite variant and keep valid variants', () => { + const configPath = path.join(tmpDir, 'config.yaml'); + const yamlContent = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: + bad: + type: composite + default_tier: sonnet + tiers: + opus: + provider: agy + model: claude-opus-4-6-thinking + sonnet: + provider: agy + model: claude-sonnet-4-5-thinking + settings: cliproxy/bad.settings.json + port: 8318 + good: + provider: gemini + settings: cliproxy/good.settings.json + port: 8319 +`; + fs.writeFileSync(configPath, yamlContent, 'utf-8'); + + const variants = listVariantsFromConfig(); + expect(variants.bad).toBeUndefined(); + expect(variants.good).toBeDefined(); + expect(variants.good.provider).toBe('gemini'); + }); +}); diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts new file mode 100644 index 00000000..c92c702f --- /dev/null +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'bun:test'; +import { + CLIPROXY_PROVIDER_IDS, + getOAuthCallbackPort, + getOAuthFlowType, + getProviderDisplayName, + getProvidersByOAuthFlow, + isCLIProxyProvider, + mapExternalProviderName, +} from '../../../src/cliproxy/provider-capabilities'; +import { + OAUTH_CALLBACK_PORTS as DIAGNOSTIC_CALLBACK_PORTS, + OAUTH_FLOW_TYPES, +} from '../../../src/management/oauth-port-diagnostics'; +import { + DEFAULT_KIRO_AUTH_METHOD, + getKiroCallbackPort, + getKiroCLIAuthFlag, + normalizeKiroAuthMethod, + OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS, + toKiroManagementMethod, +} from '../../../src/cliproxy/auth/auth-types'; + +describe('provider-capabilities', () => { + it('keeps canonical provider IDs backward-compatible', () => { + expect(CLIPROXY_PROVIDER_IDS).toEqual([ + 'gemini', + 'codex', + 'agy', + 'qwen', + 'iflow', + 'kiro', + 'ghcp', + 'claude', + ]); + }); + + it('validates provider IDs', () => { + expect(isCLIProxyProvider('gemini')).toBe(true); + expect(isCLIProxyProvider('ghcp')).toBe(true); + expect(isCLIProxyProvider('not-a-provider')).toBe(false); + expect(isCLIProxyProvider('Gemini')).toBe(false); + }); + + it('returns providers by OAuth flow capability', () => { + expect(getProvidersByOAuthFlow('device_code')).toEqual(['qwen', 'kiro', 'ghcp']); + expect(getProvidersByOAuthFlow('authorization_code')).toEqual([ + 'gemini', + 'codex', + 'agy', + 'iflow', + 'claude', + ]); + }); + + it('maps external provider aliases to canonical IDs', () => { + expect(mapExternalProviderName('gemini-cli')).toBe('gemini'); + expect(mapExternalProviderName('antigravity')).toBe('agy'); + expect(mapExternalProviderName('codewhisperer')).toBe('kiro'); + expect(mapExternalProviderName('github-copilot')).toBe('ghcp'); + expect(mapExternalProviderName('copilot')).toBe('ghcp'); + expect(mapExternalProviderName('anthropic')).toBe('claude'); + expect(mapExternalProviderName('unknown-provider')).toBeNull(); + }); + + it('exposes callback port and display name capabilities', () => { + expect(getOAuthCallbackPort('qwen')).toBeNull(); + expect(getOAuthCallbackPort('kiro')).toBeNull(); + expect(getOAuthCallbackPort('gemini')).toBe(8085); + expect(getProviderDisplayName('agy')).toBe('AntiGravity'); + }); + + it('keeps diagnostics flow metadata in sync with provider capabilities', () => { + for (const provider of CLIPROXY_PROVIDER_IDS) { + expect(OAUTH_FLOW_TYPES[provider]).toBe(getOAuthFlowType(provider)); + expect(DIAGNOSTIC_CALLBACK_PORTS[provider]).toBe(getOAuthCallbackPort(provider)); + } + }); + + it('does not define callback ports for device code providers in auth constants', () => { + for (const provider of getProvidersByOAuthFlow('device_code')) { + expect(AUTH_CALLBACK_PORTS[provider]).toBeUndefined(); + } + }); + + it('maps Kiro auth methods to upstream CLI/management contracts', () => { + expect(DEFAULT_KIRO_AUTH_METHOD).toBe('aws'); + expect(normalizeKiroAuthMethod()).toBe('aws'); + expect(normalizeKiroAuthMethod('GOOGLE')).toBe('google'); + expect(normalizeKiroAuthMethod('not-valid')).toBe('aws'); + + expect(getKiroCLIAuthFlag('aws')).toBe('--kiro-aws-login'); + expect(getKiroCLIAuthFlag('aws-authcode')).toBe('--kiro-aws-authcode'); + expect(getKiroCLIAuthFlag('google')).toBe('--kiro-google-login'); + + expect(getKiroCallbackPort('aws')).toBeNull(); + expect(getKiroCallbackPort('google')).toBe(9876); + expect(getKiroCallbackPort('github')).toBe(9876); + expect(getKiroCallbackPort('aws-authcode')).toBe(9876); + + expect(toKiroManagementMethod('aws')).toBe('aws'); + expect(toKiroManagementMethod('aws-authcode')).toBe('aws'); + expect(toKiroManagementMethod('google')).toBe('google'); + expect(toKiroManagementMethod('github')).toBe('github'); + }); +}); diff --git a/tests/unit/cliproxy/thinking-arg-parser.test.ts b/tests/unit/cliproxy/thinking-arg-parser.test.ts new file mode 100644 index 00000000..13310906 --- /dev/null +++ b/tests/unit/cliproxy/thinking-arg-parser.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'bun:test'; +import { parseThinkingOverride } from '../../../src/cliproxy/executor/thinking-arg-parser'; + +describe('parseThinkingOverride', () => { + it('parses --thinking with separate value', () => { + const out = parseThinkingOverride(['--thinking', 'high']); + expect(out.value).toBe('high'); + expect(out.sourceFlag).toBe('--thinking'); + expect(out.error).toBeUndefined(); + }); + + it('parses --thinking= inline', () => { + const out = parseThinkingOverride(['--thinking=xhigh']); + expect(out.value).toBe('xhigh'); + expect(out.sourceFlag).toBe('--thinking'); + expect(out.error).toBeUndefined(); + }); + + it('parses --effort alias', () => { + const out = parseThinkingOverride(['--effort', 'medium']); + expect(out.value).toBe('medium'); + expect(out.sourceFlag).toBe('--effort'); + expect(out.error).toBeUndefined(); + }); + + it('parses integer values as numbers', () => { + const out = parseThinkingOverride(['--thinking', '8192']); + expect(out.value).toBe(8192); + expect(out.sourceFlag).toBe('--thinking'); + }); + + it('returns first occurrence and tracks duplicates across aliases', () => { + const out = parseThinkingOverride(['--effort=high', '--thinking', 'xhigh']); + expect(out.value).toBe('high'); + expect(out.sourceFlag).toBe('--effort'); + expect(out.duplicateDisplays).toEqual(['--thinking xhigh']); + expect(out.error).toBeUndefined(); + }); + + it('returns error for missing separate value', () => { + const out = parseThinkingOverride(['--effort', '--verbose']); + expect(out.error).toEqual({ flag: '--effort', form: 'separate' }); + }); + + it('returns error for missing inline value', () => { + const out = parseThinkingOverride(['--thinking=']); + expect(out.error).toEqual({ flag: '--thinking', form: 'inline' }); + }); + + it('keeps first valid value when later duplicate is missing separate value', () => { + const out = parseThinkingOverride(['--effort', 'high', '--thinking']); + expect(out.value).toBe('high'); + expect(out.sourceFlag).toBe('--effort'); + expect(out.error).toBeUndefined(); + expect(out.duplicateDisplays).toEqual(['--thinking ']); + }); + + it('keeps first valid value when later duplicate is missing inline value', () => { + const out = parseThinkingOverride(['--thinking=medium', '--effort=']); + expect(out.value).toBe('medium'); + expect(out.sourceFlag).toBe('--thinking'); + expect(out.error).toBeUndefined(); + expect(out.duplicateDisplays).toEqual(['--effort=']); + }); +}); diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts new file mode 100644 index 00000000..38220280 --- /dev/null +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -0,0 +1,141 @@ +/** + * Unit tests for single-variant provider/model update behavior. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { updateVariant } from '../../../src/cliproxy/services/variant-service'; +import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader'; + +describe('updateVariant - provider/model consistency', () => { + let tmpDir: string; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-variant-update-test-')); + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_DIR = tmpDir; + + const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gemini-2.5-pro', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-2.5-pro', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-2.5-pro', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash', + CUSTOM_FLAG: 'keep-me', + }, + hooks: { PreToolUse: [{ matcher: 'WebSearch', hooks: [] }] }, + }, + null, + 2 + ), + 'utf-8' + ); + + fs.writeFileSync( + path.join(tmpDir, 'config.yaml'), + `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: + demo: + provider: gemini + settings: ${settingsPath} + port: 8318 +`, + 'utf-8' + ); + }); + + afterEach(() => { + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects provider change without model update', () => { + const result = updateVariant('demo', { provider: 'codex' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Changing provider requires model update'); + }); + + it('updates provider and regenerates provider-specific core env in same settings file', () => { + const result = updateVariant('demo', { + provider: 'codex', + model: 'gpt-5.1-codex-mini', + }); + + expect(result.success).toBe(true); + expect(result.variant?.provider).toBe('codex'); + expect(result.variant?.model).toBe('gpt-5.1-codex-mini'); + + const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + hooks: { PreToolUse: unknown[] }; + }; + + expect(settings.env.ANTHROPIC_BASE_URL).toContain('/api/provider/codex'); + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini-xhigh'); + expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.1-codex-mini-xhigh'); + expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.1-codex-mini-high'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); + expect(settings.env.CUSTOM_FLAG).toBe('keep-me'); + expect(settings.hooks.PreToolUse.length).toBe(1); + + const config = loadOrCreateUnifiedConfig(); + expect(config.cliproxy?.variants?.demo?.provider).toBe('codex'); + }); + + it('preserves codex sonnet alias on model-only updates', () => { + const toCodex = updateVariant('demo', { + provider: 'codex', + model: 'gpt-5.3-codex', + }); + expect(toCodex.success).toBe(true); + + const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json'); + let settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); + + const modelOnly = updateVariant('demo', { model: 'gpt-5.3-codex' }); + expect(modelOnly.success).toBe(true); + + settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); + }); +}); diff --git a/tests/unit/commands/cliproxy-command.test.js b/tests/unit/commands/cliproxy-command.test.js index 2f7c2a97..e6fc3a0b 100644 --- a/tests/unit/commands/cliproxy-command.test.js +++ b/tests/unit/commands/cliproxy-command.test.js @@ -491,6 +491,18 @@ describe('CLIProxy Command - Proxy Lifecycle', () => { }); describe('Command Routing', () => { + it('routes "start" subcommand correctly', () => { + const args = ['cliproxy', 'start']; + const subcommand = args[1]; + assert.strictEqual(subcommand, 'start'); + }); + + it('routes "restart" subcommand correctly', () => { + const args = ['cliproxy', 'restart']; + const subcommand = args[1]; + assert.strictEqual(subcommand, 'restart'); + }); + it('routes "stop" subcommand correctly', () => { const args = ['cliproxy', 'stop']; const subcommand = args[1]; @@ -504,7 +516,7 @@ describe('CLIProxy Command - Proxy Lifecycle', () => { }); it('handles unknown subcommand', () => { - const validSubcommands = ['stop', 'status', 'create', 'list', 'remove']; + const validSubcommands = ['start', 'restart', 'stop', 'status', 'create', 'list', 'remove']; const unknownCommand = 'invalid'; assert.strictEqual(validSubcommands.includes(unknownCommand), false); }); diff --git a/tests/unit/commands/command-execution-contract.test.ts b/tests/unit/commands/command-execution-contract.test.ts new file mode 100644 index 00000000..e9cd4eb6 --- /dev/null +++ b/tests/unit/commands/command-execution-contract.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'bun:test'; +import { runCommandWithContract } from '../../../src/commands/command-execution-contract'; + +describe('runCommandWithContract', () => { + it('runs parse -> validate -> execute -> render in order', async () => { + const lifecycle: string[] = []; + + const result = await runCommandWithContract(['--flag'], { + parse: (rawArgs) => { + lifecycle.push('parse'); + expect(rawArgs).toEqual(['--flag']); + return { parsed: true, value: rawArgs[0] }; + }, + validate: (parsedArgs) => { + lifecycle.push('validate'); + expect(parsedArgs).toEqual({ parsed: true, value: '--flag' }); + }, + execute: async (parsedArgs) => { + lifecycle.push('execute'); + return { output: parsedArgs.value.toUpperCase() }; + }, + render: (executionResult, context) => { + lifecycle.push('render'); + expect(executionResult).toEqual({ output: '--FLAG' }); + expect(context.rawArgs).toEqual(['--flag']); + expect(context.parsedArgs).toEqual({ parsed: true, value: '--flag' }); + }, + }); + + expect(lifecycle).toEqual(['parse', 'validate', 'execute', 'render']); + expect(result.parsedArgs).toEqual({ parsed: true, value: '--flag' }); + expect(result.result).toEqual({ output: '--FLAG' }); + }); + + it('short-circuits after validate failure', async () => { + const lifecycle: string[] = []; + + const promise = runCommandWithContract([], { + parse: () => { + lifecycle.push('parse'); + return { valid: false }; + }, + validate: () => { + lifecycle.push('validate'); + throw new Error('validation failed'); + }, + execute: () => { + lifecycle.push('execute'); + return { ok: true }; + }, + render: () => { + lifecycle.push('render'); + }, + }); + + await expect(promise).rejects.toThrow('validation failed'); + expect(lifecycle).toEqual(['parse', 'validate']); + }); + + it('awaits async validate before execute and render', async () => { + const lifecycle: string[] = []; + + const result = await runCommandWithContract(['--flag'], { + parse: (rawArgs) => { + lifecycle.push('parse'); + return { parsed: true, value: rawArgs[0] }; + }, + validate: async () => { + lifecycle.push('validate:start'); + await Promise.resolve(); + lifecycle.push('validate:end'); + }, + execute: (parsedArgs) => { + lifecycle.push('execute'); + return { output: parsedArgs.value.toUpperCase() }; + }, + render: () => { + lifecycle.push('render'); + }, + }); + + expect(lifecycle).toEqual(['parse', 'validate:start', 'validate:end', 'execute', 'render']); + expect(result.result).toEqual({ output: '--FLAG' }); + }); + + it('short-circuits after async validate failure', async () => { + const lifecycle: string[] = []; + + const promise = runCommandWithContract([], { + parse: () => { + lifecycle.push('parse'); + return { valid: false }; + }, + validate: async () => { + lifecycle.push('validate:start'); + await Promise.resolve(); + lifecycle.push('validate:reject'); + throw new Error('async validation failed'); + }, + execute: () => { + lifecycle.push('execute'); + return { ok: true }; + }, + render: () => { + lifecycle.push('render'); + }, + }); + + await expect(promise).rejects.toThrow('async validation failed'); + expect(lifecycle).toEqual(['parse', 'validate:start', 'validate:reject']); + }); +}); diff --git a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts new file mode 100644 index 00000000..3488ecfe --- /dev/null +++ b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand'; +import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; + +let tempDir: string; +let originalCcsDir: string | undefined; + +function writeUnifiedConfig(localPort: number): void { + const configPath = path.join(tempDir, 'config.yaml'); + const yaml = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +cliproxy_server: + local: + port: ${localPort} +`; + fs.writeFileSync(configPath, yaml, 'utf8'); +} + +describe('resolveLifecyclePort', () => { + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-')); + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_DIR = tempDir; + }); + + afterEach(() => { + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('uses configured cliproxy_server.local.port', () => { + writeUnifiedConfig(9456); + expect(resolveLifecyclePort()).toBe(9456); + }); + + it('falls back to default port when configured local port is invalid', () => { + writeUnifiedConfig(70000); + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }); + + it('falls back to default port when config file is missing', () => { + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }); +}); diff --git a/tests/unit/commands/shell-completion-command.test.ts b/tests/unit/commands/shell-completion-command.test.ts new file mode 100644 index 00000000..f9c93a53 --- /dev/null +++ b/tests/unit/commands/shell-completion-command.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, mock } from 'bun:test'; + +type ShellTarget = 'bash' | 'zsh' | 'fish' | 'powershell' | null; + +interface InstallResult { + success: boolean; + alreadyInstalled?: boolean; + message?: string; + reload?: string; +} + +interface InstallCall { + shell: ShellTarget; + options: { force: boolean }; +} + +const installCalls: InstallCall[] = []; +let installResult: InstallResult = { + success: true, + message: 'Added to ~/.zshrc', + reload: 'source ~/.zshrc', +}; +let installError: Error | null = null; + +mock.module('../../../src/utils/shell-completion', () => ({ + ShellCompletionInstaller: class { + install(shell: ShellTarget, options: { force: boolean }): InstallResult { + installCalls.push({ shell, options }); + if (installError) { + throw installError; + } + return installResult; + } + }, +})); + +function stripAnsi(value: string): string { + return value.replace(/\u001b\[[0-9;]*m/g, ''); +} + +let handleShellCompletionCommand: (args: string[]) => Promise; +let parseShellCompletionArgs: (args: string[]) => { targetShell: ShellTarget; force: boolean }; +let originalConsoleLog: typeof console.log; +let originalConsoleError: typeof console.error; +let originalProcessExit: typeof process.exit; +let logLines: string[] = []; +let errorLines: string[] = []; + +beforeAll(async () => { + const mod = await import('../../../src/commands/shell-completion-command'); + handleShellCompletionCommand = mod.handleShellCompletionCommand; + parseShellCompletionArgs = mod.parseShellCompletionArgs; +}); + +beforeEach(() => { + installCalls.length = 0; + installError = null; + installResult = { + success: true, + message: 'Added to ~/.zshrc', + reload: 'source ~/.zshrc', + }; + + logLines = []; + errorLines = []; + + originalConsoleLog = console.log; + originalConsoleError = console.error; + originalProcessExit = process.exit; + + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + errorLines.push(args.map(String).join(' ')); + }; + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; +}); + +afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + process.exit = originalProcessExit; +}); + +describe('shell-completion command', () => { + it('parses shell flags and force flag', () => { + const parsed = parseShellCompletionArgs(['--zsh', '--force']); + expect(parsed).toEqual({ targetShell: 'zsh', force: true }); + }); + + it('preserves existing priority when multiple shell flags are present', () => { + const parsed = parseShellCompletionArgs(['--zsh', '--bash']); + expect(parsed).toEqual({ targetShell: 'bash', force: false }); + }); + + it('executes installer with parsed args and renders success output', async () => { + await handleShellCompletionCommand(['--zsh', '--force']); + + expect(installCalls).toHaveLength(1); + expect(installCalls[0]).toEqual({ + shell: 'zsh', + options: { force: true }, + }); + + expect(logLines.some((line) => line.includes('Shell completion installed successfully!'))).toBe( + true + ); + expect(logLines.some((line) => line.includes('source ~/.zshrc'))).toBe(true); + }); + + it('renders already-installed output without forcing reinstall', async () => { + installResult = { + success: true, + alreadyInstalled: true, + message: 'Updated completion files', + reload: 'source ~/.zshrc', + }; + + await handleShellCompletionCommand(['--zsh']); + + const plainLogLines = logLines.map(stripAnsi); + expect(plainLogLines.some((line) => line.includes('Shell completion already installed'))).toBe( + true + ); + expect(plainLogLines.some((line) => line.includes('Use --force to reinstall'))).toBe(true); + expect(plainLogLines.some((line) => line.includes('installed successfully!'))).toBe(false); + }); + + it('prints usage and exits with code 1 on installer error', async () => { + installError = new Error('boom'); + + await expect(handleShellCompletionCommand([])).rejects.toThrow('process.exit(1)'); + const plainErrorLines = errorLines.map(stripAnsi); + expect(plainErrorLines.some((line) => line.includes('Error: boom'))).toBe(true); + expect(plainErrorLines.some((line) => line.includes('ccs --shell-completion --zsh'))).toBe(true); + }); +}); diff --git a/tests/unit/cursor/cursor-auth.test.ts b/tests/unit/cursor/cursor-auth.test.ts new file mode 100644 index 00000000..8743fbe6 --- /dev/null +++ b/tests/unit/cursor/cursor-auth.test.ts @@ -0,0 +1,448 @@ +/** + * Unit tests for Cursor authentication module + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { CursorCredentials } from '../../../src/cursor/types'; +import { + validateToken, + extractUserInfo, + saveCredentials, + loadCredentials, + checkAuthStatus, + deleteCredentials, + autoDetectTokens, +} from '../../../src/cursor/cursor-auth'; + +// Test isolation +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + // Save original CCS_HOME + originalCcsHome = process.env.CCS_HOME; + + // Create temp directory for test isolation + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-test-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(() => { + // Restore original CCS_HOME + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + // Clean up temp directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('validateToken', () => { + it('should accept valid token and machineId', () => { + const token = 'a'.repeat(50); // 50 chars minimum + const machineId = 'a'.repeat(32); // 32 hex chars + expect(validateToken(token, machineId)).toBe(true); + }); + + it('should reject 49-char token (too short)', () => { + const token = 'a'.repeat(49); // Just under minimum + const machineId = 'a'.repeat(32); + expect(validateToken(token, machineId)).toBe(false); + }); + + it('should reject 31-char hex UUID (too short)', () => { + const token = 'a'.repeat(50); + const machineId = 'a'.repeat(31); // Just under 32 + expect(validateToken(token, machineId)).toBe(false); + }); + + it('should accept UUID with hyphens (strips them)', () => { + const token = 'a'.repeat(50); + const machineId = '12345678-1234-1234-1234-123456789abc'; // 36 chars with hyphens + expect(validateToken(token, machineId)).toBe(true); + }); + + it('should reject empty token', () => { + const machineId = 'a'.repeat(32); + expect(validateToken('', machineId)).toBe(false); + }); + + it('should reject empty machineId', () => { + const token = 'a'.repeat(50); + expect(validateToken(token, '')).toBe(false); + }); + + it('should reject non-hex characters in machineId', () => { + const token = 'a'.repeat(50); + const machineId = 'g'.repeat(32); // 'g' is not valid hex + expect(validateToken(token, machineId)).toBe(false); + }); +}); + +describe('extractUserInfo', () => { + it('should extract email and sub from valid JWT', () => { + // JWT: {"email":"user@example.com","sub":"12345","exp":1234567890} + const payload = Buffer.from( + JSON.stringify({ email: 'user@example.com', sub: '12345', exp: 1234567890 }) + ).toString('base64'); + const token = `header.${payload}.signature`; + + const result = extractUserInfo(token); + expect(result).toEqual({ + email: 'user@example.com', + userId: '12345', + exp: 1234567890, + }); + }); + + it('should return undefined email when only sub claim exists', () => { + // JWT: {"sub":"uuid-12345","exp":1234567890} + const payload = Buffer.from(JSON.stringify({ sub: 'uuid-12345', exp: 1234567890 })).toString( + 'base64' + ); + const token = `header.${payload}.signature`; + + const result = extractUserInfo(token); + expect(result).toEqual({ + email: undefined, + userId: 'uuid-12345', + exp: 1234567890, + }); + }); + + it('should return null for non-JWT token', () => { + const token = 'a'.repeat(50); // Plain token + const result = extractUserInfo(token); + expect(result).toBe(null); + }); + + it('should return null for malformed base64', () => { + const token = 'header.!!!invalid-base64!!!.signature'; + const result = extractUserInfo(token); + expect(result).toBe(null); + }); + + it('should handle JWT with user_id instead of sub', () => { + // JWT: {"email":"user@example.com","user_id":"67890"} + const payload = Buffer.from( + JSON.stringify({ email: 'user@example.com', user_id: '67890' }) + ).toString('base64'); + const token = `header.${payload}.signature`; + + const result = extractUserInfo(token); + expect(result).toEqual({ + email: 'user@example.com', + userId: '67890', + exp: undefined, + }); + }); + + it('should return null for JWT with no meaningful claims', () => { + // JWT: {"iat":1234567890} (only issued-at, no email/sub/exp) + const payload = Buffer.from(JSON.stringify({ iat: 1234567890 })).toString('base64'); + const token = `header.${payload}.signature`; + + const result = extractUserInfo(token); + expect(result).toBe(null); + }); +}); + +describe('saveCredentials and loadCredentials', () => { + it('should save and load credentials successfully', () => { + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + const loaded = loadCredentials(); + + expect(loaded).toEqual(credentials); + }); + + it('should return null when no credentials file exists', () => { + const loaded = loadCredentials(); + expect(loaded).toBe(null); + }); + + it('should create directory with restrictive permissions', () => { + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'manual', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + + // CCS_HOME is set to tempDir, but getCcsDir() appends '.ccs' to it + const credDir = path.join(tempDir, '.ccs', 'cursor'); + expect(fs.existsSync(credDir)).toBe(true); + + // Check directory permissions (skip on Windows) + if (process.platform !== 'win32') { + const stats = fs.statSync(credDir); + const mode = stats.mode & 0o777; + expect(mode).toBe(0o700); + } + }); + + it('should return null for invalid JSON in credentials file', () => { + // CCS_HOME is set to tempDir, getCcsDir() returns path.join(tempDir, '.ccs') + const credDir = path.join(tempDir, '.ccs', 'cursor'); + const credPath = path.join(credDir, 'credentials.json'); + fs.mkdirSync(credDir, { recursive: true }); + fs.writeFileSync(credPath, 'invalid json{{{'); + + const loaded = loadCredentials(); + expect(loaded).toBe(null); + }); + + it('should return null for credentials missing required fields', () => { + const credDir = path.join(tempDir, '.ccs', 'cursor'); + const credPath = path.join(credDir, 'credentials.json'); + fs.mkdirSync(credDir, { recursive: true }); + fs.writeFileSync( + credPath, + JSON.stringify({ + accessToken: 'token', + // Missing machineId, authMethod, importedAt + }) + ); + + const loaded = loadCredentials(); + expect(loaded).toBe(null); + }); + + it('should return null for credentials with wrong types', () => { + const credDir = path.join(tempDir, '.ccs', 'cursor'); + const credPath = path.join(credDir, 'credentials.json'); + fs.mkdirSync(credDir, { recursive: true }); + fs.writeFileSync( + credPath, + JSON.stringify({ + accessToken: 123, // Wrong type (number instead of string) + machineId: 'abc', + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }) + ); + + const loaded = loadCredentials(); + expect(loaded).toBe(null); + }); + + it('should return null for invalid authMethod', () => { + const credDir = path.join(tempDir, '.ccs', 'cursor'); + const credPath = path.join(credDir, 'credentials.json'); + fs.mkdirSync(credDir, { recursive: true }); + fs.writeFileSync( + credPath, + JSON.stringify({ + accessToken: 'token', + machineId: 'abc', + authMethod: 'invalid-method', // Invalid authMethod + importedAt: new Date().toISOString(), + }) + ); + + const loaded = loadCredentials(); + expect(loaded).toBe(null); + }); +}); + +describe('checkAuthStatus', () => { + it('should return not authenticated when no credentials exist', () => { + const status = checkAuthStatus(); + expect(status.authenticated).toBe(false); + expect(status.credentials).toBeUndefined(); + }); + + it('should return authenticated for valid credentials', () => { + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + const status = checkAuthStatus(); + + expect(status.authenticated).toBe(true); + expect(status.credentials).toEqual(credentials); + expect(status.expired).toBe(false); + expect(status.tokenAge).toBeDefined(); + expect(status.tokenAge).toBeLessThan(1); // Just imported + }); + + it('should detect expired credentials (importedAt > 24h ago)', () => { + // Create credentials from 25 hours ago + const past = new Date(); + past.setHours(past.getHours() - 25); + + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'manual', + importedAt: past.toISOString(), + }; + + saveCredentials(credentials); + const status = checkAuthStatus(); + + expect(status.authenticated).toBe(true); + expect(status.expired).toBe(true); + expect(status.tokenAge).toBeGreaterThanOrEqual(24); + }); + + it('should return not authenticated for invalid token format', () => { + const credentials: CursorCredentials = { + accessToken: 'short', // Invalid (too short) + machineId: 'b'.repeat(32), + authMethod: 'manual', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + const status = checkAuthStatus(); + + expect(status.authenticated).toBe(false); + }); + + it('should use JWT exp claim when available', () => { + // Create JWT token that expired 1 hour ago + const expiredTime = Math.floor(Date.now() / 1000) - 3600; + const payload = Buffer.from( + JSON.stringify({ email: 'test@example.com', sub: '123', exp: expiredTime }) + ).toString('base64'); + const jwtToken = `header.${payload}.signature`; + + const credentials: CursorCredentials = { + accessToken: jwtToken, + machineId: 'b'.repeat(32), + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), // Recent import + }; + + saveCredentials(credentials); + const status = checkAuthStatus(); + + expect(status.authenticated).toBe(true); + expect(status.expired).toBe(true); // Should detect expiry from JWT exp + }); + + it('should handle invalid importedAt date gracefully', () => { + // Create credentials with valid format but garbage date value + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'manual', + importedAt: 'invalid-date-garbage-2026-99-99T99:99:99Z', + }; + + saveCredentials(credentials); + const status = checkAuthStatus(); + + // Should still authenticate if token format is valid + expect(status.authenticated).toBe(true); + // tokenAge should be undefined due to invalid date (NaN from getTime()) + expect(status.tokenAge).toBeUndefined(); + // expired should be false (defaults to false when date parsing fails) + expect(status.expired).toBe(false); + }); +}); + +describe('deleteCredentials', () => { + it('should delete existing credentials file and return true', () => { + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + expect(loadCredentials()).not.toBe(null); + + const result = deleteCredentials(); + expect(result).toBe(true); + expect(loadCredentials()).toBe(null); + }); + + it('should return false when credentials file does not exist', () => { + const result = deleteCredentials(); + expect(result).toBe(false); + }); + + it('should handle multiple delete calls gracefully', () => { + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'manual', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + + // First delete should succeed + expect(deleteCredentials()).toBe(true); + + // Second delete should return false (already deleted) + expect(deleteCredentials()).toBe(false); + }); +}); + +describe('autoDetectTokens', () => { + it('should return not found for Windows platform', () => { + // Save original platform + const originalPlatform = process.platform; + + // Mock Windows platform + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + + const result = autoDetectTokens(); + + expect(result.found).toBe(false); + expect(result.error).toContain('not supported on Windows'); + + // Restore original platform + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('should return not found when database file does not exist', () => { + // Skip on Windows (already covered by previous test) + if (process.platform === 'win32') { + return; + } + + const result = autoDetectTokens(); + + // Should fail because Cursor database doesn't exist in test environment + expect(result.found).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should have found property in return type', () => { + const result = autoDetectTokens(); + + // Verify return type structure + expect(result).toHaveProperty('found'); + expect(typeof result.found).toBe('boolean'); + }); +}); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts new file mode 100644 index 00000000..b46fa317 --- /dev/null +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -0,0 +1,311 @@ +/** + * Unit tests for Cursor daemon module + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as http from 'http'; +import { spawn } from 'child_process'; +import { + getPidFromFile, + writePidToFile, + removePidFile, + isDaemonRunning, + getDaemonStatus, + stopDaemon, + startDaemon, +} from '../../../src/cursor/cursor-daemon'; +import { getCcsDir } from '../../../src/utils/config-manager'; +import { handleCursorCommand } from '../../../src/commands/cursor-command'; +import { loadCredentials } from '../../../src/cursor/cursor-auth'; + +// Test isolation +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-daemon-test-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + // Cleanup temp directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +// Use getCcsDir() for consistent path resolution with production code +const getTestCursorDir = () => path.join(getCcsDir(), 'cursor'); + +describe('getPidFromFile', () => { + it('returns null when no PID file exists', () => { + expect(getPidFromFile()).toBeNull(); + }); + + it('returns PID when valid PID file exists', () => { + const dir = getTestCursorDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'daemon.pid'), '12345'); + + expect(getPidFromFile()).toBe(12345); + }); + + it('returns null when PID file contains invalid content', () => { + const dir = getTestCursorDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'daemon.pid'), 'not-a-number'); + + expect(getPidFromFile()).toBeNull(); + }); + + it('trims whitespace from PID file content', () => { + const dir = getTestCursorDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'daemon.pid'), ' 42 \n'); + + expect(getPidFromFile()).toBe(42); + }); +}); + +describe('writePidToFile', () => { + it('creates PID file with correct content', () => { + writePidToFile(12345); + + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.existsSync(pidFile)).toBe(true); + expect(fs.readFileSync(pidFile, 'utf8')).toBe('12345'); + }); + + it('creates cursor directory if it does not exist', () => { + const dir = getTestCursorDir(); + expect(fs.existsSync(dir)).toBe(false); + + writePidToFile(999); + + expect(fs.existsSync(dir)).toBe(true); + }); + + it('overwrites existing PID file', () => { + writePidToFile(111); + writePidToFile(222); + + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.readFileSync(pidFile, 'utf8')).toBe('222'); + }); +}); + +describe('removePidFile', () => { + it('removes existing PID file', () => { + writePidToFile(12345); + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.existsSync(pidFile)).toBe(true); + + removePidFile(); + + expect(fs.existsSync(pidFile)).toBe(false); + }); + + it('does not throw when PID file does not exist', () => { + expect(() => removePidFile()).not.toThrow(); + }); +}); + +describe('startDaemon', () => { + it('rejects invalid port (0)', async () => { + const result = await startDaemon({ port: 0 }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid port'); + }); + + it('rejects invalid port (65536)', async () => { + const result = await startDaemon({ port: 65536 }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid port'); + }); + + it('rejects non-integer port', async () => { + const result = await startDaemon({ port: 3.14 }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid port'); + }); + + it( + 'starts and stops daemon successfully', + async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + expect(result.pid).toBeDefined(); + + // Verify health + const running = await isDaemonRunning(port); + expect(running).toBe(true); + + // Verify chat endpoint exists (requires auth, should not be 404) + const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(chatResponse.status).toBe(401); + + // Stop + const stopResult = await stopDaemon(); + expect(stopResult.success).toBe(true); + + // Verify stopped + const stillRunning = await isDaemonRunning(port); + expect(stillRunning).toBe(false); + }, + 35000 + ); +}); + +describe('isDaemonRunning', () => { + it('returns false when no daemon is running on port', async () => { + // Use a port that should not have anything running + const result = await isDaemonRunning(19999); + expect(result).toBe(false); + }); + + it('returns false when /health is 200 but service is not cursor-daemon', async () => { + const server = http.createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, service: 'not-cursor-daemon' })); + return; + } + + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + const result = await isDaemonRunning(address.port); + expect(result).toBe(false); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); +}); + +describe('getDaemonStatus', () => { + it('returns status with running=false when no daemon running', async () => { + const status = await getDaemonStatus(19999); + expect(status.running).toBe(false); + expect(status.port).toBe(19999); + expect(status.pid).toBeUndefined(); + }); + + it('returns status with pid when PID file exists but daemon not running', async () => { + writePidToFile(99999); + const status = await getDaemonStatus(19999); + expect(status.running).toBe(false); + expect(status.port).toBe(19999); + expect(status.pid).toBeUndefined(); + }); +}); + +describe('stopDaemon', () => { + it('returns success when no PID file exists', async () => { + const result = await stopDaemon(); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('returns success when PID refers to non-existent process', async () => { + // Write a PID that doesn't exist + writePidToFile(999999); + const result = await stopDaemon(); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + + // PID file should be removed + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.existsSync(pidFile)).toBe(false); + }); + + it('does not terminate unrelated process from stale PID file', async () => { + const unrelatedProcess = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], { + detached: true, + stdio: 'ignore', + }); + unrelatedProcess.unref(); + + const unrelatedPid = unrelatedProcess.pid; + expect(unrelatedPid).toBeDefined(); + if (!unrelatedPid) { + throw new Error('Failed to spawn unrelated process'); + } + + writePidToFile(unrelatedPid); + + try { + const result = await stopDaemon(); + expect(result.success).toBe(true); + + // Unrelated process should still be alive. + expect(() => process.kill(unrelatedPid, 0)).not.toThrow(); + } finally { + try { + process.kill(unrelatedPid, 'SIGTERM'); + } catch { + // Process already exited. + } + } + }); +}); + +describe('handleCursorCommand', () => { + it('returns exit code 1 for unknown subcommand', async () => { + const exitCode = await handleCursorCommand(['nonexistent']); + expect(exitCode).toBe(1); + }); + + it('supports manual auth import from CLI flags', async () => { + const token = 'a'.repeat(60); + const machineId = '1234567890abcdef1234567890abcdef'; + + const exitCode = await handleCursorCommand([ + 'auth', + '--manual', + '--token', + token, + '--machine-id', + machineId, + ]); + + expect(exitCode).toBe(0); + + const credentials = loadCredentials(); + expect(credentials).not.toBeNull(); + expect(credentials?.authMethod).toBe('manual'); + expect(credentials?.machineId).toBe(machineId); + }); +}); diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts new file mode 100644 index 00000000..e510a590 --- /dev/null +++ b/tests/unit/cursor/cursor-models.test.ts @@ -0,0 +1,99 @@ +/** + * Unit tests for Cursor models module + */ + +import { describe, it, expect } from 'bun:test'; +import { + DEFAULT_CURSOR_MODELS, + DEFAULT_CURSOR_PORT, + DEFAULT_CURSOR_MODEL, + getDefaultModel, + detectProvider, + formatModelName, + fetchModelsFromDaemon, +} from '../../../src/cursor/cursor-models'; + +describe('DEFAULT_CURSOR_MODELS', () => { + it('contains models from multiple providers', () => { + const providers = new Set(DEFAULT_CURSOR_MODELS.map((m) => m.provider)); + expect(providers.has('anthropic')).toBe(true); + expect(providers.has('openai')).toBe(true); + expect(providers.has('google')).toBe(true); + }); + + it('has exactly one default model', () => { + const defaults = DEFAULT_CURSOR_MODELS.filter((m) => m.isDefault); + expect(defaults).toHaveLength(1); + expect(defaults[0].id).toBe(DEFAULT_CURSOR_MODEL); + }); +}); + +describe('DEFAULT_CURSOR_PORT', () => { + it('is 20129', () => { + expect(DEFAULT_CURSOR_PORT).toBe(20129); + }); +}); + +describe('DEFAULT_CURSOR_MODEL', () => { + it('is gpt-4.1', () => { + expect(DEFAULT_CURSOR_MODEL).toBe('gpt-4.1'); + }); +}); + +describe('getDefaultModel', () => { + it('returns the default model constant', () => { + expect(getDefaultModel()).toBe(DEFAULT_CURSOR_MODEL); + }); +}); + +describe('detectProvider', () => { + it('detects anthropic models', () => { + expect(detectProvider('claude-sonnet-4')).toBe('anthropic'); + expect(detectProvider('claude-opus-4')).toBe('anthropic'); + }); + + it('detects openai models', () => { + expect(detectProvider('gpt-4.1')).toBe('openai'); + expect(detectProvider('gpt-5-mini')).toBe('openai'); + expect(detectProvider('o3-mini')).toBe('openai'); + }); + + it('detects o1 and o4 models as openai', () => { + expect(detectProvider('o1')).toBe('openai'); + expect(detectProvider('o1-preview')).toBe('openai'); + expect(detectProvider('o4-mini')).toBe('openai'); + }); + + it('detects google models', () => { + expect(detectProvider('gemini-2.5-pro')).toBe('google'); + }); + + it('detects cursor models', () => { + expect(detectProvider('cursor-small')).toBe('cursor'); + }); + + it('defaults to unknown for unrecognized models', () => { + expect(detectProvider('unknown-model')).toBe('unknown'); + }); +}); + +describe('formatModelName', () => { + it('returns catalog name for known models', () => { + expect(formatModelName('claude-sonnet-4')).toBe('Claude Sonnet 4'); + expect(formatModelName('gpt-4.1')).toBe('GPT-4.1'); + }); + + it('converts kebab-case to title case for unknown models', () => { + expect(formatModelName('my-custom-model')).toBe('My Custom Model'); + }); +}); + +describe('fetchModelsFromDaemon', () => { + it('falls back to DEFAULT_CURSOR_MODELS when daemon is unreachable', async () => { + // Use a port that nothing is listening on + const unreachablePort = 9999; + const models = await fetchModelsFromDaemon(unreachablePort); + + expect(models).toEqual(DEFAULT_CURSOR_MODELS); + }); +}); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts new file mode 100644 index 00000000..0ea16a7c --- /dev/null +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -0,0 +1,843 @@ +/** + * Cursor Protobuf Module Unit Tests + * Tests encoder, decoder, translator, and executor components + */ + +import { describe, it, expect } from 'bun:test'; +import { + encodeVarint, + encodeField, + wrapConnectRPCFrame, + concatArrays, +} from '../../../src/cursor/cursor-protobuf-encoder'; +import { + decodeVarint, + decodeField, + parseConnectRPCFrame, +} from '../../../src/cursor/cursor-protobuf-decoder'; +import { buildCursorRequest } from '../../../src/cursor/cursor-translator'; +import { generateCursorBody } from '../../../src/cursor/cursor-protobuf'; +import { CursorExecutor } from '../../../src/cursor/cursor-executor'; +import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema'; +import { StreamingFrameParser, decompressPayload } from '../../../src/cursor/cursor-stream-parser'; + +describe('Protobuf Encoding/Decoding', () => { + describe('encodeVarint / decodeVarint round-trip', () => { + it('should encode and decode 0', () => { + const encoded = encodeVarint(0); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(0); + expect(offset).toBe(1); + }); + + it('should encode and decode 1', () => { + const encoded = encodeVarint(1); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(1); + expect(offset).toBe(1); + }); + + it('should encode and decode 127', () => { + const encoded = encodeVarint(127); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(127); + expect(offset).toBe(1); + }); + + it('should encode and decode 128', () => { + const encoded = encodeVarint(128); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(128); + expect(offset).toBe(2); + }); + + it('should encode and decode 16383', () => { + const encoded = encodeVarint(16383); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(16383); + expect(offset).toBe(2); + }); + + it('should encode and decode 0xFFFFFFFF', () => { + const encoded = encodeVarint(0xffffffff); + const [decoded, offset] = decodeVarint(encoded, 0); + expect(decoded).toBe(0xffffffff); + expect(offset).toBe(5); + }); + }); + + describe('encodeField / decodeField round-trip', () => { + it('should encode and decode VARINT field', () => { + const fieldNum = 5; + const value = 42; + const encoded = encodeField(fieldNum, WIRE_TYPE.VARINT, value); + + const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0); + expect(decodedFieldNum).toBe(fieldNum); + expect(wireType).toBe(WIRE_TYPE.VARINT); + expect(decodedValue).toBe(value); + expect(offset).toBe(encoded.length); + }); + + it('should encode and decode LEN field with string', () => { + const fieldNum = 10; + const value = 'Hello, World!'; + const encoded = encodeField(fieldNum, WIRE_TYPE.LEN, value); + + const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0); + expect(decodedFieldNum).toBe(fieldNum); + expect(wireType).toBe(WIRE_TYPE.LEN); + expect(new TextDecoder().decode(decodedValue as Uint8Array)).toBe(value); + expect(offset).toBe(encoded.length); + }); + + it('should encode and decode LEN field with binary data', () => { + const fieldNum = 15; + const value = new Uint8Array([1, 2, 3, 4, 5]); + const encoded = encodeField(fieldNum, WIRE_TYPE.LEN, value); + + const [decodedFieldNum, wireType, decodedValue, offset] = decodeField(encoded, 0); + expect(decodedFieldNum).toBe(fieldNum); + expect(wireType).toBe(WIRE_TYPE.LEN); + expect(decodedValue).toEqual(value); + expect(offset).toBe(encoded.length); + }); + }); + + describe('wrapConnectRPCFrame / parseConnectRPCFrame round-trip', () => { + it('should wrap and parse uncompressed frame', () => { + const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const frame = wrapConnectRPCFrame(payload, false); + + const parsed = parseConnectRPCFrame(Buffer.from(frame)); + expect(parsed).not.toBeNull(); + expect(parsed!.flags).toBe(0x00); + expect(parsed!.length).toBe(payload.length); + expect(parsed!.payload).toEqual(payload); + expect(parsed!.consumed).toBe(5 + payload.length); + }); + + it('should wrap and parse compressed frame', () => { + const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const frame = wrapConnectRPCFrame(payload, true); + + const parsed = parseConnectRPCFrame(Buffer.from(frame)); + expect(parsed).not.toBeNull(); + expect(parsed!.flags).toBe(0x01); // GZIP flag + expect(parsed!.payload).toEqual(payload); // Should be decompressed + }); + + it('should handle incomplete frame', () => { + const partial = new Uint8Array([0x00, 0x00, 0x00]); // Only 3 bytes + const parsed = parseConnectRPCFrame(Buffer.from(partial)); + expect(parsed).toBeNull(); + }); + }); + + describe('concatArrays', () => { + it('should concatenate multiple arrays', () => { + const arr1 = new Uint8Array([1, 2, 3]); + const arr2 = new Uint8Array([4, 5]); + const arr3 = new Uint8Array([6, 7, 8, 9]); + + const result = concatArrays(arr1, arr2, arr3); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9])); + }); + + it('should handle empty arrays', () => { + const arr1 = new Uint8Array([1, 2]); + const arr2 = new Uint8Array([]); + const arr3 = new Uint8Array([3, 4]); + + const result = concatArrays(arr1, arr2, arr3); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + }); +}); + +describe('Message Translation', () => { + describe('buildCursorRequest', () => { + it('should convert system message to user with prefix', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [{ role: 'system', content: 'You are a helpful assistant.' }], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toContain('[System Instructions]'); + expect(result.messages[0].content).toContain('You are a helpful assistant.'); + }); + + it('should keep user and assistant messages', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toBe('Hello'); + expect(result.messages[1].role).toBe('assistant'); + expect(result.messages[1].content).toBe('Hi there!'); + }); + + it('should handle assistant messages with tool_calls', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"NYC"}' }, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('assistant'); + expect(result.messages[0].tool_calls).toHaveLength(1); + expect(result.messages[0].tool_calls![0].id).toBe('call_123'); + expect(result.messages[0].tool_calls![0].function.name).toBe('get_weather'); + }); + + it('should accumulate tool results', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"NYC"}' }, + }, + ], + }, + { + role: 'tool', + content: '{"temperature": 72}', + name: 'get_weather', + tool_call_id: 'call_123', + }, + { role: 'user', content: 'What is the weather?' }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + // Tool result should be attached to next message + expect(result.messages[1].tool_results).toBeDefined(); + expect(result.messages[1].tool_results).toHaveLength(1); + expect(result.messages[1].tool_results![0].tool_call_id).toBe('call_123'); + }); + + it('should handle array content format', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' World' }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].content).toBe('Hello World'); + }); + + it('should handle system message with array content format', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'system', + content: [ + { type: 'text', text: 'System instruction part 1' }, + { type: 'text', text: ' part 2' }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toBe('[System Instructions]\nSystem instruction part 1 part 2'); + }); + }); +}); + +describe('Request Encoding', () => { + describe('generateCursorBody', () => { + it('should encode basic text message', () => { + const result = generateCursorBody([{ role: 'user', content: 'Hello' }], 'gpt-4', [], null); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBeGreaterThan(0); + }); + + it('should encode message with tools', () => { + const tools = [ + { + type: 'function' as const, + function: { + name: 'get_weather', + description: 'Get weather data', + parameters: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + required: ['city'], + }, + }, + }, + ]; + + const result = generateCursorBody([{ role: 'user', content: 'What is the weather?' }], 'gpt-4', tools, null); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('Edge cases', () => { + it('should handle malformed frame gracefully', () => { + const executor = new CursorExecutor(); + + // Incomplete frame header (only 3 bytes instead of 5) + const incompleteFrame = Buffer.from([0x00, 0x00, 0x00]); + + const result = executor.transformProtobufToJSON(incompleteFrame, 'gpt-4', { + messages: [], + }); + + // Should return valid response even with malformed input + expect(result.status).toBe(200); + }); + + it('should handle truncated payload', () => { + const executor = new CursorExecutor(); + + // Frame header says payload is 100 bytes but only 5 bytes follow + const truncatedFrame = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x64, 0x01, 0x02, 0x03, 0x04, 0x05]); + + const result = executor.transformProtobufToJSON(truncatedFrame, 'gpt-4', { + messages: [], + }); + + // Should handle gracefully + expect(result.status).toBe(200); + }); + + it('should handle multi-frame buffer', () => { + const executor = new CursorExecutor(); + + // Create two simple frames + const frame1 = wrapConnectRPCFrame( + encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, 'Frame 1'), + false + ); + const frame2 = wrapConnectRPCFrame( + encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, ' Frame 2'), + false + ); + + // Concatenate them + const multiFrame = Buffer.concat([Buffer.from(frame1), Buffer.from(frame2)]); + + const result = executor.transformProtobufToJSON(multiFrame, 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(200); + }); + }); +}); + +describe('CursorExecutor', () => { + const executor = new CursorExecutor(); + + describe('generateChecksum', () => { + it('should generate valid checksum format', () => { + const machineId = 'test-machine-id'; + const checksum = executor.generateChecksum(machineId); + + // Should end with machine ID + expect(checksum.endsWith(machineId)).toBe(true); + + // Should have base64url-like prefix (8 chars from 6 bytes) + const prefix = checksum.slice(0, -machineId.length); + expect(prefix.length).toBe(8); + expect(/^[A-Za-z0-9_-]+$/.test(prefix)).toBe(true); + }); + + it('should generate valid checksums at different call times', async () => { + const machineId = 'test-machine-id'; + const checksum1 = executor.generateChecksum(machineId); + + // Wait to ensure timestamp may change (though timestamp granularity is ~16 min) + await new Promise((resolve) => setTimeout(resolve, 10)); + + const checksum2 = executor.generateChecksum(machineId); + + // Verify both checksums are valid (may be same due to timestamp granularity) + expect(checksum1.endsWith(machineId)).toBe(true); + expect(checksum2.endsWith(machineId)).toBe(true); + }); + }); + + describe('buildHeaders', () => { + it('should generate all required headers', () => { + const credentials = { + accessToken: 'test-token', + machineId: 'test-machine-id', + }; + + const headers = executor.buildHeaders(credentials); + + expect(headers).toHaveProperty('authorization'); + expect(headers.authorization).toContain('Bearer'); + expect(headers).toHaveProperty('connect-accept-encoding', 'gzip'); + expect(headers).toHaveProperty('connect-protocol-version', '1'); + expect(headers).toHaveProperty('content-type', 'application/connect+proto'); + expect(headers).toHaveProperty('user-agent', 'connect-es/1.6.1'); + expect(headers).toHaveProperty('x-cursor-checksum'); + expect(headers).toHaveProperty('x-cursor-client-version', '2.3.41'); + expect(headers).toHaveProperty('x-cursor-client-type', 'ide'); + expect(headers).toHaveProperty('x-ghost-mode', 'true'); + }); + + it('should handle token with :: delimiter', () => { + const credentials = { + accessToken: 'prefix::actual-token', + machineId: 'test-machine-id', + }; + + const headers = executor.buildHeaders(credentials); + + expect(headers.authorization).toBe('Bearer actual-token'); + }); + + it('should respect ghostMode flag', () => { + const credentialsGhost = { + accessToken: 'test-token', + machineId: 'test-machine-id', + ghostMode: true, + }; + + const credentialsNoGhost = { + accessToken: 'test-token', + machineId: 'test-machine-id', + ghostMode: false, + }; + + const headersGhost = executor.buildHeaders(credentialsGhost); + const headersNoGhost = executor.buildHeaders(credentialsNoGhost); + + expect(headersGhost['x-ghost-mode']).toBe('true'); + expect(headersNoGhost['x-ghost-mode']).toBe('false'); + }); + + it('should throw error if machineId missing', () => { + const credentials = { + accessToken: 'test-token', + machineId: '', + }; + + expect(() => executor.buildHeaders(credentials)).toThrow('Machine ID is required'); + }); + }); + + describe('buildUrl', () => { + it('should return correct API endpoint', () => { + const url = executor.buildUrl(); + expect(url).toBe('https://api2.cursor.sh/aiserver.v1.AiService/StreamChat'); + }); + }); + + describe('transformProtobufToJSON', () => { + it('should handle basic text response', async () => { + // Create minimal protobuf response with text + const textContent = 'Hello, world!'; + const responseField = encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent); + const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField); + const frame = wrapConnectRPCFrame(responseMsg, false); + + const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(200); + const bodyText = await result.text(); + const body = JSON.parse(bodyText); + expect(body.choices[0].message.content).toBe(textContent); + expect(body.choices[0].finish_reason).toBe('stop'); + }); + + it('should handle JSON error response', async () => { + const errorJson = JSON.stringify({ + error: { + code: 'resource_exhausted', + message: 'Rate limit exceeded', + }, + }); + const frame = wrapConnectRPCFrame(new TextEncoder().encode(errorJson), false); + + const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(429); + const bodyText = await result.text(); + const body = JSON.parse(bodyText); + expect(body.error.type).toBe('rate_limit_error'); + }); + }); + + describe('transformProtobufToSSE', () => { + it('should output SSE format', async () => { + // Create minimal protobuf response with text + const textContent = 'Hello'; + const responseField = encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent); + const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField); + const frame = wrapConnectRPCFrame(responseMsg, false); + + const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(200); + expect(result.headers.get('content-type')).toBe('text/event-stream'); + + const bodyText = await result.text(); + expect(bodyText).toContain('data: '); + expect(bodyText).toContain('data: [DONE]'); + expect(bodyText).toContain(textContent); + }); + + it('should handle JSON error response', async () => { + const errorJson = JSON.stringify({ + error: { + code: 'resource_exhausted', + message: 'Rate limit exceeded', + }, + }); + const frame = wrapConnectRPCFrame(new TextEncoder().encode(errorJson), false); + + const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(429); + const bodyText = await result.text(); + const body = JSON.parse(bodyText); + expect(body.error.type).toBe('rate_limit_error'); + }); + }); + + describe('decompressPayload error handling', () => { + it('should return empty buffer on decompression failure', () => { + // Create invalid gzip data + const invalidGzip = Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0xff, 0xff]); + const frame = new Uint8Array(5 + invalidGzip.length); + frame[0] = 0x01; // GZIP flag + frame[1] = 0; + frame[2] = 0; + frame[3] = 0; + frame[4] = invalidGzip.length; + frame.set(invalidGzip, 5); + + const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', { + messages: [], + }); + + // Should handle gracefully and return valid response + expect(result.status).toBe(200); + }); + }); + + describe('error handling', () => { + it('should return empty buffer on decompression failure', () => { + const executor = new CursorExecutor(); + + // Invalid compressed payload (not actually gzipped) + const invalidGzipPayload = new Uint8Array([1, 2, 3, 4, 5]); + const flags = 0x01; // GZIP flag + + // Wrap with ConnectRPC frame header (flags + length) + const length = invalidGzipPayload.length; + const frame = new Uint8Array(5 + length); + frame[0] = flags; + frame[1] = (length >> 24) & 0xff; + frame[2] = (length >> 16) & 0xff; + frame[3] = (length >> 8) & 0xff; + frame[4] = length & 0xff; + frame.set(invalidGzipPayload, 5); + + const buffer = Buffer.from(frame); + + // Should not crash - decompression failure returns empty buffer + const result = executor.transformProtobufToJSON(buffer, 'test-model', { + messages: [], + stream: false, + }); + + expect(result.status).toBe(200); + }); + + it('should log unknown message roles in debug mode', () => { + const originalDebug = process.env.CCS_DEBUG; + process.env.CCS_DEBUG = '1'; + + const consoleSpy: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + const msg = args.map((a) => String(a)).join(' '); + consoleSpy.push(msg); + }; + + try { + const messages = [ + { + role: 'unknown_role' as 'user', // Type assertion to bypass TS + content: 'test', + }, + ]; + + // buildCursorRequest expects (model, body, stream, credentials) + buildCursorRequest('test-model', { messages }, false, { machineId: '12345', accessToken: 'test' }); + + // Should have logged warning + const hasWarning = consoleSpy.some((log) => log.includes('Unknown message role')); + expect(hasWarning).toBe(true); + } finally { + console.error = originalError; + process.env.CCS_DEBUG = originalDebug; + } + }); + }); +}); + +/** + * Helper: build a ConnectRPC frame from raw payload bytes + */ +function buildFrame(payload: Uint8Array, flags = 0): Buffer { + const header = Buffer.alloc(5); + header[0] = flags; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, Buffer.from(payload)]); +} + +/** + * Helper: build a protobuf text response frame + */ +function buildTextFrame(text: string): Buffer { + const responseField = encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, text); + const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField); + return buildFrame(responseMsg); +} + +describe('StreamingFrameParser', () => { + it('should parse a complete single frame', () => { + const parser = new StreamingFrameParser(); + const frame = buildTextFrame('Hello'); + const results = parser.push(frame); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('text'); + if (results[0].type === 'text') { + expect(results[0].text).toBe('Hello'); + } + expect(parser.hasPartial()).toBe(false); + }); + + it('should buffer partial frame header (< 5 bytes)', () => { + const parser = new StreamingFrameParser(); + const frame = buildTextFrame('Hi'); + + // Send only first 3 bytes (partial header) + const results1 = parser.push(frame.slice(0, 3)); + expect(results1.length).toBe(0); + expect(parser.hasPartial()).toBe(true); + + // Send rest of the frame + const results2 = parser.push(frame.slice(3)); + expect(results2.length).toBe(1); + expect(results2[0].type).toBe('text'); + expect(parser.hasPartial()).toBe(false); + }); + + it('should buffer partial frame payload', () => { + const parser = new StreamingFrameParser(); + const frame = buildTextFrame('Hello world'); + + // Send header + partial payload + const splitPoint = 8; // past the 5-byte header but not full payload + const results1 = parser.push(frame.slice(0, splitPoint)); + expect(results1.length).toBe(0); + expect(parser.hasPartial()).toBe(true); + + // Complete the frame + const results2 = parser.push(frame.slice(splitPoint)); + expect(results2.length).toBe(1); + expect(results2[0].type).toBe('text'); + if (results2[0].type === 'text') { + expect(results2[0].text).toBe('Hello world'); + } + }); + + it('should parse multiple frames from single chunk', () => { + const parser = new StreamingFrameParser(); + const frame1 = buildTextFrame('First'); + const frame2 = buildTextFrame('Second'); + const combined = Buffer.concat([frame1, frame2]); + + const results = parser.push(combined); + expect(results.length).toBe(2); + expect(results[0].type).toBe('text'); + expect(results[1].type).toBe('text'); + if (results[0].type === 'text' && results[1].type === 'text') { + expect(results[0].text).toBe('First'); + expect(results[1].text).toBe('Second'); + } + }); + + it('should handle frame split across two chunks', () => { + const parser = new StreamingFrameParser(); + const frame1 = buildTextFrame('AAA'); + const frame2 = buildTextFrame('BBB'); + const combined = Buffer.concat([frame1, frame2]); + + // Split in the middle of frame2 + const splitPoint = frame1.length + 3; + const results1 = parser.push(combined.slice(0, splitPoint)); + expect(results1.length).toBe(1); // frame1 complete + expect(results1[0].type).toBe('text'); + + const results2 = parser.push(combined.slice(splitPoint)); + expect(results2.length).toBe(1); // frame2 complete + expect(results2[0].type).toBe('text'); + if (results2[0].type === 'text') { + expect(results2[0].text).toBe('BBB'); + } + }); + + it('should detect JSON error mid-stream', () => { + const parser = new StreamingFrameParser(); + + // First: a valid text frame + const textFrame = buildTextFrame('Before error'); + const results1 = parser.push(textFrame); + expect(results1.length).toBe(1); + + // Then: a JSON error frame + const errorJson = JSON.stringify({ + error: { code: 'resource_exhausted', message: 'Rate limit' }, + }); + const errorFrame = buildFrame(new TextEncoder().encode(errorJson)); + const results2 = parser.push(errorFrame); + + expect(results2.length).toBe(1); + expect(results2[0].type).toBe('error'); + if (results2[0].type === 'error') { + expect(results2[0].status).toBe(429); + expect(results2[0].errorType).toBe('rate_limit_error'); + } + }); + + it('should report hasPartial() correctly', () => { + const parser = new StreamingFrameParser(); + expect(parser.hasPartial()).toBe(false); + + // Push partial data + parser.push(Buffer.from([0x00, 0x00])); + expect(parser.hasPartial()).toBe(true); + + // Push enough to complete the frame (empty payload) + // header: flags=0, length=0 → 5 bytes total + const emptyFrame = Buffer.alloc(5); + emptyFrame[0] = 0; + emptyFrame.writeUInt32BE(0, 1); + + const parser2 = new StreamingFrameParser(); + parser2.push(emptyFrame); + expect(parser2.hasPartial()).toBe(false); + }); +}); + +describe('decompressPayload', () => { + it('should pass through uncompressed payload', () => { + const payload = Buffer.from('raw protobuf data'); + const result = decompressPayload(payload, 0x00); + expect(result).toEqual(payload); + }); + + it('should skip decompression for JSON error payloads', () => { + const errorPayload = Buffer.from('{"error":"something went wrong"}'); + const result = decompressPayload(errorPayload, 0x01); // GZIP flag but JSON error + expect(result).toEqual(errorPayload); + }); + + it('should return empty buffer on invalid gzip data', () => { + const invalidGzip = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05]); + const result = decompressPayload(invalidGzip, 0x01); + expect(result.length).toBe(0); + }); + + it('should decompress valid gzip payload', () => { + const zlib = require('zlib'); + const original = Buffer.from('Hello compressed world'); + const compressed = zlib.gzipSync(original); + + const result = decompressPayload(compressed, 0x01); + expect(result.toString()).toBe('Hello compressed world'); + }); + + it('should handle GZIP_ALT and GZIP_BOTH flags', () => { + const zlib = require('zlib'); + const original = Buffer.from('test data'); + const compressed = zlib.gzipSync(original); + + expect(decompressPayload(compressed, 0x02).toString()).toBe('test data'); + expect(decompressPayload(compressed, 0x03).toString()).toBe('test data'); + }); +}); diff --git a/tests/unit/glmt/glmt-transformer.test.js b/tests/unit/glmt/glmt-transformer.test.js index 901887c1..05950ba7 100644 --- a/tests/unit/glmt/glmt-transformer.test.js +++ b/tests/unit/glmt/glmt-transformer.test.js @@ -1,5 +1,6 @@ const assert = require('assert'); const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; +const { getCcsDir } = require('../../../dist/utils/config-manager'); describe('GlmtTransformer', () => { describe('Request transformation', () => { @@ -202,11 +203,10 @@ describe('GlmtTransformer', () => { delete process.env.CCS_DEBUG; }); - it('uses ~/.ccs/logs by default', () => { + it('uses CCS logs directory by default', () => { const transformer = new GlmtTransformer(); - const os = require('os'); const path = require('path'); - const expectedPath = path.join(os.homedir(), '.ccs', 'logs'); + const expectedPath = path.join(getCcsDir(), 'logs'); assert.strictEqual(transformer.debugLogDir, expectedPath); }); }); diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index 5fa00305..a4b6a9e2 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -2,7 +2,7 @@ * Unit tests for JSONL Parser */ -import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { describe, expect, test, beforeEach, afterEach, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -254,6 +254,19 @@ describe('parseProjectDirectory', () => { const entries = await parseProjectDirectory('/nonexistent/dir'); expect(entries.length).toBe(0); }); + + test('returns empty array when directory read fails', async () => { + const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(true); + const readdirSpy = spyOn(fs.promises, 'readdir').mockRejectedValue(new Error('EACCES')); + + try { + const entries = await parseProjectDirectory('/protected/dir'); + expect(entries).toEqual([]); + } finally { + existsSyncSpy.mockRestore(); + readdirSpy.mockRestore(); + } + }); }); describe('findProjectDirectories', () => { diff --git a/tests/unit/scripts/hardening-inventory.test.ts b/tests/unit/scripts/hardening-inventory.test.ts new file mode 100644 index 00000000..b5f02281 --- /dev/null +++ b/tests/unit/scripts/hardening-inventory.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; + +const { collectSyncCallSites } = require('../../../scripts/hardening-inventory.js'); + +describe('hardening-inventory sync call scanning', () => { + test('ignores sync-call names inside regex literals after else', () => { + const source = [ + 'if (enabled) {', + ' run();', + '} else /fs\\.readFileSync\\(/.test("pattern");', + ].join('\n'); + + const result = collectSyncCallSites(source); + expect(result.count).toBe(0); + }); + + test('ignores sync-call names inside regex literals after do', () => { + const source = 'do /fs\\.writeFileSync\\(/.test("pattern"); while (false);'; + const result = collectSyncCallSites(source); + + expect(result.count).toBe(0); + }); + + test('still counts real sync fs call sites', () => { + const source = [ + 'if (enabled) {', + ' run();', + '} else /fs\\.readFileSync\\(/.test("pattern");', + 'fs.readFileSync("file.txt", "utf8");', + ].join('\n'); + + const result = collectSyncCallSites(source); + expect(result.count).toBe(1); + expect(result.calls).toEqual(['readFileSync']); + }); +}); diff --git a/tests/unit/utils/update-checker-beta-channel.test.js b/tests/unit/utils/update-checker-beta-channel.test.js index 964cfc75..d8a0d4d8 100644 --- a/tests/unit/utils/update-checker-beta-channel.test.js +++ b/tests/unit/utils/update-checker-beta-channel.test.js @@ -25,6 +25,8 @@ describe('Beta Channel Implementation (Phase 3)', function () { let originalHttpsGet; let mockFileSystem = {}; let httpsRequests = []; + let cacheFilePath; + let cacheDirPath; beforeAll(async function () { // Note: Build is handled by CI before tests run (bun run build:all) @@ -36,6 +38,8 @@ describe('Beta Channel Implementation (Phase 3)', function () { // Reset mocks mockFileSystem = {}; httpsRequests = []; + cacheFilePath = undefined; + cacheDirPath = undefined; // Store original functions originalFsExistsSync = fs.existsSync; @@ -46,26 +50,44 @@ describe('Beta Channel Implementation (Phase 3)', function () { // Mock fs.existsSync fs.existsSync = (filePath) => { - return mockFileSystem[filePath] !== undefined; + const key = String(filePath); + if (key.endsWith('/update-check.json') || key.endsWith('\\update-check.json')) { + cacheFilePath = key; + cacheDirPath = path.dirname(key); + } + if (key.endsWith('/cache') || key.endsWith('\\cache')) { + cacheDirPath = key; + } + return mockFileSystem[key] !== undefined; }; // Mock fs.readFileSync fs.readFileSync = (filePath, encoding) => { - if (mockFileSystem[filePath]) { - return mockFileSystem[filePath]; + const key = String(filePath); + if (mockFileSystem[key]) { + return mockFileSystem[key]; } throw new Error(`ENOENT: no such file or directory, open '${filePath}'`); }; // Mock fs.writeFileSync fs.writeFileSync = (filePath, data, encoding) => { - mockFileSystem[filePath] = data; + const key = String(filePath); + if (key.endsWith('/update-check.json') || key.endsWith('\\update-check.json')) { + cacheFilePath = key; + cacheDirPath = path.dirname(key); + } + mockFileSystem[key] = data; }; // Mock fs.mkdirSync fs.mkdirSync = (dirPath, options) => { + const key = String(dirPath); + if (key.endsWith('/cache') || key.endsWith('\\cache')) { + cacheDirPath = key; + } // Just mark as created for testing - mockFileSystem[dirPath] = 'directory'; + mockFileSystem[key] = 'directory'; }; // Mock https.get @@ -110,6 +132,9 @@ describe('Beta Channel Implementation (Phase 3)', function () { } }; }; + + // Force path discovery from module internals to avoid hardcoded ~/.ccs assumptions. + updateCheckerModule.readCache(); }); afterEach(function () { @@ -261,6 +286,10 @@ describe('Beta Channel Implementation (Phase 3)', function () { }); describe('checkForUpdates with targetTag parameter', function () { + function getCacheFilePath() { + return cacheFilePath || path.join(os.homedir(), '.ccs', 'cache', 'update-check.json'); + } + beforeEach(function () { // Set up a fresh cache const cacheData = { @@ -268,7 +297,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { latest_version: null, dismissed_version: null }; - mockFileSystem[path.join(os.homedir(), '.ccs', 'cache', 'update-check.json')] = JSON.stringify(cacheData); + mockFileSystem[getCacheFilePath()] = JSON.stringify(cacheData); }); it('should use latest tag when targetTag is "latest"', async function () { @@ -355,7 +384,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { await updateCheckerModule.checkForUpdates('5.4.0', true, 'npm', 'dev'); // Check cache was updated with dev version - const cachePath = path.join(os.homedir(), '.ccs', 'cache', 'update-check.json'); + const cachePath = getCacheFilePath(); const cacheData = JSON.parse(mockFileSystem[cachePath]); // Dev versions are now stored in dev_version field (not latest_version) @@ -371,7 +400,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { dev_version: '5.5.0', dismissed_version: null }; - mockFileSystem[path.join(os.homedir(), '.ccs', 'cache', 'update-check.json')] = JSON.stringify(cacheData); + mockFileSystem[getCacheFilePath()] = JSON.stringify(cacheData); // Call with force=false to use cache const result = await updateCheckerModule.checkForUpdates('5.4.0', false, 'npm', 'dev'); @@ -458,9 +487,17 @@ describe('Beta Channel Implementation (Phase 3)', function () { }); describe('Cache functionality', function () { + function getCacheFilePath() { + return cacheFilePath || path.join(os.homedir(), '.ccs', 'cache', 'update-check.json'); + } + + function getCacheDirPath() { + return cacheDirPath || path.dirname(getCacheFilePath()); + } + it('should create cache directory if not exists', async function () { // Ensure no cache exists - const cacheDir = path.join(os.homedir(), '.ccs', 'cache'); + const cacheDir = getCacheDirPath(); delete mockFileSystem[cacheDir]; await updateCheckerModule.checkForUpdates('5.4.0', true, 'npm', 'latest'); @@ -477,7 +514,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { dev_version: '5.5.0', dismissed_version: '5.5.0' }; - mockFileSystem[path.join(os.homedir(), '.ccs', 'cache', 'update-check.json')] = JSON.stringify(cacheData); + mockFileSystem[getCacheFilePath()] = JSON.stringify(cacheData); const result = await updateCheckerModule.checkForUpdates('5.4.1', false, 'npm', 'dev'); @@ -488,7 +525,7 @@ describe('Beta Channel Implementation (Phase 3)', function () { it('should handle corrupted cache gracefully', async function () { // Set up corrupted cache - mockFileSystem[path.join(os.homedir(), '.ccs', 'cache', 'update-check.json')] = 'invalid json'; + mockFileSystem[getCacheFilePath()] = 'invalid json'; // Should not throw error const result = await updateCheckerModule.checkForUpdates('5.4.0', true, 'npm', 'latest'); @@ -497,4 +534,4 @@ describe('Beta Channel Implementation (Phase 3)', function () { assert(result.status === 'update_available' || result.status === 'no_update'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts new file mode 100644 index 00000000..1ad73d09 --- /dev/null +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test'; +import { getStartUrlUnsupportedReason } from '../../../src/web-server/routes/cliproxy-auth-routes'; + +describe('cliproxy-auth-routes start-url guard', () => { + it('rejects device code providers', () => { + expect(getStartUrlUnsupportedReason('kiro')).toContain("Kiro method 'aws' uses Device Code flow"); + expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow"); + expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow"); + }); + + it('allows Kiro social methods on start-url', () => { + expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'google' })).toBeNull(); + expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'github' })).toBeNull(); + }); + + it('rejects Kiro aws-authcode method on start-url', () => { + expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'aws-authcode' })).toContain( + "Kiro method 'aws-authcode' uses CLI auth flow" + ); + }); + + it('allows authorization code providers', () => { + expect(getStartUrlUnsupportedReason('gemini')).toBeNull(); + expect(getStartUrlUnsupportedReason('codex')).toBeNull(); + expect(getStartUrlUnsupportedReason('claude')).toBeNull(); + }); +}); diff --git a/tests/unit/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts new file mode 100644 index 00000000..a54bd2c6 --- /dev/null +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -0,0 +1,60 @@ +/** + * Cursor Routes Tests + * Tests for daemon start precondition validation logic. + */ + +import { describe, it, expect } from 'bun:test'; +import { getDaemonStartPreconditionError } from '../../../src/web-server/routes/cursor-routes'; + +describe('Cursor Routes Logic', () => { + describe('POST /daemon/start preconditions', () => { + it('blocks start when integration is disabled', () => { + const result = getDaemonStartPreconditionError({ + enabled: false, + authenticated: true, + tokenExpired: false, + }); + + expect(result).toEqual({ + status: 400, + error: 'Cursor integration is disabled. Enable it before starting daemon.', + }); + }); + + it('blocks start when not authenticated', () => { + const result = getDaemonStartPreconditionError({ + enabled: true, + authenticated: false, + tokenExpired: false, + }); + + expect(result).toEqual({ + status: 401, + error: 'Cursor authentication required. Import credentials before starting daemon.', + }); + }); + + it('blocks start when token is expired', () => { + const result = getDaemonStartPreconditionError({ + enabled: true, + authenticated: true, + tokenExpired: true, + }); + + expect(result).toEqual({ + status: 401, + error: 'Cursor credentials expired. Re-authenticate before starting daemon.', + }); + }); + + it('allows start when all preconditions are met', () => { + const result = getDaemonStartPreconditionError({ + enabled: true, + authenticated: true, + tokenExpired: false, + }); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/web-server/cursor-settings-routes.test.ts b/tests/unit/web-server/cursor-settings-routes.test.ts new file mode 100644 index 00000000..c2e5dfac --- /dev/null +++ b/tests/unit/web-server/cursor-settings-routes.test.ts @@ -0,0 +1,326 @@ +/** + * Cursor Settings Routes Tests + * Tests for Cursor configuration API endpoints. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Setup test environment BEFORE any imports +const TEST_CCS_DIR = path.join(os.tmpdir(), `ccs-test-cursor-settings-${Date.now()}`); +process.env.CCS_HOME = TEST_CCS_DIR; + +// Import after setting env var +import type { CursorConfig } from '../../../src/config/unified-config-types'; +import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { getCcsDir } from '../../../src/utils/config-manager'; + +describe('Cursor Settings Routes Logic', () => { + beforeEach(() => { + // Ensure test directory exists + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); + } + }); + + afterEach(() => { + // Clean up test directory + if (fs.existsSync(TEST_CCS_DIR)) { + fs.rmSync(TEST_CCS_DIR, { recursive: true, force: true }); + } + }); + + describe('PUT /settings validation logic', () => { + it('validates null body', () => { + const updates = null; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(false); + }); + + it('validates non-object body', () => { + const updates = 'string'; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(false); + }); + + it('validates array body', () => { + const updates = [1, 2, 3]; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(false); + }); + + it('validates valid object', () => { + const updates = { port: 4000 }; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(true); + }); + + it('validates integer port', () => { + const port = 4000; + const isInteger = typeof port === 'number' && Number.isInteger(port); + expect(isInteger).toBe(true); + }); + + it('rejects non-integer port', () => { + const port = 3.14; + const isInteger = typeof port === 'number' && Number.isInteger(port); + expect(isInteger).toBe(false); + }); + + it('validates port range (valid)', () => { + const port = 3000; + const inRange = port >= 1 && port <= 65535; + expect(inRange).toBe(true); + }); + + it('validates port range (below)', () => { + const port = 0; + const inRange = port >= 1 && port <= 65535; + expect(inRange).toBe(false); + }); + + it('validates port range (above)', () => { + const port = 65536; + const inRange = port >= 1 && port <= 65535; + expect(inRange).toBe(false); + }); + + it('validates boolean auto_start', () => { + const auto_start = true; + const isBoolean = typeof auto_start === 'boolean'; + expect(isBoolean).toBe(true); + }); + + it('rejects non-boolean auto_start', () => { + const auto_start = 'yes'; + const isBoolean = typeof auto_start === 'boolean'; + expect(isBoolean).toBe(false); + }); + + it('validates boolean ghost_mode', () => { + const ghost_mode = false; + const isBoolean = typeof ghost_mode === 'boolean'; + expect(isBoolean).toBe(true); + }); + + it('rejects non-boolean ghost_mode', () => { + const ghost_mode = 1; + const isBoolean = typeof ghost_mode === 'boolean'; + expect(isBoolean).toBe(false); + }); + }); + + describe('PUT /settings whitelist merge pattern', () => { + it('merges known fields only (ignores unknown)', () => { + const config = loadOrCreateUnifiedConfig(); + const updates = { + port: 5000, + malicious_key: 'should be ignored', + another_unknown: true, + }; + + // Simulate the whitelist merge from the route + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(5000); + expect(cursorConfig).not.toHaveProperty('malicious_key'); + expect(cursorConfig).not.toHaveProperty('another_unknown'); + }); + + it('updates port only', () => { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false }; + saveUnifiedConfig(config); + + const updates = { port: 4000 }; + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(4000); + expect(cursorConfig.auto_start).toBe(false); + expect(cursorConfig.ghost_mode).toBe(false); + }); + + it('updates auto_start only', () => { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false }; + saveUnifiedConfig(config); + + const updates = { auto_start: true }; + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(3000); + expect(cursorConfig.auto_start).toBe(true); + expect(cursorConfig.ghost_mode).toBe(false); + }); + + it('updates ghost_mode only', () => { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false }; + saveUnifiedConfig(config); + + const updates = { ghost_mode: true }; + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(3000); + expect(cursorConfig.auto_start).toBe(false); + expect(cursorConfig.ghost_mode).toBe(true); + }); + }); + + describe('GET /settings/raw logic', () => { + it('returns defaults when file does not exist', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const exists = fs.existsSync(settingsPath); + + expect(exists).toBe(false); + + const config = loadOrCreateUnifiedConfig(); + const cursorPort = config.cursor?.port ?? 3000; + const defaultSettings = { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorPort}`, + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + }, + }; + + expect(defaultSettings.env.ANTHROPIC_BASE_URL).toContain('http://127.0.0.1:'); + expect(defaultSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed'); + }); + + it('reads existing file', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const testSettings = { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:4000', + ANTHROPIC_AUTH_TOKEN: 'test-token', + }, + }; + + fs.writeFileSync(settingsPath, JSON.stringify(testSettings, null, 2)); + const exists = fs.existsSync(settingsPath); + + expect(exists).toBe(true); + + const content = fs.readFileSync(settingsPath, 'utf-8'); + const parsed = JSON.parse(content); + + expect(parsed).toEqual(testSettings); + }); + }); + + describe('PUT /settings/raw validation logic', () => { + it('validates missing settings field', () => { + const body: { expectedMtime: number; settings?: unknown } = { expectedMtime: Date.now() }; + const isValid = !!(body.settings && typeof body.settings === 'object'); + expect(isValid).toBe(false); + }); + + it('validates non-object settings', () => { + const body = { settings: 'not an object' }; + const isValid = !!(body.settings && typeof body.settings === 'object'); + expect(isValid).toBe(false); + }); + + it('validates valid settings', () => { + const body = { settings: { env: { test: 'value' } } }; + const isValid = !!(body.settings && typeof body.settings === 'object'); + expect(isValid).toBe(true); + }); + + it('writes settings file atomically', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const testSettings = { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:5000', + ANTHROPIC_AUTH_TOKEN: 'new-token', + }, + }; + + // Simulate atomic write + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(testSettings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + const exists = fs.existsSync(settingsPath); + expect(exists).toBe(true); + + const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + expect(written).toEqual(testSettings); + }); + + it('detects mtime conflict', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const initialSettings = { env: { test: 'initial' } }; + + fs.writeFileSync(settingsPath, JSON.stringify(initialSettings)); + const stat = fs.statSync(settingsPath); + + const expectedMtime = stat.mtimeMs - 5000; // 5 seconds in the past + const hasConflict = Math.abs(stat.mtimeMs - expectedMtime) > 1000; + + expect(hasConflict).toBe(true); + }); + + it('requires expectedMtime when file already exists', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: { test: 'existing' } })); + + const expectedMtime = undefined; + const shouldRequireMtime = + fs.existsSync(settingsPath) && + (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)); + + expect(shouldRequireMtime).toBe(true); + }); + + it('does not require expectedMtime when file does not exist', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + if (fs.existsSync(settingsPath)) { + fs.rmSync(settingsPath, { force: true }); + } + + const expectedMtime = undefined; + const shouldRequireMtime = + fs.existsSync(settingsPath) && + (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)); + + expect(shouldRequireMtime).toBe(false); + }); + + it('allows write when mtime matches', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const initialSettings = { env: { test: 'initial' } }; + + fs.writeFileSync(settingsPath, JSON.stringify(initialSettings)); + const stat = fs.statSync(settingsPath); + + const expectedMtime = stat.mtimeMs; + const hasConflict = Math.abs(stat.mtimeMs - expectedMtime) > 1000; + + expect(hasConflict).toBe(false); + }); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 846e3ead..66ff5a40 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -26,6 +26,7 @@ const CliproxyControlPanelPage = lazy(() => import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage })) ); const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage }))); +const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m.CursorPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -99,6 +100,14 @@ export default function App() { } /> + }> + + + } + /> (null); + const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); const wasAuthenticatingRef = useRef(false); const authFlow = useCliproxyAuthFlow(); const kiroImportMutation = useKiroImport(); const isKiro = provider === 'kiro'; - const isDeviceCode = isDeviceCodeProvider(provider); + const defaultDeviceCode = isDeviceCodeProvider(provider); + const requiresNickname = isNicknameRequiredProvider(provider); + const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod); + const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode; const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; + const nicknameTrimmed = nickname.trim(); + const errorMessage = localError || authFlow.error; const resetAndClose = () => { setNickname(''); setCallbackUrl(''); setCopied(false); + setLocalError(null); + setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD); wasAuthenticatingRef.current = false; onClose(); }; @@ -103,13 +126,23 @@ export function AddAccountDialog({ }; /** - * Authenticate via /start-url + polling only. - * Does NOT call /start (which spawns a local CLIProxy binary that kills running instances). - * /start-url uses the management API to get auth URL, then polls for completion. + * Start auth flow using provider capabilities. + * - Device code providers use /start and rely on WebSocket events for code display. + * - Authorization code providers use /start-url and polling. */ const handleAuthenticate = () => { + if (requiresNickname && !nicknameTrimmed) { + setLocalError(`Nickname is required for ${displayName} accounts.`); + return; + } + setLocalError(null); wasAuthenticatingRef.current = true; - authFlow.startAuth(provider, { nickname: nickname.trim() || undefined }); + authFlow.startAuth(provider, { + nickname: nicknameTrimmed || undefined, + kiroMethod: isKiro ? kiroAuthMethod : undefined, + flowType: isKiro ? kiroMethodOption.flowType : undefined, + startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined, + }); }; const handleKiroImport = () => { @@ -146,7 +179,7 @@ export function AddAccountDialog({ Add {displayName} Account {isKiro - ? 'Authenticate via browser or import an existing token from Kiro IDE.' + ? 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.' : isDeviceCode ? 'Click Authenticate. A verification code will appear for you to enter on the provider website.' : 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'} @@ -154,23 +187,56 @@ export function AddAccountDialog({
+ {/* Kiro auth method */} + {isKiro && !showAuthUI && ( +
+ + +

{kiroMethodOption.description}

+
+ )} + {/* Nickname input - only show before auth starts */} {!showAuthUI && (
- +
setNickname(e.target.value)} + onChange={(e) => { + setNickname(e.target.value); + setLocalError(null); + }} placeholder="e.g., work, personal" disabled={isPending} className="flex-1" />

- A friendly name to identify this account. Auto-generated from email if left empty. + {requiresNickname + ? 'Required for this provider. Use a unique friendly name (e.g., work, personal).' + : 'A friendly name to identify this account. Auto-generated from email if left empty.'}

)} @@ -191,11 +257,6 @@ export function AddAccountDialog({

- {/* Error display */} - {authFlow.error && !authFlow.authUrl && ( -

{authFlow.error}

- )} - {/* Auth URL section - only for Authorization Code flows, NOT Device Code */} {authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
@@ -270,9 +331,18 @@ export function AddAccountDialog({
)} + + {!authFlow.authUrl && !authFlow.isDeviceCodeFlow && ( +

+ Preparing sign-in URL... +

+ )} )} + {/* Persist error visibility outside auth-only UI states */} + {errorMessage &&

{errorMessage}

} + {/* Kiro import loading */} {kiroImportMutation.isPending && (

@@ -302,7 +372,10 @@ export function AddAccountDialog({ )} {!showAuthUI && ( - diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index ba990e63..6e71c344 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -2,7 +2,13 @@ * Account Card Component for Flow Visualization */ -import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils'; +import { + cn, + getCodexQuotaBreakdown, + getProviderMinQuota, + getProviderResetTime, + isCodexQuotaResult, +} from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, Loader2, Pause, Play, KeyRound } from 'lucide-react'; import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats'; @@ -96,6 +102,14 @@ export function AccountCard({ // Use shared helper for provider-specific minimum quota const minQuota = getProviderMinQuota(account.provider, quota); const resetTime = getProviderResetTime(account.provider, quota); + const codexBreakdown = + account.provider === 'codex' && quota && isCodexQuotaResult(quota) + ? getCodexQuotaBreakdown(quota.windows) + : null; + const codexQuotaRows = [ + { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null }, + { label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, + ].filter((row): row is { label: string; value: number } => row.value !== null); // Tier badge (AGY only) - show P for Pro, U for Ultra const showTierBadge = @@ -229,6 +243,15 @@ export function AccountCard({ {minQuota}% + {account.provider === 'codex' && codexQuotaRows.length > 0 && ( +

+ {codexQuotaRows.map((row) => ( + + {row.label} {row.value}% + + ))} +
+ )}
+ @@ -52,7 +52,12 @@ export function CliproxyStatsCard({ // Proxy not running if (!status?.running) { return ( - +
@@ -76,7 +81,12 @@ export function CliproxyStatsCard({ // Error fetching stats if (error) { return ( - +
@@ -110,7 +120,7 @@ export function CliproxyStatsCard({ const maxModelRequests = models.length > 0 ? models[0][1] : 1; return ( - +
diff --git a/ui/src/components/analytics/date-range-filter.tsx b/ui/src/components/analytics/date-range-filter.tsx index fd0730e0..a644db6d 100644 --- a/ui/src/components/analytics/date-range-filter.tsx +++ b/ui/src/components/analytics/date-range-filter.tsx @@ -70,7 +70,7 @@ export function DateRangeFilter({ }; return ( -
+
{presets.map((preset) => ( -
- + + {mode === 'single' ? ( +
+
+ + + {singleForm.formState.errors.name && ( + + {singleForm.formState.errors.name.message} + + )} +
+ +
+ + + {singleForm.formState.errors.provider && ( + + {singleForm.formState.errors.provider.message} + + )} +
+ + {selectedProvider && providerAccounts.length > 0 && ( +
+ + +
+ )} + +
+ + +
+ +
+ + +
+
+ ) : ( +
+
+ + + {compositeForm.formState.errors.name && ( + + {compositeForm.formState.errors.name.message} + + )} +
+ +
+ + + + Opus + Sonnet + Haiku + + {(['opus', 'sonnet', 'haiku'] as const).map((tier) => ( + +
+ + +
+
+ + + {compositeForm.formState.errors.tiers?.[tier]?.model && ( + + {compositeForm.formState.errors.tiers[tier]?.model?.message} + + )} +
+
+ + +
+
+ ))} +
+
+ +
+ + +
+ +
+ + +
+
+ )} +
); diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx new file mode 100644 index 00000000..3197ebe1 --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -0,0 +1,258 @@ +/** + * CLIProxy Variant Edit Dialog Component + * Phase 05: Dashboard UI full CRUD for composite variants + */ + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { useEffect } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { useUpdateVariant } from '@/hooks/use-cliproxy'; +import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; +import type { Variant } from '@/lib/api-client'; + +const singleProviderSchema = z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().optional(), + account: z.string().optional(), +}); + +const compositeSchema = z.object({ + default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), + tiers: z.object({ + opus: z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().trim().min(1, 'Model is required'), + account: z.string().optional(), + }), + sonnet: z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().trim().min(1, 'Model is required'), + account: z.string().optional(), + }), + haiku: z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().trim().min(1, 'Model is required'), + account: z.string().optional(), + }), + }), +}); + +type SingleProviderFormData = z.infer; +type CompositeFormData = z.infer; + +interface CliproxyEditDialogProps { + variant: Variant | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({ + value: id, + label: getProviderDisplayName(id), +})); + +export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) { + const updateMutation = useUpdateVariant(); + const isComposite = variant?.type === 'composite'; + + const singleForm = useForm({ + resolver: zodResolver(singleProviderSchema), + }); + + const compositeForm = useForm({ + resolver: zodResolver(compositeSchema), + }); + + // Pre-populate form when variant changes + useEffect(() => { + if (!variant) return; + + if (isComposite && variant.tiers && variant.default_tier) { + const mapTier = (t: { provider: string; model: string; account?: string }) => ({ + provider: t.provider as (typeof CLIPROXY_PROVIDERS)[number], + model: t.model, + account: t.account || '', + }); + compositeForm.reset({ + default_tier: variant.default_tier, + tiers: { + opus: mapTier(variant.tiers.opus), + sonnet: mapTier(variant.tiers.sonnet), + haiku: mapTier(variant.tiers.haiku), + }, + }); + } else { + singleForm.reset({ + provider: variant.provider, + model: variant.model ?? undefined, + account: variant.account ?? undefined, + }); + } + }, [variant, isComposite, singleForm, compositeForm]); + + const onSubmitSingle = async (data: SingleProviderFormData) => { + if (!variant) return; + // Filter out undefined values - backend interprets undefined as "no change" + const payload = Object.fromEntries( + Object.entries(data).filter(([, v]) => v !== undefined && v !== '') + ) as SingleProviderFormData; + try { + await updateMutation.mutateAsync({ name: variant.name, data: payload }); + onOpenChange(false); + } catch (error) { + console.error('Failed to update variant:', error); + } + }; + + const onSubmitComposite = async (data: CompositeFormData) => { + if (!variant) return; + try { + await updateMutation.mutateAsync({ + name: variant.name, + data: { + default_tier: data.default_tier, + tiers: data.tiers, + }, + }); + onOpenChange(false); + } catch (error) { + console.error('Failed to update composite variant:', error); + } + }; + + if (!variant) return null; + + return ( + + + + + Edit {isComposite ? 'Composite' : 'Single'} Variant: {variant.name} + + + + {isComposite ? ( +
+
+ + + + Opus + Sonnet + Haiku + + {(['opus', 'sonnet', 'haiku'] as const).map((tier) => ( + +
+ + +
+
+ + + {compositeForm.formState.errors.tiers?.[tier]?.model && ( + + {compositeForm.formState.errors.tiers[tier]?.model?.message} + + )} +
+
+ + +
+
+ ))} +
+
+ +
+ + +
+ +
+ + +
+
+ ) : ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/ui/src/components/cliproxy/cliproxy-table.tsx b/ui/src/components/cliproxy/cliproxy-table.tsx index ab4c5ff2..e3f5996e 100644 --- a/ui/src/components/cliproxy/cliproxy-table.tsx +++ b/ui/src/components/cliproxy/cliproxy-table.tsx @@ -1,9 +1,11 @@ /** * CLIProxy Variants Table Component * Phase 03: REST API Routes & CRUD + * Phase 05: Dashboard UI full CRUD for composite variants * Phase 06: Multi-Account Support */ +import { useState } from 'react'; import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; import { Table, @@ -21,8 +23,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { MoreHorizontal, Trash2, User } from 'lucide-react'; +import { MoreHorizontal, Trash2, User, Pencil } from 'lucide-react'; import { useDeleteVariant } from '@/hooks/use-cliproxy'; +import { CliproxyEditDialog } from './cliproxy-edit-dialog'; import type { Variant } from '@/lib/api-client'; interface CliproxyTableProps { @@ -41,6 +44,7 @@ const providerLabels: Record = { export function CliproxyTable({ data }: CliproxyTableProps) { const deleteMutation = useDeleteVariant(); + const [editingVariant, setEditingVariant] = useState(null); const columns: ColumnDef[] = [ { @@ -51,7 +55,12 @@ export function CliproxyTable({ data }: CliproxyTableProps) { { accessorKey: 'provider', header: 'Provider', - cell: ({ row }) => providerLabels[row.original.provider] || row.original.provider, + cell: ({ row }) => { + if (row.original.type === 'composite') { + return composite; + } + return providerLabels[row.original.provider] || row.original.provider; + }, }, { accessorKey: 'account', @@ -91,6 +100,10 @@ export function CliproxyTable({ data }: CliproxyTableProps) { + setEditingVariant(row.original)}> + + Edit + deleteMutation.mutate(row.original.name)} @@ -124,33 +137,40 @@ export function CliproxyTable({ data }: CliproxyTableProps) { } return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
+ <> + !open && setEditingVariant(null)} + /> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); } diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 00d5ee67..f258c0c1 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -30,7 +30,13 @@ import { Check, KeyRound, } from 'lucide-react'; -import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils'; +import { + cn, + getCodexQuotaBreakdown, + getProviderMinQuota, + getProviderResetTime, + isCodexQuotaResult, +} from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; @@ -115,6 +121,14 @@ export function AccountItem({ // Use shared utility functions for provider-specific quota handling const minQuota = getProviderMinQuota(account.provider, quota); const nextReset = getProviderResetTime(account.provider, quota); + const codexBreakdown = + account.provider === 'codex' && quota && isCodexQuotaResult(quota) + ? getCodexQuotaBreakdown(quota.windows) + : null; + const codexQuotaRows = [ + { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null }, + { label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, + ].filter((row): row is { label: string; value: number } => row.value !== null); return (
-
- - {minQuota}% -
+ {account.provider === 'codex' && codexQuotaRows.length > 0 ? ( +
+ {codexQuotaRows.map((row) => ( +
+ + {row.label} + + + + {row.value}% + +
+ ))} +
+ ) : ( +
+ + {minQuota}% +
+ )}
{quota && } diff --git a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx index a078dd6b..07dbfd46 100644 --- a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -63,6 +63,12 @@ export function CustomPresetDialog({ />
+ {catalog?.provider === 'codex' && ( +

+ Codex tip: suffixes -medium, -high, and -xhigh{' '} + pin effort. Unsuffixed models use Thinking settings. +

+ )} Configure which models to use for each tier

+ {provider === 'codex' && ( +

+ Codex tip: suffixes -medium, -high, and -xhigh{' '} + pin reasoning effort. Unsuffixed models use Thinking settings. +

+ )}
m.id) || []); + const isCodexProvider = catalog?.provider === 'codex'; + const selectedCodexEffort = isCodexProvider ? getCodexEffortDisplay(value) : null; return (
@@ -273,7 +276,19 @@ export function FlexibleModelSelector({ { + updateConfigDraft((draft) => ({ ...draft, port: e.target.value })); + }} + /> +
+ +
+
+ +

+ Start cursor daemon automatically when integration is used. +

+
+ { + updateConfigDraft((draft) => ({ ...draft, auto_start: value })); + }} + /> +
+ +
+
+ +

+ Requests `x-ghost-mode` to reduce telemetry. +

+
+ { + updateConfigDraft((draft) => ({ ...draft, ghost_mode: value })); + }} + /> +
+ +
+ +
+ + + + + + + + Available Models + + Cursor forwards the requested model from each client request. + + + + {modelsLoading ? ( +
+ + Loading models... +
+ ) : ( +
+ {models.map((model) => ( +
+
+

{model.id}

+

+ {model.name} • {model.provider} +

+
+
+ {model.id === currentModel && Default} +
+
+ ))} +
+ )} +
+
+
+ + + + + + + cursor.settings.json + + + {rawSettings?.path ?? '~/.ccs/cursor.settings.json'} + + + + { + setRawConfigDirty(true); + setRawConfigText(value); + }} + language="json" + /> + +
+ + +
+
+
+
+ +
+
+
+ + + + + Manual Cursor Auth Import + + Provide Cursor access token and machine ID if auto-detect is unavailable. + + + +
+
+ + setManualToken(e.target.value)} + placeholder="Paste Cursor access token" + /> +
+
+ + setManualMachineId(e.target.value)} + placeholder="32-char hex (UUID without hyphens)" + /> +
+
+ + + + + +
+
+ + ); +} diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index d8d447d3..03ba32b3 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -13,3 +13,5 @@ export { HealthPage } from './health'; export { SharedPage } from './shared'; export { AnalyticsPage } from './analytics'; + +export { CursorPage } from './cursor'; diff --git a/ui/src/pages/settings/sections/thinking/index.tsx b/ui/src/pages/settings/sections/thinking/index.tsx index 6268e7fd..91368a4b 100644 --- a/ui/src/pages/settings/sections/thinking/index.tsx +++ b/ui/src/pages/settings/sections/thinking/index.tsx @@ -115,7 +115,12 @@ export default function ThinkingSection() { Thinking budget: agy, gemini (token-based)
  • - Reasoning effort: codex (level-based: medium/high/xhigh) + Reasoning effort: codex (suffix or --effort: + medium/high/xhigh) +
  • +
  • + Codex suffixes pin effort (for example -high); unsuffixed models use + Thinking mode.
  • @@ -140,7 +145,8 @@ export default function ThinkingSection() {

    {mode === 'auto' && 'Automatically set thinking based on model tier'} {mode === 'off' && 'Disable extended thinking'} - {mode === 'manual' && 'Use --thinking flag to control manually'} + {mode === 'manual' && + 'Use --thinking (agy/gemini) or --effort (codex) per run'}

    CLI Override

    - Use --thinking flag to - override settings per session: + Override per session with{' '} + --thinking (agy/gemini) or{' '} + --effort (codex):

    ccs gemini --thinking high

    ccs agy --thinking 16384

    -

    ccs gemini --thinking off

    +

    ccs codex --effort xhigh

    +

    ccs codex -p "Refactor this module" --effort xhigh

    diff --git a/ui/tests/unit/ui/lib/codex-effort.test.ts b/ui/tests/unit/ui/lib/codex-effort.test.ts new file mode 100644 index 00000000..965d1963 --- /dev/null +++ b/ui/tests/unit/ui/lib/codex-effort.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { getCodexEffortDisplay, parseCodexEffort } from '@/lib/codex-effort'; + +describe('parseCodexEffort', () => { + it('parses lowercase suffixes', () => { + expect(parseCodexEffort('gpt-5.3-codex-high')).toBe('high'); + expect(parseCodexEffort('gpt-5.3-codex-xhigh')).toBe('xhigh'); + expect(parseCodexEffort('gpt-5-mini-medium')).toBe('medium'); + }); + + it('parses mixed-case suffixes', () => { + expect(parseCodexEffort('gpt-5.3-codex-XHIGH')).toBe('xhigh'); + }); + + it('returns undefined for unsuffixed or unsupported values', () => { + expect(parseCodexEffort('gpt-5.3-codex')).toBeUndefined(); + expect(parseCodexEffort('gpt-5.3-codex-low')).toBeUndefined(); + expect(parseCodexEffort(undefined)).toBeUndefined(); + }); +}); + +describe('getCodexEffortDisplay', () => { + it('returns pinned label for suffixed models', () => { + expect(getCodexEffortDisplay('gpt-5.3-codex-high')).toEqual({ + label: 'Pinned high', + explicit: true, + }); + }); + + it('returns auto label for unsuffixed models', () => { + expect(getCodexEffortDisplay('gpt-5.3-codex')).toEqual({ + label: 'Auto effort', + explicit: false, + }); + }); + + it('returns null for empty input', () => { + expect(getCodexEffortDisplay(undefined)).toBeNull(); + }); +}); diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index a1f1c1e4..8efdf579 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -9,6 +9,7 @@ import { getEarliestResetTime, getMinCodexQuota, getCodexResetTime, + getCodexWindowDisplayLabel, getMinGeminiQuota, getGeminiResetTime, getProviderMinQuota, @@ -386,7 +387,8 @@ describe('getMinCodexQuota', () => { resetAt: '2026-01-30T14:30:32Z', }, ]; - expect(getMinCodexQuota(windows)).toBe(8.7); + // Core account quota should ignore code-review windows. + expect(getMinCodexQuota(windows)).toBe(21.1); }); }); }); @@ -439,10 +441,10 @@ describe('getCodexResetTime', () => { usedPercent: 50, remainingPercent: 50, resetAfterSeconds: 1800, - resetAt: '2026-01-30T16:00:00Z', + resetAt: '2026-01-30T09:00:00Z', }, ]; - // Should return earliest (alphabetically sorted) + // Should ignore code-review reset when choosing main account reset. expect(getCodexResetTime(windows)).toBe('2026-01-30T10:00:00Z'); }); }); @@ -516,14 +518,64 @@ describe('getCodexResetTime', () => { usedPercent: 75, remainingPercent: 25, resetAfterSeconds: 5400, - resetAt: '2026-01-30T18:45:00Z', + resetAt: '2026-01-30T08:15:00Z', }, ]; + // Code-review windows should not drive the main account reset. expect(getCodexResetTime(windows)).toBe('2026-01-30T09:30:00Z'); }); }); }); +describe('getCodexWindowDisplayLabel', () => { + it('labels code review primary as weekly when it matches usage weekly window', () => { + const windows: Array<{ label: string; resetAfterSeconds: number | null }> = [ + { label: 'Primary', resetAfterSeconds: 18000 }, + { label: 'Secondary', resetAfterSeconds: 604800 }, + { label: 'Code Review (Primary)', resetAfterSeconds: 600000 }, + ]; + expect( + getCodexWindowDisplayLabel( + { + label: 'Code Review (Primary)', + resetAfterSeconds: 600000, + }, + windows + ) + ).toBe('Code review (weekly)'); + }); + + it('labels code review primary as 5h when it matches usage 5h window', () => { + const windows: Array<{ label: string; resetAfterSeconds: number | null }> = [ + { label: 'Primary', resetAfterSeconds: 18000 }, + { label: 'Secondary', resetAfterSeconds: 604800 }, + { label: 'Code Review (Primary)', resetAfterSeconds: 17000 }, + ]; + expect( + getCodexWindowDisplayLabel( + { + label: 'Code Review (Primary)', + resetAfterSeconds: 17000, + }, + windows + ) + ).toBe('Code review (5h)'); + }); + + it('keeps secondary usage label as weekly by default', () => { + expect(getCodexWindowDisplayLabel('Secondary')).toBe('Weekly usage limit'); + }); + + it('falls back to generic code review label when cadence cannot be inferred', () => { + expect( + getCodexWindowDisplayLabel({ + label: 'Code Review (Primary)', + resetAfterSeconds: 604800, + }) + ).toBe('Code review'); + }); +}); + // ==================== Gemini Quota Functions ==================== describe('getMinGeminiQuota', () => {