mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
Merge pull request #567 from kaitranntt/dev
feat(release): promote dev to main and close pending-release backlog
This commit is contained in:
136 files changed
+16120
-725
No files matched your search
@@ -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()` |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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 || '-',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(' ');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
EnvValue,
|
||||
ProfileMetadata,
|
||||
ProfilesRegistry,
|
||||
CLIProxyVariantConfig,
|
||||
CLIProxyVariantsConfig,
|
||||
} from './config';
|
||||
export { isConfig, isSettings } from './config';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in new issue
Block a user