Merge pull request #567 from kaitranntt/dev

feat(release): promote dev to main and close pending-release backlog
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-14 15:43:37 +07:00
commit 400db74f19
136 files changed
+16120 -725

No files matched your search

Symlink
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+5 -4
View File
@@ -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()` |
+49 -2
View File
@@ -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 <token> --machine-id <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
```
<br>
## 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) |
+4 -4
View File
@@ -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"
}
}
+107
View File
@@ -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 <token> --machine-id <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`.
+45
View File
@@ -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` |
@@ -0,0 +1,9 @@
{
"sourceDirectory": "src",
"largeFileThresholdLoc": 350,
"typeScriptFileCount": 341,
"locInSrc": 67708,
"processExitReferenceCount": 164,
"synchronousFsApiReferenceCount": 850,
"largeFileCountOver350Loc": 55
}
+28 -1
View File
@@ -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
+470
View File
@@ -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"
]
}
}
+48
View File
@@ -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`
+19 -5
View File
@@ -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)
--------------------------------------------------------
+5 -2
View File
@@ -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",
+526
View File
@@ -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*|(?<![\\w$.]))(${SYNC_CALL_NAMES.join('|')})\\s*\\(`,
'g'
);
const LEGACY_MARKER_REGEX =
/(?:\blegacy\b|\bshim\b|backward compatibility|backwards compatibility|compatibility layer|deprecated.*re-export|re-export.*compatibility)/i;
const REGEX_LITERAL_KEYWORDS = new Set([
'return',
'throw',
'case',
'else',
'do',
'delete',
'void',
'typeof',
'instanceof',
'in',
'of',
'yield',
'await',
'new',
]);
function toPosixPath(filePath) {
return filePath.split(path.sep).join('/');
}
function relativePath(filePath) {
return toPosixPath(path.relative(ROOT_DIR, filePath));
}
function isSourceFile(filePath) {
return SOURCE_EXTENSIONS.has(path.extname(filePath));
}
function isHotpath(filePath) {
return HOTPATH_PATTERNS.some((pattern) => 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,
};
+309
View File
@@ -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();
+5 -1
View File
@@ -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 || '-',
});
}
+65 -18
View File
@@ -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<string, string>;
/** 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})`);
});
}
+35 -4
View File
@@ -532,6 +532,14 @@ async function main(): Promise<void> {
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<void> {
// 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<void> {
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
+75 -3
View File
@@ -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=<value>
*/
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<Record<CLIProxyProvider, number>> = {
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<CLIProxyProvider, ProviderOAuthConfig> = {
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) */
+30 -5
View File
@@ -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');
}
+118
View File
@@ -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<Record<CompositeTierName, CompositeTierConfig>>;
function isRecord(value: unknown): value is Record<string, unknown> {
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;
}
+100
View File
@@ -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<string, string> {
const globalEnv = getGlobalEnvVars();
// Load user settings if provided (may contain additional env vars like hooks)
let additionalEnvVars: Record<string, string> = {};
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<string, string>;
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<string, string> = {
...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;
}
+170 -33
View File
@@ -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);
}
}
+130 -33
View File
@@ -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<string,
extendedContextOverride,
codexReasoningPort,
toolSanitizationPort,
isComposite,
compositeTiers,
compositeDefaultTier,
} = config;
// Build base env vars - remote or local
// Build base env vars - check remote mode first
let envVars: NodeJS.ProcessEnv;
if (useRemoteProxy && remoteConfig) {
if (httpsTunnel && tunnelPort) {
// HTTPS remote via local tunnel - use HTTP to tunnel
envVars = getRemoteEnvVars(
provider,
{
host: '127.0.0.1',
port: tunnelPort,
protocol: 'http', // Tunnel speaks HTTP locally
authToken: remoteConfig.authToken,
},
customSettingsPath
);
// HTTPS remote via local tunnel
if (isComposite && compositeTiers && compositeDefaultTier) {
envVars = getCompositeEnvVars(
compositeTiers,
compositeDefaultTier,
tunnelPort,
customSettingsPath,
{
host: '127.0.0.1',
port: tunnelPort,
protocol: 'http',
authToken: remoteConfig.authToken,
}
);
} else {
envVars = getRemoteEnvVars(
provider,
{
host: '127.0.0.1',
port: tunnelPort,
protocol: 'http', // Tunnel speaks HTTP locally
authToken: remoteConfig.authToken,
},
customSettingsPath
);
}
} else {
// HTTP remote - direct connection
envVars = getRemoteEnvVars(
provider,
{
host: remoteConfig.host,
port: remoteConfig.port,
protocol: remoteConfig.protocol,
authToken: remoteConfig.authToken,
},
customSettingsPath
);
if (isComposite && compositeTiers && compositeDefaultTier) {
envVars = getCompositeEnvVars(
compositeTiers,
compositeDefaultTier,
remoteConfig.port,
customSettingsPath,
{
host: remoteConfig.host,
port: remoteConfig.port,
protocol: remoteConfig.protocol,
authToken: remoteConfig.authToken,
}
);
} else {
envVars = getRemoteEnvVars(
provider,
{
host: remoteConfig.host,
port: remoteConfig.port,
protocol: remoteConfig.protocol,
authToken: remoteConfig.authToken,
},
customSettingsPath
);
}
}
} else {
// Local proxy mode
const remoteRewriteConfig = remoteConfig
? {
host: remoteConfig.host,
port: remoteConfig.port,
protocol: remoteConfig.protocol,
authToken: remoteConfig.authToken,
}
: undefined;
if (isComposite && compositeTiers && compositeDefaultTier) {
envVars = getCompositeEnvVars(
compositeTiers,
compositeDefaultTier,
localPort,
customSettingsPath
);
} else {
const remoteRewriteConfig = remoteConfig
? {
host: remoteConfig.host,
port: remoteConfig.port,
protocol: remoteConfig.protocol,
authToken: remoteConfig.authToken,
}
: undefined;
envVars = getEffectiveEnvVars(provider, localPort, customSettingsPath, remoteRewriteConfig);
envVars = getEffectiveEnvVars(provider, localPort, customSettingsPath, remoteRewriteConfig);
}
}
// Extract per-tier thinking from composite config
let compositeTierThinking: { opus?: string; sonnet?: string; haiku?: string } | undefined;
if (isComposite && compositeTiers) {
const tierThinking: { opus?: string; sonnet?: string; haiku?: string } = {};
if (compositeTiers.opus?.thinking) tierThinking.opus = compositeTiers.opus.thinking;
if (compositeTiers.sonnet?.thinking) tierThinking.sonnet = compositeTiers.sonnet.thinking;
if (compositeTiers.haiku?.thinking) tierThinking.haiku = compositeTiers.haiku.thinking;
if (Object.keys(tierThinking).length > 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<string, string>,
failedTier: 'opus' | 'sonnet' | 'haiku',
fallback: { provider: string; model: string }
): Record<string, string> {
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;
}
+217 -73
View File
@@ -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;
+32
View File
@@ -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<void
}
}
}
/** Error patterns indicating provider failure */
export const PROVIDER_ERROR_PATTERNS = [
/Error:\s*4[0-9]{2}/i,
/Error:\s*5[0-9]{2}/i,
/overloaded/i,
/quota.*exceeded/i,
/ECONNREFUSED/i,
/rate.?limit/i,
];
/** Detect which composite tier failed from stderr output */
export function detectFailedTier(
stderr: string,
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig }
): 'opus' | 'sonnet' | 'haiku' | null {
for (const tier of ['opus', 'sonnet', 'haiku'] as const) {
// Strip thinking suffix (e.g., "model(high)" → "model") for matching
const model = tiers[tier].model.replace(/\([^)]+\)$/, '');
if (stderr.includes(model)) return tier;
}
return null;
}
/** Check if Claude exit indicates provider error (vs normal user exit) */
export function isProviderError(exitCode: number, stderr: string): boolean {
// Exit code 0 means success, even if stderr has error-like output
// (could be warnings, debug info, etc.)
if (exitCode === 0) return false;
return PROVIDER_ERROR_PATTERNS.some((p) => p.test(stderr));
}
@@ -0,0 +1,98 @@
/**
* Parse CLI thinking/effort override flags.
*
* Supported aliases:
* - --thinking <value>
* - --thinking=<value>
* - --effort <value>
* - --effort=<value>
*/
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} <missing-value>`);
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}=<missing-value>`);
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 };
}
+14 -3
View File
@@ -160,12 +160,23 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
{
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'],
+36 -7
View File
@@ -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<string, unknown> = {};
@@ -163,10 +189,10 @@ export async function configureProviderModel(
const ccsControlledEnv: Record<string, string> = {
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<voi
const currentModel = getCurrentModel(provider);
const settingsPath = getProviderSettingsPath(provider);
const normalizedCurrentModel = currentModel
? stripCodexEffortSuffix(currentModel, provider)
: undefined;
console.error('');
console.error(header(`${catalog.displayName} Model Configuration`));
console.error('');
if (currentModel) {
const entry = catalog.models.find((m) => 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<voi
console.error(dim(' [DEPRECATED] = Not recommended for use'));
console.error('');
catalog.models.forEach((m) => {
const isCurrent = m.id === currentModel;
const isCurrent = m.id === normalizedCurrentModel;
console.error(formatModelDetailed(m, isCurrent));
});
+113
View File
@@ -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<CLIProxyProvider, ProviderCapabilities> = {
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<string, CLIProxyProvider> = (() => {
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;
}
+115 -93
View File
@@ -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,
};
}
/**
+5 -29
View File
@@ -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<string, string> = {
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<string, string> = {
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<Remot
* @param files Array of auth files from remote API
*/
function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] {
const byProvider = new Map<string, RemoteAuthFile[]>();
const byProvider = new Map<CLIProxyProvider, RemoteAuthFile[]>();
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,
+3 -6
View File
@@ -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);
+6
View File
@@ -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
@@ -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<StopProxyResult> {
return stopProxySession();
export async function stopProxy(port?: number): Promise<StopProxyResult> {
return stopProxySession(port);
}
/**
* Start CLIProxy service (or reuse existing running instance)
*/
export async function startProxy(
port: number = CLIPROXY_DEFAULT_PORT,
verbose: boolean = false
): Promise<StartProxyResult> {
return ensureCliproxyService(port, verbose);
}
/**
+115 -10
View File
@@ -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<string, VariantConfig> {
const unifiedConfig = loadOrCreateUnifiedConfig();
const variants = unifiedConfig.cliproxy?.variants || {};
const result: Record<string, VariantConfig> = {};
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<string, VariantConfig> {
}
}
/**
* 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,
};
}
/**
+229 -10
View File
@@ -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<Record<'opus' | 'sonnet' | 'haiku', CompositeTierConfig>>;
}
/**
* 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 };
}
}
+224 -12
View File
@@ -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<string, string>;
[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<string, string>)
: {};
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/<profile>.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<string, string>).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<string, string>)
: {};
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);
}
+14
View File
@@ -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;
}
/**
+2 -1
View File
@@ -43,8 +43,9 @@ export async function handleList(): Promise<void> {
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] })
+4
View File
@@ -26,6 +26,8 @@ export async function showHelp(): Promise<void> {
'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 <name>', 'Remove a CLIProxy variant profile'],
],
@@ -59,6 +61,8 @@ export async function showHelp(): Promise<void> {
[
'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'],
+22 -2
View File
@@ -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<void> {
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<void> {
}
// 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;
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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) {
+142 -4
View File
@@ -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<CodexQuotaResult['windows'][number], 'label' | 'resetAfterSeconds'>;
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<ReturnType<typeof fetchAllProviderQuotas>>
): 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<typeof w> => !!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<typeof w> => !!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('');
+363 -2
View File
@@ -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<CompositeTierConfig | null> {
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<void> {
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<void> {
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<void> {
console.log(ok(`Variant removed: ${name}`));
console.log('');
}
export async function handleEdit(
args: string[],
backend: CLIProxyBackend = DEFAULT_BACKEND
): Promise<void> {
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<Record<'opus' | 'sonnet' | 'haiku', CompositeTierConfig>> = {};
// 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('');
}
@@ -0,0 +1,30 @@
/**
* Command Execution Contract
*
* Standardized lifecycle: parse -> validate -> execute -> render
* for CLI command handlers.
*/
export interface CommandExecutionContract<TParsedArgs, TExecutionResult> {
parse(rawArgs: string[]): TParsedArgs;
validate(parsedArgs: TParsedArgs): void | Promise<void>;
execute(parsedArgs: TParsedArgs): Promise<TExecutionResult> | TExecutionResult;
render(
result: TExecutionResult,
context: { rawArgs: string[]; parsedArgs: TParsedArgs }
): Promise<void> | void;
}
/**
* Run a command through the standard lifecycle.
*/
export async function runCommandWithContract<TParsedArgs, TExecutionResult>(
rawArgs: string[],
contract: CommandExecutionContract<TParsedArgs, TExecutionResult>
): 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 };
}
+115
View File
@@ -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 <subcommand> [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 <t> --machine-id <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');
}
+274
View File
@@ -0,0 +1,274 @@
/**
* Cursor CLI Command
*
* Handles `ccs cursor <subcommand>` 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<number> {
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<number> {
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 <token> --machine-id <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 <token> --machine-id <machine-id>');
console.log('');
return 1;
}
async function handleStatus(): Promise<number> {
const cursorConfig = getCursorConfig();
const authStatus = checkAuthStatus();
const daemonStatus = await getDaemonStatus(cursorConfig.port);
renderCursorStatus(cursorConfig, authStatus, daemonStatus);
return 0;
}
async function handleModels(): Promise<number> {
const cursorConfig = getCursorConfig();
const models = await getAvailableModels(cursorConfig.port);
const defaultModel = getDefaultModel();
renderCursorModels(models, defaultModel);
return 0;
}
/**
* Handle start subcommand.
*/
async function handleStart(): Promise<number> {
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<number> {
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<number> {
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<number> {
const config = loadOrCreateUnifiedConfig();
if (config.cursor) {
config.cursor.enabled = false;
saveUnifiedConfig(config);
}
console.log(ok('Cursor integration disabled'));
return 0;
}
+35 -3
View File
@@ -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 <provider> --thinking <value>',
'Set thinking budget (low/medium/high/xhigh/auto/off or number)',
],
['ccs codex --effort <level>', 'Set codex reasoning effort (medium/high/xhigh)'],
['ccs <provider> --1m', 'Enable 1M token context window'],
['ccs <provider> --no-1m', 'Disable 1M context (use 200K default)'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs <provider> --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 <cmd>', 'Use Cursor IDE integration'],
['ccs cursor auth', 'Import Cursor token'],
['ccs cursor auth --manual --token <t> --machine-id <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 <number>', 'Custom token budget (512-100000)'],
['', ''],
['--effort <level>', '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)
+68 -30
View File
@@ -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<ShellCompletionParsedArgs, ShellCompletionInstallResult> {
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 <TAB> # See available profiles');
console.log(' ccs auth <TAB> # See auth subcommands');
console.log('');
},
};
}
/**
* Handle shell completion command
@@ -16,38 +82,10 @@ export async function handleShellCompletionCommand(args: string[]): Promise<void
console.log(header('Shell Completion Installer'));
console.log('');
// Parse flags
let targetShell: string | null = 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';
try {
const installer = new ShellCompletionInstaller();
const result = installer.install(targetShell as 'bash' | 'zsh' | 'fish' | 'powershell' | null, {
force,
});
if (result.alreadyInstalled && !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 <TAB> # See available profiles');
console.log(' ccs auth <TAB> # 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}`));
+2
View File
@@ -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',
+64 -2
View File
@@ -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>): 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 };
}
+92 -3
View File
@@ -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<string, CLIProxyVariantConfig>;
/** User-defined provider variants (single-provider or composite) */
variants: Record<string, CLIProxyVariantConfig | CompositeVariantConfig>;
/** 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 },
+314
View File
@@ -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<string, unknown>;
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;
}
}
+283
View File
@@ -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<unknown> {
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<string, unknown>;
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<void> {
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<Uint8Array>);
await new Promise<void>((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);
}
+49
View File
@@ -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
}
}
+312
View File
@@ -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<string | null> {
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<boolean> {
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<CursorDaemonStatus> {
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}`,
};
}
}
File diff suppressed because it is too large. Load diff
+189
View File
@@ -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<CursorModel[]> {
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<CursorModel[]> {
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(' ');
}
+336
View File
@@ -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<number, Array<{ wireType: WireType; value: Uint8Array | number }>> {
const fields = new Map<number, Array<{ wireType: WireType; value: Uint8Array | number }>>();
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 };
}
}
+224
View File
@@ -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;
}
+201
View File
@@ -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<string, unknown>;
};
name?: string;
description?: string;
input_schema?: Record<string, unknown>;
}
/** 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;
+186
View File
@@ -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,
};
+136
View File
@@ -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;
}
}
+155
View File
@@ -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,
};
}
+75
View File
@@ -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';
}
+44
View File
@@ -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';
+85
View File
@@ -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;
}
+28 -24
View File
@@ -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<CLIProxyProvider, number | null> = {
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<T>(
valueFor: (provider: CLIProxyProvider) => T
): Record<CLIProxyProvider, T> {
return CLIPROXY_PROVIDER_IDS.reduce(
(acc, provider) => {
acc[provider] = valueFor(provider);
return acc;
},
{} as Record<CLIProxyProvider, T>
);
}
export const OAUTH_CALLBACK_PORTS: Record<CLIProxyProvider, number | null> = 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<CLIProxyProvider, OAuthFlowType> = {
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<CLIProxyProvider, OAuthFlowType> = buildProviderMap(
(provider) => getOAuthFlowType(provider)
);
/**
* Port diagnostic result
+1
View File
@@ -12,6 +12,7 @@ export type {
EnvValue,
ProfileMetadata,
ProfilesRegistry,
CLIProxyVariantConfig,
CLIProxyVariantsConfig,
} from './config';
export { isConfig, isSettings } from './config';
+24 -7
View File
@@ -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,
};
}
}
}
+1 -1
View File
@@ -146,7 +146,7 @@ export async function parseProjectDirectory(projectDir: string): Promise<RawUsag
const projectPath = path.basename(projectDir).replace(/-/g, '/');
try {
const files = fs.readdirSync(projectDir);
const files = await fs.promises.readdir(projectDir);
const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
// Parse files sequentially within a project to avoid too many open handles
+6 -14
View File
@@ -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<void> => {
return;
}
if (!isValidProvider(provider)) {
if (!isCLIProxyProvider(provider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
+96 -7
View File
@@ -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<void> => {
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<voi
return;
}
if (provider === 'kiro' && invalidKiroMethod) {
res.status(400).json({
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github',
code: 'INVALID_KIRO_METHOD',
});
return;
}
// For kiro/ghcp: nickname is required
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
if (!nickname) {
@@ -400,6 +457,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
add: true, // Always add mode from UI
headless: false, // Force interactive mode
nickname: nickname || undefined,
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
fromUI: true, // Enable project selection prompt in UI
noIncognito, // Kiro: use normal browser if enabled
});
@@ -544,6 +602,8 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
*/
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
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';
+35 -4
View File
@@ -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);
}
+204
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
try {
const result = await stopDaemon();
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
@@ -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;
+4
View File
@@ -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);
+123 -6
View File
@@ -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;
}
@@ -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\])?$/);
});
});
@@ -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();
});
});
@@ -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');
});
});
@@ -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'");
});
});
@@ -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<string, string>;
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');
});
});
@@ -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');
});
});
@@ -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=<value> 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 <missing-value>']);
});
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=<missing-value>']);
});
});
@@ -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<string, string>;
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<string, string>;
};
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<string, string>;
};
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');
});
});
+13 -1
View File
@@ -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);
});
@@ -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']);
});
});
@@ -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);
});
});
@@ -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<void>;
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);
});
});
+448
View File
@@ -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');
});
});
+311
View File
@@ -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<void>((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<void>((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);
});
});
+99
View File
@@ -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);
});
});
+843
View File
@@ -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');
});
});
+3 -3
View File
@@ -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);
});
});
+14 -1
View File
@@ -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', () => {
@@ -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']);
});
});
@@ -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');
});
});
});
});
@@ -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();
});
});
@@ -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();
});
});
});
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more