Merge pull request #1033 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-16 22:01:30 -04:00
commit 5730122069
213 files changed
+13012 -2079

No files matched your search

+1
View File
@@ -34,6 +34,7 @@ package-lock.json
.claude/active-plan
.claude/agent-memory/
.claude/plans-registry.json*
# Logs directory
logs/
+39 -2
View File
@@ -50,6 +50,40 @@ ccs glm
ccs ollama
```
## OpenAI-Compatible Routing
CCS can now bridge Claude Code into OpenAI-compatible providers through a local
Anthropic-compatible proxy instead of requiring a native Anthropic upstream.
```bash
ccs api create --preset hf
ccs hf
```
Need to manage the proxy manually?
```bash
ccs proxy start hf
eval "$(ccs proxy activate)"
```
The proxy also supports request-time `profile:model` selectors, scenario-based
model routing through `proxy.routing`, and explicit activation helpers such as
`ccs proxy activate --fish`.
Guide: [OpenAI-Compatible Provider Routing](./docs/openai-compatible-providers.md)
### Related Project: claude-code-router
[claude-code-router](https://github.com/musistudio/claude-code-router) is an
excellent standalone tool for routing Claude Code requests to OpenAI-compatible
providers. CCS's local proxy and SSE transformation work was directly informed
by CCR's transformer architecture.
Use CCR when you want a standalone router without CCS profile management.
Use CCS when you want the routing flow integrated with CCS profiles, runtime
bridges, and the existing `ccs` command surface.
Need the full setup path instead of the short version?
| Need | Start here |
@@ -93,8 +127,10 @@ Deep dive:
![WebSearch Fallback](assets/screenshots/websearch.webp)
CCS can provision first-class local tools like WebSearch and image analysis for
third-party launches instead of leaving you to wire them by hand. Deep dive:
[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch).
third-party launches instead of leaving you to wire them by hand. Browser
automation now has a first-class setup path as well. Deep dive:
[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch) |
[Browser Automation](./docs/browser-automation.md).
## Docs Matrix
@@ -110,6 +146,7 @@ reference material.
| Compare OAuth providers, Claude accounts, and API profiles | [Provider Overview](https://docs.ccs.kaitran.ca/providers/concepts/overview) |
| Learn the dashboard structure and feature pages | [Dashboard Overview](https://docs.ccs.kaitran.ca/features/dashboard/overview) |
| Configure profiles, paths, and environment variables | [Configuration](https://docs.ccs.kaitran.ca/getting-started/configuration) |
| Understand browser attach vs Codex browser tooling | [Browser Automation](./docs/browser-automation.md) |
| Keep OpenCode aligned with your live CCS setup | [OpenCode Sync Plugin](https://docs.ccs.kaitran.ca/features/workflow/opencode-sync) |
| Browse every command and flag | [CLI Commands](https://docs.ccs.kaitran.ca/reference/cli-commands) |
| Recover from install, auth, or provider failures | [Troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) |
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codebuddy",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "auto",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2.5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v3-2-volc"
}
}
+4 -4
View File
@@ -2,9 +2,9 @@
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "gpt-5-codex",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5-codex",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-codex-mini"
"ANTHROPIC_MODEL": "gpt-5.4",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.4",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.4",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4-mini"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/cursor",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "composer-2",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-4-sonnet",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-4-sonnet",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "cursor-small"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/gitlab",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "gitlab-duo",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "duo-chat-opus-4-6",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "duo-chat-sonnet-4-6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "duo-chat-haiku-4-5"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kilo",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "kilo/auto",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kilo/auto",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kilo/auto",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kilo/auto"
}
}
+2
View File
@@ -62,6 +62,8 @@ environment:
CCS_DASHBOARD_PASSWORD_HASH: "<bcrypt-hash>"
```
Running `ccs config auth setup` on the outer host shell updates that machine's own `~/.ccs`, not the Docker volume mounted into `ccs-cliproxy`. For the integrated stack, configure auth inside the container or provide the auth env vars in Compose.
Generate a bcrypt hash:
```bash
+174
View File
@@ -0,0 +1,174 @@
# Browser Automation
Last Updated: 2026-04-16
CCS provides browser automation through two separate runtime paths:
- **Claude Browser Attach**: reuses a running Chrome/Chromium session through the CCS-managed local `ccs-browser` MCP runtime
- **Codex Browser Tools**: injects Playwright MCP tooling into Codex-target launches
These are related, but they are not the same implementation and they do not promise a shared browser session.
## How Browser Automation Works
### Claude Browser Attach
Claude-target CCS launches can provision a managed local MCP server named `ccs-browser`.
That path is designed for workflows where you want Claude to interact with a browser session
that already has useful authenticated state.
Claude Browser Attach requires a browser launched in attach mode with remote debugging
enabled. A recent Chrome update alone is not sufficient.
### Codex Browser Tools
Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides
for the `ccs_browser` runtime config entry.
This is configured from the same Browser settings surface, but it is distinct from Claude
Browser Attach.
## Configuration
### Via Dashboard
Open `ccs config` -> `Settings` -> `Browser`.
The Browser screen exposes two sections:
- **Claude Browser Attach**
- enable/disable the Claude attach lane
- choose the Chrome user-data directory
- set the expected DevTools port
- review readiness and next-step guidance
- copy a generated browser launch command
- **Codex Browser Tools**
- enable/disable CCS-managed browser tooling for Codex-target launches
- review whether the detected Codex build supports managed browser overrides
### Via CLI
```bash
ccs help browser
ccs browser status
ccs browser doctor
```
Use `ccs browser status` for the current state and `ccs browser doctor` for actionable
troubleshooting guidance.
### Via Config File
Edit `~/.ccs/config.yaml`:
```yaml
browser:
claude:
enabled: false
user_data_dir: "~/.ccs/browser/chrome-user-data"
devtools_port: 9222
codex:
enabled: true
```
Notes:
- `claude.user_data_dir` is a **Chrome user-data directory**, not a display-name browser profile
- `claude.devtools_port` is the expected remote debugging port for attach mode
- `codex.enabled` controls whether CCS injects browser tooling into Codex-target launches
## Environment Variable Overrides
CCS still supports environment-variable overrides for backward compatibility.
| Variable | Description |
|----------|-------------|
| `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir |
| `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory |
| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override |
If an override is active, Browser status surfaces should report that the current session is being
managed externally by environment variables.
Override precedence is:
1. `CCS_BROWSER_USER_DATA_DIR`
2. `CCS_BROWSER_PROFILE_DIR`
3. the persisted `browser.claude.user_data_dir` config value
Config-backed Browser Attach always passes an explicit DevTools port to the runtime, even when the
effective value is the default `9222`. Metadata-based port discovery is preserved only for the
legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not set.
## Managed Runtime Files
- `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach
- `~/.ccs/mcp/ccs-browser-server.cjs` -> local Claude Browser Attach MCP runtime
- `Codex runtime config overrides` -> CCS manages the `ccs_browser` MCP entry for Codex-target launches
Do not treat the generic Codex MCP editor as the primary browser setup path. CCS-managed browser
entries should be configured from `Settings -> Browser`.
## Launching Chrome For Claude Attach
Claude Browser Attach needs a browser launched with remote debugging.
Typical examples:
```bash
# macOS
open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data"
# Linux
google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data"
# Windows
chrome.exe --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.ccs\\browser\\chrome-user-data"
```
Using a dedicated CCS browser data dir is recommended. It avoids profile-locking issues and keeps
automation state separate from your daily browser profile.
## Troubleshooting
### Browser status says Claude Browser Attach is disabled
Enable Claude Browser Attach in `Settings -> Browser` or via the browser config block in
`~/.ccs/config.yaml`.
### Browser status says the path is missing
The configured Chrome user-data directory does not exist yet.
1. Create the directory or use the generated launch command
2. Start Chrome in attach mode with `--remote-debugging-port`
3. Rerun `ccs browser doctor`
### Browser status says no running browser session was found
CCS could not find usable DevTools attach metadata for the configured user-data directory.
1. Make sure Chrome was started with `--remote-debugging-port=<port>`
2. Make sure it is using the same `user_data_dir` configured in CCS
3. Rerun `ccs browser doctor`
### Browser status says the DevTools endpoint is unreachable
CCS found attach metadata, but the endpoint did not answer successfully.
1. Restart the attach browser session
2. Confirm the expected port matches the real remote debugging port
3. Rerun `ccs browser status`
### Codex Browser Tools are unavailable
Codex browser tooling depends on a Codex build that supports `--config` overrides.
If CCS reports `unsupported_build`, upgrade Codex and rerun `ccs browser status`.
## Security Notes
- Browser automation may operate inside authenticated browser sessions
- Prefer a dedicated automation user-data dir instead of your everyday browser profile
- Do not commit browser paths, secrets, or generated session state to version control
- Treat `~/.ccs/config.yaml`, `~/.claude.json`, and the browser user-data directory as local machine state
+32 -18
View File
@@ -1,17 +1,20 @@
# Cursor IDE Integration
This guide covers the current CCS-owned Cursor runtime, including auth import, local daemon lifecycle, live probe checks, and dashboard controls.
This guide covers the deprecated CCS-owned Cursor IDE bridge, including auth import, local daemon lifecycle, live probe checks, and dashboard controls.
`ccs cursor` now belongs to the CLIProxy-backed Cursor provider path.
Use `ccs legacy cursor` for the deprecated local bridge documented here.
## What It Provides
- OpenAI-compatible local endpoint powered by Cursor credentials.
- Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients.
- Cursor model list and chat completions via the local CCS daemon.
- Dedicated dashboard page: `ccs config` -> `Cursor IDE`.
- Dedicated dashboard page: `ccs config` -> `Deprecated` -> `Cursor IDE (Legacy)`.
## What This Runtime Actually Does
`ccs cursor` does not launch Cursor IDE itself.
`ccs legacy cursor` does not launch Cursor IDE itself.
The current workflow is:
1. import Cursor credentials from local SQLite or manual input
@@ -32,7 +35,7 @@ Treat this as a CCS-managed Cursor bridge, not a generic CLIProxy-backed provide
### 1) Enable integration
```bash
ccs cursor enable
ccs legacy cursor enable
```
### 2) Import credentials
@@ -40,25 +43,25 @@ ccs cursor enable
Auto-detect from Cursor local SQLite state:
```bash
ccs cursor auth
ccs legacy cursor auth
```
Manual fallback:
```bash
ccs cursor auth --manual --token <token> --machine-id <machine-id>
ccs legacy cursor auth --manual --token <token> --machine-id <machine-id>
```
### 3) Start daemon
```bash
ccs cursor start
ccs legacy cursor start
```
### 4) Run a live probe
```bash
ccs cursor probe
ccs legacy cursor probe
```
Use this to verify that the current build can complete one real authenticated request through the local daemon.
@@ -66,26 +69,37 @@ Use this to verify that the current build can complete one real authenticated re
### 5) Run Cursor-backed Claude
```bash
ccs cursor "explain this repo"
ccs legacy cursor "explain this repo"
```
### 6) Verify status
```bash
ccs cursor status
ccs legacy cursor status
```
Use `ccs cursor` with bare or normal Claude args to run through the local Cursor proxy.
Use `ccs legacy cursor` with bare or normal Claude args to run through the local Cursor proxy.
The admin namespace remains available for setup and inspection:
```bash
ccs cursor help
ccs legacy cursor help
```
### 7) Stop daemon
```bash
ccs cursor stop
ccs legacy cursor stop
```
## Supported Cursor Provider Path
For the supported CLIProxy-backed Cursor provider, use:
```bash
ccs cursor --auth
ccs cursor --accounts
ccs cursor --config
ccs cursor "task"
```
## Runtime Defaults
@@ -96,7 +110,7 @@ ccs cursor stop
- Model list resolution: authenticated live fetch when available, with cached/default fallback.
- Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model.
- Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`.
- Live verification: `ccs cursor probe` or `POST /api/cursor/probe`
- Live verification: `ccs legacy cursor probe` or `POST /api/cursor/probe`
These values are managed in unified config and can be updated from CLI or dashboard.
@@ -108,7 +122,7 @@ Open dashboard:
ccs config
```
Then navigate to `Cursor IDE` in the sidebar.
Then navigate to `Cursor IDE (Legacy)` in the `Deprecated` section.
Available controls:
@@ -131,9 +145,9 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr
### `Not authenticated` or `expired` in `ccs cursor status`
- Re-run `ccs cursor auth` (or manual auth command).
- Re-run `ccs legacy cursor auth` (or manual auth command).
### `ccs cursor probe` fails even though status is green
### `ccs legacy cursor probe` fails even though status is green
- `status` proves local config/auth/daemon readiness only.
- `probe` proves the live runtime path.
@@ -148,4 +162,4 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr
### Daemon fails to start
- Check if port `20129` is in use.
- Change port in dashboard config tab, then retry `ccs cursor start`.
- Change port in dashboard config tab, then retry `ccs legacy cursor start`.
+8
View File
@@ -12,6 +12,12 @@ Authentication is **disabled by default** for backward compatibility. Use the CL
CCS does **not** ship a default dashboard username or password. When someone opens the dashboard from a non-loopback/IP address before auth is enabled, the UI now shows a setup state instead of an ambiguous login form. The host owner must run `ccs config auth setup`, or the user should switch back to the localhost URL if they are on the same machine.
Docker note: the integrated `ccs docker` stack stores its config inside the running container volume, not in the outer shell's `~/.ccs`. For Docker deployments, run auth setup inside the container:
```bash
docker exec -it ccs-cliproxy ccs config auth setup
```
When auth stays disabled, CCS now applies a localhost-only fallback on sensitive management endpoints. Remote devices can still open the dashboard UI when you intentionally bind it beyond loopback, but write-capable routes such as AI Provider management and CLIProxy auth/status helpers reject non-loopback requests until you enable dashboard auth.
## Account Context Modes (Related Feature)
@@ -193,6 +199,8 @@ dashboard_auth:
Run `ccs config auth setup` to configure credentials.
If you are using the integrated Docker stack, run that command inside `ccs-cliproxy`. Running it on the outer host shell updates a different config directory and will not unlock the running dashboard.
### Forgot password
Run `ccs config auth setup` again to set a new password.
+332
View File
@@ -0,0 +1,332 @@
# OpenAI-Compatible Provider Routing
CCS can route Claude Code traffic through a local Anthropic-compatible proxy when
your API profile points at an OpenAI-compatible chat completions endpoint.
This is useful for providers such as:
- Hugging Face Inference Providers
- OpenRouter
- Ollama
- llama.cpp servers
- OpenAI-compatible self-hosted gateways
## Related Project: claude-code-router
[claude-code-router](https://github.com/musistudio/claude-code-router) is the
main external reference that informed this CCS work. Their Anthropic/OpenAI
transformer design helped shape the routing approach here.
When to use CCR:
- you want a standalone router without CCS profile integration
- you do not need CCS account/runtime management around the request flow
When to use CCS:
- you already use CCS API profiles or runtime bridges
- you want the proxy flow available through `ccs <profile>` and `ccs proxy ...`
- you want the routing behavior documented and tested inside the CCS workflow
## What CCS Does
When you launch a compatible settings profile with the Claude target, CCS now:
1. Starts a local proxy on `127.0.0.1`
2. Accepts Anthropic `/v1/messages` traffic from Claude Code
3. Translates requests into OpenAI chat-completions format
4. Forwards them to your configured upstream provider
5. Translates streaming responses back into Anthropic SSE
You do not need to rewrite your profile by hand each time.
## Quick Start
Create or reuse an API profile that points at an OpenAI-compatible endpoint:
```bash
ccs api create --preset hf
```
Then you can use the profile directly:
```bash
ccs hf
```
CCS detects that the profile is OpenAI-compatible and auto-routes Claude Code
through the local proxy.
## Manual Proxy Lifecycle
If you want to manage the proxy explicitly:
```bash
ccs proxy start hf
eval "$(ccs proxy activate)"
ccs proxy status
ccs proxy stop
```
Useful variants:
```bash
ccs proxy start hf --host 127.0.0.1
ccs proxy activate --fish
```
`ccs proxy activate` now prints the full local runtime contract:
- `ANTHROPIC_BASE_URL`
- `ANTHROPIC_AUTH_TOKEN`
- `ANTHROPIC_MODEL` plus tier defaults when present
- `DISABLE_TELEMETRY`
- `DISABLE_COST_WARNINGS`
- `API_TIMEOUT_MS`
- `NO_PROXY`
## One Active Proxy Profile
The current runtime is a single local proxy daemon.
- Reusing the same OpenAI-compatible profile is supported
- Starting a different OpenAI-compatible profile while one proxy is already
running is rejected instead of silently replacing the active upstream
This is intentional to avoid breaking an in-flight Claude session by swapping
its upstream provider out from under it.
## Request-Time Routing
The proxy is no longer limited to the startup profile's default model.
Supported request-time selectors:
- `profile:model`
Example: `deepseek:deepseek-reasoner`
- `profile`
Example: `openrouter`
- plain model ids
Example: `deepseek-chat`
Plain model ids use exact string equality against the configured profile model
slots (`model`, `opusModel`, `sonnetModel`, `haikuModel`). CCS does not apply
fuzzy matching or prefix matching here. If no exact match is found, the request
stays on the active profile with the requested model id unchanged.
Routing behavior:
1. `profile:model` wins immediately.
2. Scenario routing may override the active profile when configured.
3. Plain model ids are matched against the configured OpenAI-compatible
profiles before falling back to the active profile.
This means a Claude session launched through one compatible profile can still
request another compatible profile/model when the proxy can resolve it safely.
## Scenario Routing
Scenario routing is now supported through `proxy.routing` in your CCS config.
Example `~/.ccs/config.yaml`:
```yaml
proxy:
routing:
default: "deepseek:deepseek-chat"
background: "ollama:qwen2.5-coder:0.5b"
think: "deepseek:deepseek-reasoner"
longContext: "openrouter:google/gemini-2.5-pro"
longContextThreshold: 60000
webSearch: "openrouter:perplexity/sonar-pro"
```
Current scenario detection:
- `background`: requested model contains `haiku`
- `think`: Anthropic `thinking` is enabled
- `longContext`: estimated request tokens exceed `longContextThreshold`
- `webSearch`: tool list includes `web_search`
- `default`: fallback selector when the above do not apply
Routing decisions are logged through CCS structured logs.
`longContextThreshold` uses an intentionally approximate token estimate based on
message characters, tool payload size, and a `chars / 4` heuristic. Tune the
threshold conservatively if your routing decision needs a sharper cutoff near
the boundary.
## How Profile Detection Works
CCS keeps these profiles in the normal API/settings-profile flow.
Anthropic-compatible endpoints such as:
- `https://api.anthropic.com`
- `https://api.z.ai/api/anthropic`
- `https://api.deepseek.com/anthropic`
continue to launch directly.
OpenAI-compatible endpoints such as:
- `https://router.huggingface.co/v1`
- `https://api.openai.com/v1`
- `http://localhost:11434`
are routed through the local proxy for Claude-target launches.
## Provider Setup
### DeepSeek
Use a settings profile whose env looks like:
```json
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-...",
"ANTHROPIC_MODEL": "deepseek-chat",
"CCS_DROID_PROVIDER": "generic-chat-completion-api"
}
}
```
Typical override target:
- `deepseek:deepseek-reasoner`
### OpenRouter
```json
{
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-or-...",
"ANTHROPIC_MODEL": "openai/gpt-4.1-mini",
"CCS_DROID_PROVIDER": "generic-chat-completion-api"
}
}
```
Useful when you want:
- model fan-out behind one provider profile
- long-context or web-search scenario targets
### Ollama / Local Gateways
```json
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:11434",
"ANTHROPIC_AUTH_TOKEN": "ollama",
"ANTHROPIC_MODEL": "qwen3-coder",
"CCS_DROID_PROVIDER": "generic-chat-completion-api"
}
}
```
For self-signed HTTPS gateways, add `CCS_OPENAI_PROXY_INSECURE=1`.
### DashScope / Qwen Compatible Mode
DashScope's compatible endpoint works even when older settings files still
carry a stale Anthropic-style provider hint:
```json
{
"env": {
"ANTHROPIC_BASE_URL": "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-...",
"ANTHROPIC_MODEL": "qwen3.6-plus",
"CCS_DROID_PROVIDER": "anthropic"
}
}
```
CCS now infers the OpenAI-compatible route from the base URL and does not let
that stale provider hint block proxy routing.
## Self-Signed TLS
If your upstream gateway uses a self-signed or privately issued certificate,
set this in the profile settings JSON:
```json
{
"env": {
"CCS_OPENAI_PROXY_INSECURE": "1"
}
}
```
That flag is respected by both:
- `ccs <profile>` auto-routing
- `ccs proxy start <profile>`
## Supported Runtime Paths
- `ccs <profile>` with Claude target: auto-starts the local proxy when needed
- `ccs proxy start <profile>`: starts the proxy explicitly
- `GET /`: proxy info and bound profile details
- `GET /health`: proxy liveness check
- `GET /v1/models`: local view of the configured model mapping
- `POST /v1/messages`: Anthropic-compatible request entrypoint
## Troubleshooting
### Missing or invalid local proxy token
- Re-run `eval "$(ccs proxy activate)"`
- Check `ccs proxy status` and confirm the expected profile is running
### Self-signed or private CA upstream
- Add `CCS_OPENAI_PROXY_INSECURE=1` to the profile settings
- Restart the proxy after changing the setting
### Port conflict on `3456`
- Start with a fixed port: `ccs proxy start hf --port 3457`
- Re-run `ccs proxy activate` after changing the port
### Provider returns `429` or empty upstream output
- CCS now preserves upstream rate-limit errors and retry headers
- Empty or malformed provider JSON is returned as Anthropic-style `api_error`
### Requests route to the wrong model/profile
- Use an explicit selector such as `profile:model`
- Review `proxy.routing` if scenario routing is enabled
- Check CCS structured logs in `~/.ccs/logs/current.jsonl` for routing decisions
## Validation
The shipped coverage includes:
- unit tests for OpenAI-compatible profile detection
- unit tests for Anthropic -> OpenAI request translation
- unit tests for request-time profile/model routing and scenario routing
- unit tests for multi-line SSE parsing
- integration tests for `/v1/messages` request/response translation
- integration tests for rate limits, empty upstream responses, timeout handling,
thinking/tool-call chunk streaming, and request-time routing
- integration tests for daemon lifecycle and `/health` / `/v1/models`
- e2e tests for `ccs proxy` lifecycle
- e2e tests for `ccs <profile>` auto-routing through a mock upstream
Focused verification command:
```bash
bun test tests/e2e/proxy-command.e2e.test.ts tests/integration/proxy/request-routing.test.ts --coverage
```
Pre-merge gate:
```bash
bun run validate
```
+5 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-04-10
Last Updated: 2026-04-14
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,10 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor.
- **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads.
- **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell.
- **2026-04-14**: **#991** CCS now auto-routes Claude-target settings profiles that use OpenAI-compatible endpoints through a local Anthropic-compatible proxy instead of sending raw Anthropic `/v1/messages` traffic directly to chat-completions backends. The `ccs proxy` command now supports `start`, `status`, `activate`, and `stop` with explicit host binding, shell-aware activation helpers, and a fuller local runtime env contract. The proxy surface now exposes `GET /`, `/health`, `/v1/models`, and `/v1/messages`, logs routing decisions into CCS structured logs, supports Anthropic image blocks plus request-time `profile:model` overrides, and adds config-driven scenario routing (`background`, `think`, `longContext`, `webSearch`) on top of the compatible-profile path. Coverage now includes request routing, rate-limit/timeout/empty-upstream failures, chunked tool-call streaming, and disconnect cleanup alongside the existing unit, integration, and e2e suites.
- **2026-04-10**: **#765** `/providers` now includes a first-class Hugging Face preset for API Profiles. CCS exposes Hugging Face Inference Providers through the existing OpenAI-compatible profile flow with the official router endpoint `https://router.huggingface.co/v1`, a short `hf` default profile name, and `hf` preset alias support for both the dashboard chooser and `ccs api create --preset hf`.
- **2026-04-10**: **#944** Image Analysis auth readiness no longer collapses to native Read when merged runtime-status dependency overrides include a missing initializer value. CCS now preserves default dependency functions when override entries are `undefined`, still reads token-backed auth status directly in the local readiness path, and includes regression coverage for the missing-initializer case that previously surfaced as `deps.initializeAccounts is not a function`.
- **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.71.0",
"version": "7.71.0-dev.13",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
@@ -0,0 +1,127 @@
---
phase: 1
title: "CLI Routing & Namespacing"
status: complete
effort: "6h"
---
# Phase 1: CLI Routing & Namespacing
## Context Links
- `plan.md`
- `src/ccs.ts`
- `src/auth/profile-detector.ts`
- `src/cursor/constants.ts`
- `src/commands/root-command-router.ts`
- `src/commands/command-catalog.ts`
- `src/commands/help-command.ts`
- `src/commands/cursor-command.ts`
- `src/commands/cursor-command-display.ts`
- `src/types/profile.ts`
- `src/config/reserved-names.ts`
## Overview
- Priority: P1
- Owner scope: CLI entry, help, profile detection, command naming
- Goal: make `cursor` provider-first and move the deprecated bridge under `legacy cursor`
## Key Insights
- The current collision is structural, not cosmetic. `ccs cursor` means "legacy bridge" in `src/ccs.ts` and `src/auth/profile-detector.ts`, but `cursor` is also listed as a built-in CLIProxy provider.
- `shouldUseCursorCliproxyShortcut()` is only a heuristic escape hatch. It does not fix bare `ccs cursor`, quoted prompts, or help routing.
- Help is currently inconsistent: provider help exists generically, but `cursor` is excluded and routed to bridge help instead.
## Requirements
- Reserve `cursor` for CLIProxy runtime and CLIProxy admin flags.
- Introduce explicit legacy syntax: `ccs legacy cursor ...`.
- Keep a release-N alias for old legacy admin subcommands only.
- Rename internal bridge-only profile typing from ambiguous `cursor` to explicit `legacy-cursor`.
- Keep file ownership isolated to CLI/router/help files in this phase.
## Data Flow
- Provider path:
`argv -> root command resolution -> provider shortcut/help path -> ProfileDetector(type=cliproxy, provider=cursor) -> CLIProxy runtime`
- Legacy path:
`argv -> legacy root command -> legacy cursor subrouter -> ProfileDetector(type=legacy-cursor) or direct handler -> local bridge runtime`
- Deprecated alias path, release N only:
`argv=ccs cursor auth|status|... -> alias shim -> warning -> dispatch to legacy cursor handler`
## Architecture
- Add a new root command namespace: `ccs legacy`.
- Add nested routing under `legacy` with `cursor` as the first migrated leaf. Do not overload `cursor` itself any longer.
- Remove provider exceptions for `cursor` from the generic provider help/routing logic. `ccs cursor --help` should now use provider shortcut help.
- Convert bridge-only type checks from `profileInfo.type === 'cursor'` to `profileInfo.type === 'legacy-cursor'`.
- Keep `ccs cursor help` only as a release-N compatibility shim that prints:
- `Use "ccs cursor --help" for CLIProxy Cursor`
- `Use "ccs legacy cursor help" for the deprecated bridge`
## Related Code Files
- Modify:
- `src/ccs.ts`
- `src/auth/profile-detector.ts`
- `src/cursor/constants.ts`
- `src/commands/root-command-router.ts`
- `src/commands/command-catalog.ts`
- `src/commands/help-command.ts`
- `src/commands/cursor-command.ts`
- `src/commands/cursor-command-display.ts`
- `src/types/profile.ts`
- `src/config/reserved-names.ts`
- `src/shared/claude-extension-setup.ts`
- `src/targets/target-runtime-compatibility.ts`
- Create:
- `src/commands/legacy-command.ts` or `src/commands/legacy/index.ts`
- `src/commands/legacy/cursor-command.ts` if the team wants physical separation immediately
## Implementation Steps
1. Add the `legacy` root command route and its help surface.
2. Flip `src/ccs.ts` so `cursor` goes through normal CLIProxy provider routing; remove the special-case that gives the bridge ownership of the name.
3. Replace the `shouldUseCursorCliproxyShortcut()` hack with provider-first dispatch plus a compatibility alias table for the old legacy subcommands.
4. Update `ProfileDetector` priority order so `cursor` resolves as `cliproxy`, while `legacy cursor` resolves as `legacy-cursor`.
5. Rename bridge-only help text, summaries, and status text to say "legacy Cursor bridge" explicitly.
6. Audit all `profileType === 'cursor'` checks and convert only the bridge-specific ones to `legacy-cursor`.
## Todo List
- [x] Add `legacy cursor` routing
- [x] Make `ccs cursor` provider-first for bare, prompt, and `--help` usage
- [x] Add deprecated alias forwarding for old admin subcommands
- [x] Rename internal bridge profile path to `legacy-cursor`
- [x] Update provider help, completion, and command catalog summaries
## Success Criteria
- `ccs cursor "task"` resolves to CLIProxy Cursor.
- `ccs legacy cursor "task"` resolves to the old bridge.
- `ccs cursor --help` shows provider shortcut help.
- `ccs cursor auth` still works in release N, but prints an exact replacement warning.
- No CLI path depends on `shouldUseCursorCliproxyShortcut()` to disambiguate runtime meaning.
## Risk Assessment
- High likelihood / high impact: users with scripts calling `ccs cursor "task"` will hit the provider path immediately.
Mitigation: call this out in release notes, keep admin aliases, add explicit warning when legacy files/config are detected and the user invokes `ccs cursor` with no flags.
- Medium likelihood / medium impact: bridge-only type renames may break target compatibility checks or extension setup.
Mitigation: grep audit every `profileType === 'cursor'` branch before tests.
## Rollback Plan
- Re-enable the old `cursor` special-case in `src/ccs.ts` and `ProfileDetector`.
- Keep the new `legacy` namespace in place even if dormant; it is additive and safe to leave.
- Do not roll back migrated files in this phase; routing rollback alone is enough.
## Security Considerations
- No auth material moves in this phase.
- Preserve existing `CCS_HOME`-aware path resolution. Do not introduce `os.homedir()` shortcuts while adding the new namespace.
## Next Steps
- Phase 2 depends on the new command contract from this phase.
@@ -0,0 +1,140 @@
---
phase: 2
title: "Storage & API Boundaries"
status: partial
effort: "6h"
---
# Phase 2: Storage & API Boundaries
## Context Links
- `plan.md`
- `src/config/unified-config-types.ts`
- `src/config/unified-config-loader.ts`
- `src/cursor/cursor-auth.ts`
- `src/cursor/cursor-daemon-pid.ts`
- `src/cliproxy/config/path-resolver.ts`
- `src/cliproxy/config/env-builder.ts`
- `src/web-server/routes/index.ts`
- `src/web-server/routes/cursor-routes.ts`
- `src/web-server/routes/cursor-settings-routes.ts`
- `src/web-server/routes/cliproxy-stats-routes.ts`
- `src/api/services/profile-lifecycle-service.ts`
## Overview
- Priority: P1
- Owner scope: config schema, path resolution, backend APIs, migration readers
- Goal: make legacy bridge storage explicit and guarantee CLIProxy Cursor never writes the legacy raw settings file
## Key Insights
- Top-level `config.cursor` is bridge-only configuration today and must move.
- The legacy bridge owns `~/.ccs/cursor.settings.json`, `~/.ccs/cursor/credentials.json`, and `~/.ccs/cursor/daemon.pid`.
- CLIProxy provider settings currently resolve through generic provider settings helpers and can still collide with the legacy file for provider `cursor`.
- `~/.ccs/cursor.settings.json` is historically documented as legacy-owned, so it is unsafe to auto-import it into provider storage by default.
## Requirements
- Canonical legacy config key: `legacy.cursor`
- Canonical legacy files:
- `~/.ccs/legacy/cursor.settings.json`
- `~/.ccs/legacy/cursor/credentials.json`
- `~/.ccs/legacy/cursor/daemon.pid`
- Canonical provider file for CLIProxy Cursor only:
- `~/.ccs/cliproxy/cursor.settings.json`
- Canonical legacy API namespace:
- `/api/legacy/cursor/*`
- Compatibility reads:
- read old `config.cursor`
- read old `~/.ccs/cursor.settings.json`
- read old `~/.ccs/cursor/*`
- Compatibility writes:
- write only the new `legacy.*` and `cliproxy/*` paths
## Data Flow
- Legacy config:
`load config -> prefer legacy.cursor -> fallback config.cursor -> normalize -> write legacy.cursor only`
- Legacy raw settings:
`load /api/legacy/cursor/settings/raw -> prefer ~/.ccs/legacy/cursor.settings.json -> fallback ~/.ccs/cursor.settings.json -> write new legacy path`
- Provider settings:
`CLIProxy env builder/stats updater -> read ~/.ccs/cliproxy/cursor.settings.json -> if absent use defaults -> never read/write ~/.ccs/cursor.settings.json`
## Architecture
- Add a `legacy` section to unified config types and loader. Keep old `cursor` as read-only migration input during the compatibility window.
- Move legacy bridge filesystem helpers under a `legacy/cursor` path prefix.
- Split API routing:
- new canonical mount: `/api/legacy/cursor`
- release-N alias: `/api/cursor` -> same handlers + deprecation header
- Special-case CLIProxy provider settings for `cursor` only in the provider path resolver. Do not expand this migration to every provider in this issue.
- Treat existing `~/.ccs/cursor.settings.json` as legacy-owned. Do not auto-copy it into provider storage unless a future explicit provider migration is added.
## Related Code Files
- Modify:
- `src/config/unified-config-types.ts`
- `src/config/unified-config-loader.ts`
- `src/cursor/cursor-auth.ts`
- `src/cursor/cursor-daemon-pid.ts`
- `src/cliproxy/config/path-resolver.ts`
- `src/cliproxy/config/env-builder.ts`
- `src/web-server/routes/index.ts`
- `src/web-server/routes/cursor-routes.ts`
- `src/web-server/routes/cursor-settings-routes.ts`
- `src/web-server/routes/cliproxy-stats-routes.ts`
- `src/api/services/profile-lifecycle-service.ts`
- Create:
- `src/web-server/routes/legacy-cursor-routes.ts`
- `src/web-server/routes/legacy-cursor-settings-routes.ts`
- `src/config/migrations/cursor-legacy-migration.ts` if migration logic should stay out of the loader
## Implementation Steps
1. Extend config types and loader to support `legacy.cursor`, with `legacy.cursor` taking precedence over old `cursor`.
2. Update legacy bridge credential and pid helpers to use `~/.ccs/legacy/cursor/`.
3. Update the raw settings route to use `~/.ccs/legacy/cursor.settings.json` as canonical and old root path as read fallback only.
4. Move legacy API mounts to `/api/legacy/cursor/*` and keep `/api/cursor/*` as a warned alias for release N.
5. Change CLIProxy Cursor provider settings resolution to `~/.ccs/cliproxy/cursor.settings.json`.
6. Update orphan detection and cleanup logic so old `cursor.settings.json` is treated as a migration target, not a permanent provider-owned file.
## Todo List
- [ ] Add `legacy.cursor` config schema and loader precedence
- [ ] Move bridge credentials/pid/raw settings under `~/.ccs/legacy/`
- [x] Add canonical `/api/legacy/cursor/*` routes
- [x] Keep release-N `/api/cursor/*` alias
- [x] Isolate CLIProxy Cursor settings away from `~/.ccs/cursor.settings.json`
- [ ] Update cleanup/orphan handling
## Success Criteria
- Saving legacy bridge settings writes only to `legacy.cursor` and `~/.ccs/legacy/*`.
- CLIProxy Cursor model/env updates write only to `~/.ccs/cliproxy/cursor.settings.json`.
- Existing legacy users can still read old config/files during the compatibility window.
- No backend route that serves the provider path references `~/.ccs/cursor.settings.json`.
## Risk Assessment
- High likelihood / high impact: old `~/.ccs/cursor.settings.json` contents are ambiguous between bridge and provider expectations.
Mitigation: treat the file as legacy-owned and do not auto-import it into provider storage.
- Medium likelihood / medium impact: route aliasing may mask which API is canonical.
Mitigation: add explicit response headers or payload flags marking `/api/cursor/*` as deprecated.
## Rollback Plan
- Keep read fallback from old paths even if the canonical write path changes back.
- If the new legacy API namespace causes regressions, remount `/api/cursor/*` as canonical temporarily and keep the new namespace dormant.
- Do not delete old files during release N; cleanup stays opt-in until release N+2.
## Security Considerations
- Preserve `0600` for migrated credentials and `0700` for directories.
- Use atomic temp-file writes exactly as current routes do.
- Never copy provider tokens into the legacy namespace or legacy tokens into provider storage automatically.
## Next Steps
- Phase 3 depends on the canonical API and path names from this phase.
@@ -0,0 +1,121 @@
---
phase: 3
title: "Dashboard & Deprecation UX"
status: partial
effort: "4h"
---
# Phase 3: Dashboard & Deprecation UX
## Context Links
- `plan.md`
- `ui/src/App.tsx`
- `ui/src/components/layout/app-sidebar.tsx`
- `ui/src/pages/cursor.tsx`
- `ui/src/hooks/use-cursor.ts`
- `ui/src/lib/i18n.ts`
- `src/web-server/routes/index.ts`
- `src/commands/cursor-command-display.ts`
## Overview
- Priority: P1
- Owner scope: dashboard route ownership, labels, user-facing deprecation messaging
- Goal: align dashboard semantics with CLI semantics so `/cursor` means provider and legacy UI is clearly marked and isolated
## Key Insights
- The current dashboard already admits the bridge is deprecated, but the route `/cursor` still belongs to it.
- The page includes direct navigation to CLIProxy Cursor, which means the UX already wants a split; the route layer just has not caught up.
- Keeping `/cursor` for legacy while CLI uses `cursor` for provider would create the same ambiguity in a different surface.
## Requirements
- `/cursor` must become the provider-owned dashboard surface.
- The legacy bridge page must move to `/legacy/cursor`.
- Legacy bridge API hooks must move to `/api/legacy/cursor/*`.
- The deprecated UX must contain exact replacements, not generic warnings.
- Sidebar grouping must reflect support level:
- provider view under provider/cliproxy navigation
- legacy bridge under deprecated navigation
## Data Flow
- Provider dashboard:
`browser /cursor -> provider view or redirect wrapper -> /cliproxy?provider=cursor -> existing CLIProxy provider APIs`
- Legacy dashboard:
`browser /legacy/cursor -> legacy bridge page -> useLegacyCursor hook -> /api/legacy/cursor/*`
- Compatibility API path, release N only:
`old UI/tests -> /api/cursor/* -> alias handler -> same legacy payload + deprecation signal`
## Architecture
- Keep provider UI DRY by making `/cursor` a thin redirect or preselected wrapper around the existing CLIProxy provider page instead of building a second Cursor-provider page.
- Move the current `ui/src/pages/cursor.tsx` implementation to a new `legacy-cursor` page and rename its hook to `useLegacyCursor`.
- Change nav labels from generic "Cursor IDE" to explicit "Cursor Bridge (Legacy)" in the deprecated section.
- Update CLI and dashboard warnings to show both paths side-by-side:
- `ccs cursor --auth` / `/cursor`
- `ccs legacy cursor auth` / `/legacy/cursor`
## Related Code Files
- Modify:
- `ui/src/App.tsx`
- `ui/src/components/layout/app-sidebar.tsx`
- `ui/src/lib/i18n.ts`
- `src/commands/cursor-command-display.ts`
- Move or rename:
- `ui/src/pages/cursor.tsx` -> `ui/src/pages/legacy-cursor.tsx`
- `ui/src/hooks/use-cursor.ts` -> `ui/src/hooks/use-legacy-cursor.ts`
- Create:
- `ui/src/pages/cursor-provider-redirect.tsx` if a wrapper is preferred over direct router config
## Implementation Steps
1. Move the legacy page and hook to `legacy-*` names and update all imports.
2. Reassign `/cursor` to the provider path and add `/legacy/cursor` for the bridge page.
3. Update sidebar grouping and labels so the provider path is no longer listed under Deprecated.
4. Replace vague deprecated copy with concrete migration copy:
- old command
- new command
- old route
- new route
5. Keep the legacy page banner persistent until release N+2, not dismissible per session.
## Todo List
- [ ] Move legacy page/hook module names to `legacy-*`
- [x] Reassign `/cursor` and add `/legacy/cursor`
- [x] Update deprecated nav group and labels
- [x] Rewrite key banners, button copy, and path labels with exact replacements
- [x] Keep provider and legacy links visible from both surfaces during release N
## Success Criteria
- Opening `/cursor` lands on the CLIProxy Cursor provider surface.
- Opening `/legacy/cursor` lands on the bridge page with a persistent deprecation banner.
- No dashboard component serving the provider route uses the legacy API hook.
- Every warning banner shows the exact before/after command and route.
## Risk Assessment
- Medium likelihood / medium impact: users with bookmarked `/cursor` expect the legacy page.
Mitigation: provider page shows a top-level "Looking for the old bridge?" callout linking to `/legacy/cursor`.
- Low likelihood / medium impact: UI rename churn breaks lazy imports or tests.
Mitigation: do route and hook rename in one phase and leave compatibility API alias in place until tests pass.
## Rollback Plan
- Point `/cursor` back to the legacy page if the provider redirect breaks.
- Keep `/legacy/cursor` additive; it does not block rollback.
- Do not remove the deprecation banner on rollback; it still communicates future intent.
## Security Considerations
- No auth secrets should be exposed in UI copy or route params.
- Keep manual auth dialogs scoped to the legacy page only. Provider auth remains in CLIProxy flows.
## Next Steps
- Phase 4 owns test rewrites, docs updates, and release gating for these UI changes.
@@ -0,0 +1,148 @@
---
phase: 4
title: "Tests Docs & Rollout"
status: complete
effort: "4h"
---
# Phase 4: Tests Docs & Rollout
## Context Links
- `plan.md`
- `docs/cursor-integration.md`
- `README.md`
- `docs/system-architecture/provider-flows.md`
- `docs/system-architecture/index.md`
- `tests/unit/cursor/cursor-shortcut-routing.test.ts`
- `tests/unit/web-server/cursor-settings-routes.test.ts`
- `tests/unit/web-server/cursor-routes.test.ts`
- `ui/tests/unit/hooks/use-cursor.test.tsx`
- `ui/tests/unit/ui/pages/cursor-page.test.tsx`
## Overview
- Priority: P1
- Owner scope: compatibility rollout, validation, docs/help updates, release notes
- Goal: ship the namespace split without surprising existing bridge users or leaving docs/help inconsistent
## Key Insights
- This change has one intentional breaking behavior: positional `ccs cursor` stops being the legacy bridge.
- Everything else can use a compatibility window: admin subcommands, API aliases, old config reads, old file-path reads.
- Tests must lock both meanings so the ambiguity does not regress later.
## Requirements
- Document exact before/after commands and routes.
- Add a concrete migration path for three user groups:
- legacy bridge users
- CLIProxy Cursor users
- dashboard bookmark users
- Define removal windows for aliases and old path fallbacks.
- Run repo quality gates after implementation:
- root: `bun run format && bun run lint:fix && bun run validate && bun run validate:ci-parity`
- UI: `cd ui && bun run format && bun run lint:fix && bun run validate`
## Test Matrix
- Unit:
- provider-first cursor routing
- legacy alias forwarding
- `legacy.cursor` loader precedence
- path resolvers for legacy vs provider files
- deprecation help text snapshots
- Integration:
- `ccs cursor "task"` -> provider
- `ccs legacy cursor "task"` -> bridge
- `/api/legacy/cursor/*` canonical behavior
- `/api/cursor/*` alias behavior during release N
- UI:
- `/cursor` route ownership
- `/legacy/cursor` banner and actions
- hook path changes and raw settings save targets
- Manual release validation:
- migrate old config/files in a temp `CCS_HOME`
- verify provider path never writes `~/.ccs/cursor.settings.json`
## User Migration Plan
1. Legacy bridge users:
- replace `ccs cursor ...` with `ccs legacy cursor ...`
- run `ccs legacy cursor status`
- update scripts and dashboard bookmarks to `/legacy/cursor`
2. CLIProxy Cursor users:
- keep using `ccs cursor ...`
- if provider-specific settings are needed, re-save them under the new provider-owned path instead of relying on `~/.ccs/cursor.settings.json`
3. Mixed/unclear state:
- `ccs migrate` should move `config.cursor` and legacy files into the new legacy namespace
- do not auto-copy the old raw settings file into provider storage
## Deprecation UX Plan
- CLI warning text, release N:
- `ccs cursor auth` is deprecated. Use `ccs legacy cursor auth` for the old bridge or `ccs cursor --auth` for CLIProxy Cursor.
- Dashboard banner:
- visible on `/legacy/cursor`
- provider route links back to legacy route with "Looking for the old bridge?"
- Docs banner:
- top callout in `docs/cursor-integration.md` pointing users to CLIProxy Cursor as the supported path
## Related Code Files
- Modify tests:
- `tests/unit/cursor/cursor-shortcut-routing.test.ts`
- `tests/unit/web-server/cursor-settings-routes.test.ts`
- `tests/unit/web-server/cursor-routes.test.ts`
- `ui/tests/unit/hooks/use-cursor.test.tsx`
- `ui/tests/unit/ui/pages/cursor-page.test.tsx`
- Modify docs:
- `docs/cursor-integration.md`
- `README.md` if root command examples mention Cursor
- `docs/system-architecture/provider-flows.md`
- `docs/system-architecture/index.md`
- CLI help snapshots or generated references if present
## Implementation Steps
1. Rewrite tests around the new command contract and route ownership before removing aliases in later releases.
2. Update docs/help text in the same PR as code changes so the new syntax ships atomically.
3. Add migration notes to changelog/release notes with a bold callout that `ccs cursor "task"` now means CLIProxy Cursor.
4. Keep a removal checklist for release N+1 and N+2 in the plan or roadmap so the compatibility window does not become permanent.
## Todo List
- [x] Update unit, integration, and selected UI tests
- [x] Update docs and CLI help text
- [x] Add migration note and deprecation wording
- [x] Run root and UI quality gates
- [x] Record alias-removal follow-up for N+1 and old-path-removal follow-up for N+2
## Success Criteria
- Test suite covers both provider and legacy cursor paths explicitly.
- Docs and help text match the shipped command contract exactly.
- Release notes include the migration table and deprecation window.
- Quality gates pass in both root and `ui/`.
## Risk Assessment
- High likelihood / medium impact: docs or tests lag behind the command flip and users keep invoking the wrong surface.
Mitigation: block merge until help text, docs, and tests all match the new contract.
- Medium likelihood / medium impact: compatibility shims never get removed.
Mitigation: create follow-up issues or roadmap entries for N+1 and N+2 removal work before merge.
## Rollback Plan
- If rollout messaging is incomplete, revert the command flip before removing aliases.
- If only docs/help are wrong, fix docs first and keep aliases until corrected.
- Old-path readers stay in place through N+1, so rollback does not strand migrated users.
## Security Considerations
- Use temp `CCS_HOME` in tests and manual verification. Never touch the real `~/.ccs`.
- Sanitize any migration logs or warnings so they mention paths, not token contents.
## Next Steps
- Implementation is complete when all four phases land together; do not ship phase 1 without phases 2-4.
@@ -0,0 +1,93 @@
---
title: "Separate legacy Cursor bridge from CLIProxy Cursor provider"
description: "Reserve `cursor` for the CLIProxy provider, move the reverse-engineered bridge under `legacy`, and split storage/UI with a staged migration."
status: in_progress
priority: P1
effort: 2d
branch: kai/feat/1016-missing-provider-integration
tags: [cursor, cliproxy, migration, dashboard, deprecation]
created: 2026-04-15
blockedBy: []
blocks: []
---
# Separate legacy Cursor bridge from CLIProxy Cursor provider
## Goal
Make `cursor` mean one thing everywhere: the CLIProxy-backed provider. Move the deprecated local bridge to `legacy`, stop provider writes to `~/.ccs/cursor.settings.json`, and ship a low-risk migration window.
## Current Collision Points
- `src/ccs.ts` hardcodes `cursor` as a legacy command/profile, then reclaims only `--auth|--logout|--config|--accounts` for CLIProxy.
- `src/auth/profile-detector.ts` resolves `cursor` to the legacy runtime before CLIProxy provider detection.
- `src/commands/command-catalog.ts` and `src/commands/help-command.ts` advertise `cursor` as both bridge and provider.
- `src/config/unified-config-types.ts` + `src/config/unified-config-loader.ts` store bridge config under top-level `cursor`.
- `src/cliproxy/config/path-resolver.ts`, `src/cliproxy/config/env-builder.ts`, and `src/web-server/routes/cliproxy-stats-routes.ts` still use provider settings paths that collide with the legacy raw file.
- `src/web-server/routes/cursor-*.ts`, `ui/src/pages/cursor.tsx`, `ui/src/hooks/use-cursor.ts`, `ui/src/App.tsx`, and `ui/src/components/layout/app-sidebar.tsx` dedicate `/cursor` and `/api/cursor/*` to the legacy bridge.
- `docs/cursor-integration.md` documents `ccs cursor` as the bridge even though CLIProxy already exposes a `cursor` provider shortcut.
## Command Contract
Before:
```text
ccs cursor -> legacy bridge runtime
ccs cursor "task" -> legacy bridge runtime
ccs cursor auth|status|... -> legacy bridge admin
ccs cursor --auth|--config -> CLIProxy Cursor shortcut
```
After release N:
```text
ccs cursor -> CLIProxy Cursor runtime
ccs cursor "task" -> CLIProxy Cursor runtime
ccs cursor --auth|--config -> CLIProxy Cursor admin
ccs legacy cursor -> legacy bridge runtime
ccs legacy cursor "task" -> legacy bridge runtime
ccs legacy cursor auth|... -> legacy bridge admin
```
Compatibility window, release N only:
- `ccs cursor auth|status|probe|models|start|stop|enable|disable|help` forwards to `ccs legacy cursor ...` with a deprecation warning.
- Bare and positional `ccs cursor` switch immediately to the provider path; no silent legacy fallback.
## Phase Plan
| Phase | Scope | Output |
| --- | --- | --- |
| 1 | [CLI Routing & Namespacing](./phase-01-cli-routing-namespacing.md) | Provider-first `cursor`, explicit `legacy cursor`, updated help/catalog/type names |
| 2 | [Storage & API Boundaries](./phase-02-storage-api-boundaries.md) | `legacy.cursor` config, split file paths, `/api/legacy/cursor/*`, provider path isolation |
| 3 | [Dashboard & Deprecation UX](./phase-03-dashboard-deprecation-ux.md) | `/cursor` -> provider view, `/legacy/cursor` -> bridge view, clear migration UX |
| 4 | [Tests Docs & Rollout](./phase-04-tests-docs-rollout.md) | Compatibility plan, migration steps, test matrix, docs updates, rollback gates |
## Rollout Sequence
1. Release N: add new legacy namespace, flip `ccs cursor` to provider, keep old admin subcommands and `/api/cursor/*` as warned aliases, and split provider settings away from `~/.ccs/cursor.settings.json`.
2. Release N+1: move the remaining legacy backend/config namespaces fully under `legacy.cursor`, keep old file-path fallback and `/api/cursor/*` alias for one more release.
3. Release N+2: remove old `config.cursor` and root-level `~/.ccs/cursor*` fallback reads, delete stale alias docs/help, and let cleanup/migrate remove leftovers.
## Current Implementation Status
- Completed in this branch:
- `ccs cursor` is provider-first for runtime and `--help`
- `ccs legacy cursor` works as the explicit legacy bridge namespace
- old legacy admin subcommands under `ccs cursor ...` forward with deprecation warnings
- CLIProxy Cursor settings no longer collide with `~/.ccs/cursor.settings.json`
- `/cursor` redirects to the provider surface while `/legacy/cursor` serves the deprecated bridge page
- `/api/legacy/cursor/*` is mounted and the legacy page uses that namespace
- docs, completion, and core regression tests were updated
- Intentionally deferred follow-up:
- move top-level `config.cursor` to `legacy.cursor`
- move legacy credentials/pid/raw settings fully under `~/.ccs/legacy/cursor/*`
- rename `use-cursor` and `CursorPage` modules to explicit `legacy-*`
## Success Criteria
- `cursor` is provider-owned in CLI help, routing, dashboard nav, and docs.
- Legacy bridge is reachable only through `legacy cursor` and `legacy.cursor` storage.
- CLIProxy Cursor never reads or writes `~/.ccs/cursor.settings.json`.
- Existing legacy users have an explicit migration path, warning UX, and rollback-safe compatibility window.
## Docs Impact
Major. CLI reference, Cursor docs, dashboard tour, provider docs, and migration notes all change in the same release.
+20 -20
View File
@@ -26,6 +26,7 @@ import { getCcsDir } from '../utils/config-manager';
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
import type { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
import type { TargetType } from '../targets/target-adapter';
import type { ProfileType } from '../types/profile';
@@ -253,9 +254,8 @@ class ProfileDetector {
* Detect profile type and return routing information
*
* Priority order:
* 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen)
* 0.5. Copilot profile (if enabled in config)
* 0.75. Cursor profile (if enabled in config)
* 0. Hardcoded special runtime profiles (copilot, cursor)
* 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...)
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
* 3. Settings-based profiles (config.profiles section) [legacy]
@@ -267,16 +267,7 @@ class ProfileDetector {
return this.resolveDefaultProfile();
}
// Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config
if (isCLIProxyProvider(profileName)) {
return {
type: 'cliproxy',
name: profileName,
provider: profileName,
};
}
// Priority 0.5: Check Copilot profile - GitHub Copilot subscription via copilot-api
// Priority 0: Check Copilot profile - GitHub Copilot subscription via copilot-api
if (profileName === 'copilot') {
const unifiedConfig = this.readUnifiedConfig();
const copilotConfig = unifiedConfig?.copilot;
@@ -306,17 +297,17 @@ class ProfileDetector {
};
}
// Priority 0.75: Check Cursor profile - local Cursor daemon runtime
if (profileName === 'cursor') {
// Priority 0.25: Check explicit legacy Cursor bridge profile.
if (profileName === LEGACY_CURSOR_PROFILE_NAME) {
const cursorConfig = getCursorConfig();
if (!cursorConfig?.enabled) {
const error = new Error(
'Cursor profile is not enabled.\n\n' +
'Legacy Cursor profile is not enabled.\n\n' +
'To enable Cursor integration:\n' +
' 1. Run: ccs cursor enable\n' +
' 2. Import auth: ccs cursor auth\n' +
' 3. Start daemon: ccs cursor start\n\n' +
' 1. Run: ccs legacy cursor enable\n' +
' 2. Import auth: ccs legacy cursor auth\n' +
' 3. Start daemon: ccs legacy cursor start\n\n' +
'Or manually edit ~/.ccs/config.yaml:\n' +
' cursor:\n' +
' enabled: true'
@@ -329,11 +320,20 @@ class ProfileDetector {
return {
type: 'cursor',
name: 'cursor',
name: LEGACY_CURSOR_PROFILE_NAME,
cursorConfig,
};
}
// Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...)
if (isCLIProxyProvider(profileName)) {
return {
type: 'cliproxy',
name: profileName,
provider: profileName,
};
}
// Priority 1: Try unified config if available
const unifiedConfig = this.readUnifiedConfig();
if (unifiedConfig) {
+129 -20
View File
@@ -39,11 +39,15 @@ import {
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
getEffectiveClaudeBrowserAttachConfig,
resolveBrowserRuntimeEnv,
resolveConfiguredBrowserProfileDir,
syncBrowserMcpToConfigDir,
} from './utils/browser';
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
import {
getBrowserConfig,
getGlobalEnvConfig,
getOfficialChannelsConfig,
} from './config/unified-config-loader';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
removeImageAnalysisProfileHook,
@@ -65,7 +69,8 @@ import {
resolveOfficialChannelsLaunchPlan,
} from './channels/official-channels-runtime';
import { getOfficialChannelReadiness } from './channels/official-channels-store';
import { isCursorSubcommandToken } from './cursor/constants';
import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants';
import { isCLIProxyProvider } from './cliproxy/provider-capabilities';
// Import centralized error handling
import { handleError, runCleanup } from './errors';
@@ -77,6 +82,7 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
import type { ProfileDetectionResult } from './auth/profile-detector';
// Import target adapter system
import {
@@ -97,6 +103,11 @@ import {
} from './targets/droid-reasoning-runtime';
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge';
import {
buildOpenAICompatProxyEnv,
resolveOpenAICompatProfileConfig,
startOpenAICompatProxy,
} from './proxy';
// Version and Update check utilities
import { getVersion } from './utils/version';
@@ -128,7 +139,7 @@ const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v
function resolveCodexRuntimeConfigOverrides(
target: ReturnType<typeof resolveTargetType>
): string[] {
if (target !== 'codex') {
if (target !== 'codex' || !getBrowserConfig().codex.enabled) {
return [];
}
return buildCodexBrowserMcpOverrides();
@@ -147,6 +158,26 @@ function detectProfile(args: string[]): DetectedProfile {
}
}
function normalizeLegacyCursorArgs(args: string[]): string[] {
if (args[0] === 'legacy' && args[1] === 'cursor') {
return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)];
}
return args;
}
function printCursorLegacySubcommandDeprecation(subcommand: string): void {
console.error(
info(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`)
);
console.error(
info(
`Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.`
)
);
console.error('');
}
function resolveRuntimeReasoningFlags(
args: string[],
envThinkingValue: string | undefined
@@ -341,7 +372,7 @@ async function main(): Promise<void> {
registerTarget(new CodexAdapter());
const cliLogger = createLogger('cli');
const args = process.argv.slice(2);
let args = process.argv.slice(2);
const isCompletionCommand = args[0] === '__complete';
// Initialize UI colors early to ensure consistent colored output
@@ -417,6 +448,8 @@ async function main(): Promise<void> {
return;
}
args = normalizeLegacyCursorArgs(args);
cliLogger.info('command.start', 'CLI invocation started', {
command: args[0] || 'default',
argCount: args.length,
@@ -484,6 +517,17 @@ async function main(): Promise<void> {
return;
}
if (
typeof firstArg === 'string' &&
isCLIProxyProvider(firstArg) &&
args.length > 1 &&
(args.includes('--help') || args.includes('-h'))
) {
const { showProviderShortcutHelp } = await import('./commands/help-command');
await showProviderShortcutHelp(firstArg);
return;
}
// Special case: copilot command (GitHub Copilot integration)
// Route known subcommands to command handler, keep all other args as profile passthrough.
if (firstArg === 'copilot' && args.length > 1) {
@@ -497,9 +541,8 @@ async function main(): Promise<void> {
}
}
// Special case: cursor command (Cursor local proxy integration)
// Route known admin subcommands to the command handler, keep all other args as profile passthrough.
if (firstArg === 'cursor' && args.length > 1) {
// Special case: explicit legacy Cursor bridge namespace.
if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) {
const { handleCursorCommand } = await import('./commands/cursor-command');
const cursorToken = args[1];
@@ -509,6 +552,19 @@ async function main(): Promise<void> {
}
}
// Compatibility shim: old `ccs cursor <subcommand>` still forwards to the legacy bridge
// for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy.
if (firstArg === 'cursor' && args.length > 1) {
const { handleCursorCommand } = await import('./commands/cursor-command');
const cursorToken = args[1];
if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') {
printCursorLegacySubcommandDeprecation(cursorToken);
const exitCode = await handleCursorCommand(args.slice(1));
process.exit(exitCode);
}
}
// First-time install: offer setup wizard for interactive users
// Check independently of recovery status (user may have empty config.yaml)
// Skip if headless, CI, or non-TTY environment
@@ -538,7 +594,7 @@ async function main(): Promise<void> {
// Detect profile (strip --target flags before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
const profileInfo = detector.detectProfileType(profile);
const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
let resolvedTarget: ReturnType<typeof resolveTargetType>;
try {
resolvedTarget = resolveTargetType(
@@ -1002,13 +1058,13 @@ async function main(): Promise<void> {
const imageAnalysisMcpReady =
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
const browserProfileDir =
const browserAttachConfig =
resolvedTarget === 'claude'
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
: undefined;
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
if (browserProfileDir) {
if (browserAttachConfig?.enabled) {
ensureBrowserMcpOrThrow();
}
}
@@ -1035,7 +1091,7 @@ async function main(): Promise<void> {
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserProfileDir &&
browserAttachConfig?.enabled &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
@@ -1245,10 +1301,13 @@ async function main(): Promise<void> {
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
// values from prior sessions cannot leak into the active profile.
if (browserProfileDir) {
if (browserAttachConfig?.enabled) {
browserRuntimeEnv = {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
profileDir: browserAttachConfig.userDataDir,
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
? String(browserAttachConfig.devtoolsPort)
: undefined,
})),
};
}
@@ -1304,6 +1363,53 @@ async function main(): Promise<void> {
const browserArgs = browserRuntimeEnv
? appendBrowserToolArgs(imageAnalysisArgs)
: imageAnalysisArgs;
const openAICompatProfile = resolveOpenAICompatProfileConfig(
profileInfo.name,
expandedSettingsPath,
settingsEnv
);
if (openAICompatProfile) {
const proxyStart = await startOpenAICompatProxy(openAICompatProfile, {
insecure: openAICompatProfile.insecure,
});
if (!proxyStart.success) {
console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy'));
process.exit(1);
}
console.error(
info(
`Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}`
)
);
const proxyEnv = {
...envVars,
...buildOpenAICompatProxyEnv(
openAICompatProfile,
proxyStart.port,
proxyStart.authToken || '',
inheritedClaudeConfigDir
),
};
delete proxyEnv.ANTHROPIC_API_KEY;
const launchArgs = [
'--settings',
expandedSettingsPath,
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile.proxy',
args: launchArgs,
profile: profileInfo.name,
profileType: profileInfo.type,
settingsPath: expandedSettingsPath,
});
execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv });
return;
}
const launchArgs = [
'--settings',
expandedSettingsPath,
@@ -1366,17 +1472,20 @@ async function main(): Promise<void> {
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
const browserProfileDir =
const browserAttachConfig =
resolvedTarget === 'claude'
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
: undefined;
if (resolvedTarget === 'claude') {
if (browserProfileDir) {
if (browserAttachConfig?.enabled) {
ensureBrowserMcpOrThrow();
browserRuntimeEnv = {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
profileDir: browserAttachConfig.userDataDir,
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
? String(browserAttachConfig.devtoolsPort)
: undefined,
})),
};
Object.assign(envVars, browserRuntimeEnv);
@@ -1396,7 +1505,7 @@ async function main(): Promise<void> {
if (defaultContinuityInheritance.claudeConfigDir) {
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
if (
browserProfileDir &&
browserAttachConfig?.enabled &&
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
) {
throw new Error(
@@ -1,6 +1,9 @@
import type { CLIProxyProvider } from '../types';
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
const FREE_PLAN_PARTS = new Set(['free']);
const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']);
const BUSINESS_PLAN_PARTS = new Set(['team']);
// Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is
// separate because the browser bundle cannot import this server module directly.
@@ -49,6 +52,20 @@ function formatVariantPart(value: string): string {
}
}
function formatWorkspaceLabel(parts: string[]): string | null {
const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part));
if (workspaceId) {
return `Workspace ${workspaceId.toLowerCase()}`;
}
return parts.map(formatVariantPart).filter(Boolean).join(' · ') || null;
}
function formatAudienceDetail(parts: string[]): string | null {
const label = parts.map(formatVariantPart).filter(Boolean).join(' · ');
return label || null;
}
export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean {
return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider));
}
@@ -136,8 +153,16 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st
}
const suffix = parts[parts.length - 1]?.toLowerCase();
if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) {
return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
return ['Business', formatWorkspaceLabel(parts.slice(0, -1))].filter(Boolean).join(' · ');
}
if (suffix && FREE_PLAN_PARTS.has(suffix)) {
return ['Free', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · ');
}
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
return ['Personal', formatVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))]
.filter(Boolean)
.join(' · ');
}
+38
View File
@@ -155,6 +155,10 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google'
* - Qwen: Device Code Flow (polling-based, NO callback port needed)
* - GHCP: Device Code Flow (polling-based, NO callback port needed)
* - Kimi: Device Code Flow (polling-based, NO callback port needed)
* - Cursor: Device-style browser polling (NO callback port needed)
* - GitLab: Authorization Code Flow with callback server on port 17171
* - CodeBuddy: Device-style browser polling (NO callback port needed)
* - Kilo: Device Code Flow (polling-based, NO callback port needed)
*/
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> =
CLIPROXY_PROVIDER_IDS.reduce(
@@ -274,6 +278,34 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
scopes: ['api'],
authFlag: '--kimi-login',
},
cursor: {
provider: 'cursor',
displayName: 'Cursor',
authUrl: 'https://cursor.com/loginDeepControl',
scopes: [],
authFlag: '--cursor-login',
},
gitlab: {
provider: 'gitlab',
displayName: 'GitLab Duo',
authUrl: 'https://gitlab.com/oauth/authorize',
scopes: ['api', 'read_user'],
authFlag: '--gitlab-login',
},
codebuddy: {
provider: 'codebuddy',
displayName: 'CodeBuddy (Tencent)',
authUrl: 'https://copilot.tencent.com/v2/plugin/auth/state',
scopes: [],
authFlag: '--codebuddy-login',
},
kilo: {
provider: 'kilo',
displayName: 'Kilo AI',
authUrl: 'https://api.kilo.ai/api/device-auth/codes',
scopes: [],
authFlag: '--kilo-login',
},
};
/**
@@ -373,6 +405,12 @@ export interface OAuthOptions {
noIncognito?: boolean;
/** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */
import?: boolean;
/** GitLab auth mode override. */
gitlabAuthMode?: 'oauth' | 'pat';
/** GitLab self-hosted base URL override. */
gitlabBaseUrl?: string;
/** GitLab personal access token for PAT login. */
gitlabPersonalAccessToken?: string;
/** Enable paste-callback mode: show auth URL and prompt for callback paste */
pasteCallback?: boolean;
/** If true, use port-forwarding mode (skip interactive prompt in headless) */
-437
View File
@@ -1,437 +0,0 @@
/**
* Gemini Token Refresh
*
* Handles proactive token validation and refresh for Gemini OAuth tokens.
* Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use.
*
* Token sources (priority order):
* 1. CLIProxy auth dir (~/.ccs/cliproxy/auth/) - CCS-managed tokens
* 2. Standard Gemini CLI (~/.gemini/oauth_creds.json) - backward compatibility
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getProviderAuthDir } from '../config-generator';
import { getDefaultAccount, getProviderAccounts } from '../account-manager';
import { isTokenFileForProvider } from './token-manager';
/** Google OAuth token endpoint */
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
/** Refresh tokens 5 minutes before expiry */
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000;
const GEMINI_CLIENT_ID_ENV_KEYS = ['CCS_GEMINI_OAUTH_CLIENT_ID', 'OPENCLAW_GEMINI_OAUTH_CLIENT_ID'];
const GEMINI_CLIENT_SECRET_ENV_KEYS = [
'CCS_GEMINI_OAUTH_CLIENT_SECRET',
'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET',
];
/** Gemini oauth_creds.json structure */
interface GeminiOAuthCreds {
access_token: string;
refresh_token?: string;
expiry_date?: number; // Unix timestamp in milliseconds
scope?: string;
token_type?: string;
id_token?: string;
client_id?: string;
client_secret?: string;
token_uri?: string;
}
/** Gemini credentials with source path for write-back */
interface GeminiCredsWithSource {
creds: GeminiOAuthCreds;
sourcePath: string;
}
/** CLIProxyAPI Gemini token structure (from GeminiTokenStorage Go struct) */
interface CliproxyGeminiToken {
token: {
access_token: string;
refresh_token?: string;
expiry?: number; // Unix timestamp in milliseconds
client_id?: string;
client_secret?: string;
token_uri?: string;
};
project_id: string;
email: string;
type: 'gemini';
}
/** Token refresh response from Google */
interface TokenRefreshResponse {
access_token?: string;
expires_in?: number;
token_type?: string;
error?: string;
error_description?: string;
}
interface GoogleOAuthClientCredentials {
clientId: string;
clientSecret: string;
tokenUrl: string;
}
/**
* Get path to Gemini OAuth credentials file
*/
export function getGeminiOAuthPath(): string {
return path.join(os.homedir(), '.gemini', 'oauth_creds.json');
}
/**
* Map CLIProxyAPI token format to internal GeminiOAuthCreds format
*/
function mapCliproxyToGeminiCreds(cliproxy: CliproxyGeminiToken): GeminiOAuthCreds {
return {
access_token: cliproxy.token.access_token,
refresh_token: cliproxy.token.refresh_token,
expiry_date: cliproxy.token.expiry,
token_type: 'Bearer',
client_id: cliproxy.token.client_id,
client_secret: cliproxy.token.client_secret,
token_uri: cliproxy.token.token_uri,
};
}
/**
* Validate CLIProxyAPI token structure has required fields
*/
function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken {
if (typeof data !== 'object' || data === null) return false;
const obj = data as Record<string, unknown>;
if (obj.type !== 'gemini') return false;
if (typeof obj.token !== 'object' || obj.token === null) return false;
const token = obj.token as Record<string, unknown>;
return typeof token.access_token === 'string';
}
function getFirstEnvValue(keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function resolveGeminiRefreshCredentials(creds: GeminiOAuthCreds): {
credentials?: GoogleOAuthClientCredentials;
error?: string;
} {
const clientId = creds.client_id?.trim() || getFirstEnvValue(GEMINI_CLIENT_ID_ENV_KEYS);
const clientSecret =
creds.client_secret?.trim() || getFirstEnvValue(GEMINI_CLIENT_SECRET_ENV_KEYS);
if (!clientId || !clientSecret) {
return {
error:
'Gemini token refresh unavailable: missing OAuth client credentials in the token file. ' +
'Re-authenticate with CLIProxy or set CCS_GEMINI_OAUTH_CLIENT_ID and CCS_GEMINI_OAUTH_CLIENT_SECRET.',
};
}
return {
credentials: {
clientId,
clientSecret,
tokenUrl: creds.token_uri?.trim() || GOOGLE_TOKEN_URL,
},
};
}
/**
* Read Gemini token from CLIProxy auth directory
* Returns credentials with source path, or null if no valid token found
*/
function readCliproxyGeminiCreds(accountId?: string): GeminiCredsWithSource | null {
const authDir = getProviderAuthDir('gemini');
if (!fs.existsSync(authDir)) return null;
let tokenPath: string | null = null;
const normalizedAccountId = accountId?.trim();
const accounts = getProviderAccounts('gemini');
// Account-specific refresh path (used by background worker)
if (normalizedAccountId) {
const targetAccount = accounts.find((account) => account.id === normalizedAccountId);
if (!targetAccount) {
return null;
}
tokenPath = path.join(authDir, targetAccount.tokenFile);
}
if (!normalizedAccountId) {
// Try to find default account's token file
const defaultAccount = getDefaultAccount('gemini');
if (defaultAccount) {
tokenPath = path.join(authDir, defaultAccount.tokenFile);
if (!fs.existsSync(tokenPath)) tokenPath = null;
}
// Fallback: find any gemini account token file
if (!tokenPath && accounts.length > 0) {
tokenPath = path.join(authDir, accounts[0].tokenFile);
if (!fs.existsSync(tokenPath)) tokenPath = null;
}
// Last fallback: scan directory for gemini token files
if (!tokenPath) {
try {
const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json'));
for (const file of files) {
const filePath = path.join(authDir, file);
if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) {
tokenPath = filePath;
break;
}
}
} catch {
// Directory read failed - continue to return null
return null;
}
}
}
if (!tokenPath) return null;
try {
const content = fs.readFileSync(tokenPath, 'utf8');
const data: unknown = JSON.parse(content);
// Validate CLIProxyAPI format with proper type checking
if (isValidCliproxyToken(data)) {
return {
creds: mapCliproxyToGeminiCreds(data),
sourcePath: tokenPath,
};
}
return null;
} catch {
return null;
}
}
/**
* Read Gemini OAuth credentials
* Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json
* Returns credentials with source path for correct write-back
*/
function readGeminiCreds(accountId?: string): GeminiCredsWithSource | null {
// 1. Try CLIProxy auth directory first (CCS-managed tokens)
const cliproxyResult = readCliproxyGeminiCreds(accountId);
if (cliproxyResult) {
return cliproxyResult;
}
// Account-scoped refresh is only supported for CLIProxy account files.
// Do not fall back to ~/.gemini for a specific accountId.
if (accountId?.trim()) {
return null;
}
// 2. Fall back to standard Gemini CLI location
const oauthPath = getGeminiOAuthPath();
if (!fs.existsSync(oauthPath)) {
return null;
}
try {
const content = fs.readFileSync(oauthPath, 'utf8');
return {
creds: JSON.parse(content) as GeminiOAuthCreds,
sourcePath: oauthPath,
};
} catch {
return null;
}
}
/**
* Write updated credentials to CLIProxy token file
* Preserves existing fields (email, project_id), only updates token subfields
*/
function writeCliproxyGeminiCreds(tokenPath: string, creds: GeminiOAuthCreds): string | undefined {
try {
const existing = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
const updated = {
...existing,
token: {
...existing.token,
access_token: creds.access_token,
refresh_token: creds.refresh_token,
expiry: creds.expiry_date,
},
};
fs.writeFileSync(tokenPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
return undefined;
} catch (err) {
return err instanceof Error ? err.message : 'Failed to write credentials';
}
}
/**
* Write Gemini OAuth credentials
* Writes back to the specified source location (CLIProxy or ~/.gemini)
* @param creds - The credentials to write
* @param sourcePath - The path where credentials were originally read from
* @returns error message if write failed, undefined on success
*/
function writeGeminiCreds(creds: GeminiOAuthCreds, sourcePath: string): string | undefined {
const geminiOAuthPath = getGeminiOAuthPath();
// If source is not the standard Gemini path, write to CLIProxy format
if (sourcePath !== geminiOAuthPath) {
return writeCliproxyGeminiCreds(sourcePath, creds);
}
// Otherwise write to standard Gemini CLI location
const dir = path.dirname(geminiOAuthPath);
try {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(geminiOAuthPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
return undefined;
} catch (err) {
return err instanceof Error ? err.message : 'Failed to write credentials';
}
}
/**
* Check if Gemini token is expired or expiring soon
*/
export function isGeminiTokenExpiringSoon(accountId?: string): boolean {
const result = readGeminiCreds(accountId);
if (!result || !result.creds.access_token) {
return true; // No token = needs auth
}
if (!result.creds.expiry_date) {
return false; // No expiry info = assume valid
}
const expiresIn = result.creds.expiry_date - Date.now();
return expiresIn < REFRESH_LEAD_TIME_MS;
}
/**
* Refresh Gemini access token using refresh_token
* @param accountId Optional account ID for account-scoped refresh
* @returns Result with success status, optional error, and expiry time
*/
export async function refreshGeminiToken(accountId?: string): Promise<{
success: boolean;
error?: string;
expiresAt?: number;
}> {
const result = readGeminiCreds(accountId);
if (!result || !result.creds.refresh_token) {
return { success: false, error: 'No refresh token available' };
}
const { creds, sourcePath } = result;
const resolvedCredentials = resolveGeminiRefreshCredentials(creds);
if (!resolvedCredentials.credentials) {
return { success: false, error: resolvedCredentials.error };
}
const { clientId, clientSecret, tokenUrl } = resolvedCredentials.credentials;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(tokenUrl, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: creds.refresh_token as string, // Already validated above
client_id: clientId,
client_secret: clientSecret,
}).toString(),
});
clearTimeout(timeoutId);
const data = (await response.json()) as TokenRefreshResponse;
if (!response.ok || data.error) {
return {
success: false,
error: data.error_description || data.error || `OAuth error: ${response.status}`,
};
}
if (!data.access_token) {
return { success: false, error: 'No access_token in response' };
}
// Update credentials file with new token
const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000;
const updatedCreds: GeminiOAuthCreds = {
...creds,
access_token: data.access_token,
expiry_date: expiresAt,
};
const writeError = writeGeminiCreds(updatedCreds, sourcePath);
if (writeError) {
return { success: false, error: `Token refreshed but failed to save: ${writeError}` };
}
return { success: true, expiresAt };
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
return { success: false, error: 'Token refresh timeout' };
}
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
/**
* Ensure Gemini token is valid, refreshing if needed
* @param verbose Log progress if true
* @param accountId Optional account ID for account-scoped refresh
* @returns true if token is valid (or was refreshed), false if refresh failed
*/
export async function ensureGeminiTokenValid(
verbose = false,
accountId?: string
): Promise<{
valid: boolean;
refreshed: boolean;
error?: string;
}> {
const result = readGeminiCreds(accountId);
if (!result || !result.creds.access_token) {
return { valid: false, refreshed: false, error: 'No Gemini credentials found' };
}
if (!isGeminiTokenExpiringSoon(accountId)) {
return { valid: true, refreshed: false };
}
// Token is expired or expiring soon - try to refresh
if (verbose) {
console.log('[i] Gemini token expired or expiring soon, refreshing...');
}
const refreshResult = await refreshGeminiToken(accountId);
if (refreshResult.success) {
if (verbose) {
console.log('[OK] Gemini token refreshed successfully');
}
return { valid: true, refreshed: true };
}
return { valid: false, refreshed: false, error: refreshResult.error };
}
+90
View File
@@ -0,0 +1,90 @@
const GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH = 500;
const GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
const HTML_ERROR_RESPONSE_OMITTED = '[HTML error response omitted]';
function sanitizeGitLabPatErrorDetail(
detail: string | undefined,
submittedToken?: string
): string | undefined {
const trimmed = detail?.trim();
if (!trimmed) {
return undefined;
}
if (/^<!doctype html/i.test(trimmed) || /^<html/i.test(trimmed) || /^<[^>]+>/.test(trimmed)) {
return HTML_ERROR_RESPONSE_OMITTED;
}
let sanitized = trimmed.replace(
/"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token|personal_access_token)"\s*:\s*"[^"]*"/gi,
'"$1":"[redacted]"'
);
if (submittedToken) {
sanitized = sanitized.split(submittedToken).join('[redacted]');
}
sanitized = sanitized
.replace(/glpat-[A-Za-z0-9._-]+/gi, '[redacted]')
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]')
.replace(/\s+/g, ' ');
if (sanitized.length > GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH) {
sanitized = `${sanitized.slice(
0,
GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH - GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX.length
)}${GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX}`;
}
return sanitized;
}
export function parseGitLabPatAuthResponse(
responseOk: boolean,
responseStatus: number,
responseBody: string,
submittedToken?: string
):
| { ok: true; payload: Record<string, unknown> }
| { ok: false; payload: Record<string, unknown>; errorMessage: string } {
const trimmedBody = responseBody.trim();
let payload: Record<string, unknown> = {};
if (trimmedBody) {
try {
payload = JSON.parse(trimmedBody) as Record<string, unknown>;
} catch {
payload = {
error:
sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) ||
`GitLab PAT login failed with status ${responseStatus}`,
};
}
}
const payloadError =
typeof payload.error === 'string'
? sanitizeGitLabPatErrorDetail(payload.error, submittedToken)
: undefined;
const fallbackError =
sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) ||
`GitLab PAT login failed with status ${responseStatus}`;
if (!responseOk) {
return {
ok: false,
payload,
errorMessage: payloadError || fallbackError,
};
}
if (payload.status !== 'ok') {
return {
ok: false,
payload,
errorMessage: payloadError || fallbackError,
};
}
return { ok: true, payload };
}
+1 -1
View File
@@ -29,7 +29,7 @@ export async function tryKiroImport(tokenDir: string, verbose = false): Promise<
try {
log('Ensuring CLIProxy binary is available...');
const binaryPath = await ensureCLIProxyBinary(verbose);
const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true });
const configPath = generateConfig('kiro');
log(`Binary: ${binaryPath}`);
+184 -5
View File
@@ -56,6 +56,7 @@ import {
} from './token-manager';
import { executeOAuthProcess } from './oauth-process';
import { importKiroToken } from './kiro-import';
import { parseGitLabPatAuthResponse } from './gitlab-pat-response';
import {
getProxyTarget,
buildProxyUrl,
@@ -69,6 +70,7 @@ import {
warnPossible403Ban,
} from '../account-safety';
import { ensureCliAntigravityResponsibility } from '../antigravity-responsibility';
import { InteractivePrompt } from '../../utils/prompt';
interface PasteCallbackStartData {
url?: string;
@@ -83,9 +85,12 @@ const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
export async function requestPasteCallbackStart(
provider: CLIProxyProvider,
target: ProxyTarget,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
options?: {
kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
}
): Promise<PasteCallbackStartData> {
const startPath = getPasteCallbackStartPath(provider, {
let startPath = getPasteCallbackStartPath(provider, {
kiroMethod: options?.kiroMethod,
});
if (!startPath) {
@@ -93,6 +98,11 @@ export async function requestPasteCallbackStart(
`Paste-callback start is not available for ${provider} with the selected method`
);
}
const normalizedGitLabBaseUrl =
provider === 'gitlab' ? normalizeGitLabBaseUrl(options?.gitlabBaseUrl) : undefined;
if (normalizedGitLabBaseUrl) {
startPath += `&base_url=${encodeURIComponent(normalizedGitLabBaseUrl)}`;
}
const response = await fetch(buildProxyUrl(target, startPath), {
headers: buildManagementHeaders(target),
});
@@ -142,6 +152,49 @@ function parseAuthUrlState(url: string | null | undefined): string | null {
}
}
export function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined {
const normalized = baseUrl?.trim();
if (!normalized) {
return undefined;
}
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error('GitLab URL must be a valid http:// or https:// URL');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('GitLab URL must use http:// or https://');
}
parsed.hash = '';
parsed.search = '';
parsed.username = '';
parsed.password = '';
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
return normalizedPath ? `${parsed.origin}${normalizedPath}` : parsed.origin;
}
export async function promptGitLabPersonalAccessToken(): Promise<string | null> {
try {
const token = (await InteractivePrompt.password('GitLab Personal Access Token')).trim();
return token.length > 0 ? token : null;
} catch (error) {
if ((error as Error).message.includes('TTY')) {
console.log(
fail(
'GitLab Personal Access Token prompt requires an interactive TTY. Set the token explicitly or use Browser OAuth.'
)
);
return null;
}
throw error;
}
}
export function findNewTokenSnapshotForManualAuth(
provider: CLIProxyProvider,
tokenDir: string,
@@ -375,7 +428,7 @@ async function prepareBinary(
showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
try {
const binaryPath = await ensureCLIProxyBinary(verbose);
const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true });
process.stdout.write('\x1b[1A\x1b[2K');
showStep(1, 4, 'ok', 'CLIProxy binary ready');
@@ -458,7 +511,10 @@ async function handlePasteCallbackMode(
tokenDir: string,
nickname?: string,
expectedAccountId?: string,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
options?: {
kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
}
): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget();
@@ -647,6 +703,91 @@ async function handlePasteCallbackMode(
}
}
async function handleGitLabPatLogin(
provider: CLIProxyProvider,
oauthConfig: ProviderOAuthConfig,
verbose: boolean,
tokenDir: string,
nickname?: string,
expectedAccountId?: string,
options?: {
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
gitlabPersonalAccessToken?: OAuthOptions['gitlabPersonalAccessToken'];
}
): Promise<AccountInfo | null> {
const target = getProxyTarget();
const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl);
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
const suppliedToken = options?.gitlabPersonalAccessToken?.trim();
const personalAccessToken =
suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
let token = personalAccessToken;
if (!token) {
console.log('');
console.log(info(`Starting ${oauthConfig.displayName} PAT login...`));
console.log('Paste a Personal Access Token with api and read_user scopes.');
token = (await promptGitLabPersonalAccessToken()) || undefined;
}
if (!token) {
console.log(info('Cancelled'));
return null;
}
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildManagementHeaders(target),
},
body: JSON.stringify({
...(baseUrl ? { base_url: baseUrl } : {}),
personal_access_token: token,
}),
});
const responseBody = await response.text();
const parsedResponse = parseGitLabPatAuthResponse(
response.ok,
response.status,
responseBody,
token
);
if (!parsedResponse.ok) {
console.log(fail(parsedResponse.errorMessage));
return null;
}
const tokenSnapshot = findNewTokenSnapshotForAuthAttempt(
provider,
tokenDir,
knownTokenFiles,
expectedAccountId
);
if (!tokenSnapshot) {
console.log(fail('GitLab PAT login completed, but CCS could not find the saved token file.'));
return null;
}
const account = registerAccountFromToken(
provider,
tokenDir,
nickname,
verbose,
expectedAccountId || tokenSnapshot.file
);
if (!account) {
console.log(fail('Authenticated GitLab token could not be registered as a CCS account.'));
return null;
}
console.log(ok('Authentication successful!'));
return account;
}
/**
* Trigger OAuth flow for provider
* Auto-detects headless environment and uses --no-browser flag accordingly
@@ -666,6 +807,17 @@ export async function triggerOAuth(
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
const resolvedKiroIDCFlow =
provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW;
const resolvedGitLabAuthMode =
provider === 'gitlab' && options.gitlabAuthMode === 'pat' ? 'pat' : 'oauth';
let resolvedGitLabBaseUrl: string | undefined;
if (provider === 'gitlab') {
try {
resolvedGitLabBaseUrl = normalizeGitLabBaseUrl(options.gitlabBaseUrl);
} catch (error) {
console.log(fail((error as Error).message));
return null;
}
}
if (provider === 'agy') {
if (fromUI && !acceptAgyRisk) {
@@ -744,6 +896,14 @@ export async function triggerOAuth(
}
}
if (provider === 'gitlab' && resolvedGitLabBaseUrl && !selectedPasteCallback) {
selectedPasteCallback = true;
console.log('');
console.log(
info('GitLab custom base URL selected. Switching to paste-callback mode for OAuth.')
);
}
const useSelectedKiroLocalPasteCallback =
selectedPasteCallback &&
provider === 'kiro' &&
@@ -765,6 +925,22 @@ export async function triggerOAuth(
}
}
if (provider === 'gitlab' && resolvedGitLabAuthMode === 'pat') {
const tokenDir = getProviderTokenDir(provider);
return handleGitLabPatLogin(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id,
{
gitlabBaseUrl: resolvedGitLabBaseUrl,
gitlabPersonalAccessToken: options.gitlabPersonalAccessToken,
}
);
}
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
@@ -774,7 +950,10 @@ export async function triggerOAuth(
tokenDir,
nickname,
existingNameMatch?.id,
{ kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined }
{
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
}
);
}
+6 -29
View File
@@ -4,8 +4,7 @@
* Exports refresh functions for each OAuth provider.
*
* Refresh responsibility:
* - CCS-managed: gemini (CCS refreshes tokens directly via Google OAuth)
* - CLIProxy-delegated: codex, agy, kiro, ghcp, qwen, iflow, kimi
* - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, qwen, iflow, kimi
* (CLIProxyAPIPlus handles refresh automatically in background)
* - Not implemented: claude
*/
@@ -16,7 +15,6 @@ import {
getTokenRefreshOwnership,
isRefreshDelegatedToCLIProxy,
} from '../../provider-capabilities';
import { refreshGeminiToken } from '../gemini-token-refresh';
/** Token refresh result */
export interface ProviderRefreshResult {
@@ -66,19 +64,18 @@ export async function refreshToken(
};
}
if (provider === 'gemini') {
return await refreshGeminiTokenWrapper(normalizedAccountId);
}
const ownership = getTokenRefreshOwnership(provider);
switch (ownership) {
case 'cliproxy':
// CLIProxyAPIPlus handles refresh for these providers automatically.
// No action needed from CCS — report success with delegated flag.
return { success: true, delegated: true };
case 'unsupported':
case 'ccs':
// Non-gemini CCS-owned refresh paths are not implemented yet.
return {
success: false,
error: `Token refresh not yet implemented for ${provider}`,
};
case 'unsupported':
return {
success: false,
error: `Token refresh not yet implemented for ${provider}`,
@@ -87,23 +84,3 @@ export async function refreshToken(
return assertNever(ownership);
}
}
/**
* Wrapper for Gemini token refresh
* Converts gemini-token-refresh.ts format to provider-refreshers format
*/
async function refreshGeminiTokenWrapper(accountId: string): Promise<ProviderRefreshResult> {
const result = await refreshGeminiToken(accountId);
if (!result.success) {
return {
success: false,
error: result.error,
};
}
return {
success: true,
expiresAt: result.expiresAt,
};
}
+9 -6
View File
@@ -14,6 +14,7 @@ import { getProviderAuthDir } from '../config-generator';
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
import { buildEmailBackedAccountId } from '../accounts/email-account-identity';
import { getTokenRefreshOwnership } from '../provider-capabilities';
import {
AuthStatus,
PROVIDER_AUTH_PREFIXES,
@@ -507,14 +508,16 @@ export function displayAuthStatus(): void {
*/
export async function ensureTokenValid(
provider: CLIProxyProvider,
verbose = false
_verbose = false
): Promise<{ valid: boolean; refreshed: boolean; error?: string }> {
if (provider === 'gemini') {
const { ensureGeminiTokenValid } = await import('./gemini-token-refresh');
return ensureGeminiTokenValid(verbose);
if (getTokenRefreshOwnership(provider) === 'ccs') {
return {
valid: false,
refreshed: false,
error: `CCS-managed token validation is not available for ${provider}`,
};
}
// For CLIProxy-delegated providers, token refresh is handled by CLIProxyAPIPlus.
// CCS only verifies the token file exists (authentication state).
// Runtime-managed providers refresh upstream. CCS only verifies auth material exists locally.
return { valid: isAuthenticated(provider), refreshed: false };
}
+21 -2
View File
@@ -62,6 +62,8 @@ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): Binary
maxRetries: 3,
verbose: false,
forceVersion: false,
skipAutoUpdate: false,
allowInstall: true,
backend, // Pass backend for installer to use correct download URL
};
}
@@ -115,8 +117,16 @@ export class BinaryManager {
}
}
export interface EnsureCLIProxyBinaryOptions {
allowInstall?: boolean;
skipAutoUpdate?: boolean;
}
/** Convenience function respecting version pin */
export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
export async function ensureCLIProxyBinary(
verbose = false,
options: EnsureCLIProxyBinaryOptions = {}
): Promise<string> {
const backend = getConfiguredBackend();
// Migrate old shared pin to backend-specific location (one-time migration)
@@ -130,11 +140,20 @@ export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
version: pinnedVersion,
verbose,
forceVersion: true,
skipAutoUpdate: options.skipAutoUpdate ?? false,
allowInstall: options.allowInstall ?? true,
},
backend
).ensureBinary();
}
return new BinaryManager({ verbose }, backend).ensureBinary();
return new BinaryManager(
{
verbose,
skipAutoUpdate: options.skipAutoUpdate ?? false,
allowInstall: options.allowInstall ?? true,
},
backend
).ensureBinary();
}
/** Check if CLIProxyAPI binary is installed */
+17 -1
View File
@@ -26,6 +26,10 @@ function log(message: string, verbose: boolean): void {
if (verbose) console.error(`[cliproxy] ${message}`);
}
function getBackendLabel(backend: CLIProxyBackend): string {
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
}
/**
* Check if version is above max stable (known unstable)
*/
@@ -52,7 +56,7 @@ function clampToMaxStable(version: string | undefined, verbose: boolean): string
/** Handle auto-update when binary exists */
async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise<void> {
const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND;
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
const backendLabel = getBackendLabel(backend);
const updateResult = await checkForUpdates(config.binPath, config.version, verbose, backend);
const currentVersion = updateResult.currentVersion;
const latestVersion = updateResult.latestVersion;
@@ -112,6 +116,11 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
return binaryPath;
}
if (config.skipAutoUpdate) {
log('Runtime bootstrap mode: skipping auto-update check', verbose);
return binaryPath;
}
try {
await handleAutoUpdate(config, verbose);
} catch (error) {
@@ -125,6 +134,13 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
// Binary missing - download
log('Binary not found, downloading...', verbose);
if (!config.allowInstall) {
throw new Error(
`${getBackendLabel(backend)} binary is not installed locally. ` +
'Run "ccs cliproxy install" when you have network access.'
);
}
if (!config.forceVersion) {
try {
const latestVersion = await fetchLatestVersion(verbose, backend);
+6 -6
View File
@@ -1,5 +1,6 @@
import { getDefaultAccount } from './account-manager';
import { getProviderCatalog } from './model-catalog';
import { normalizeModelIdForProvider } from './model-id-normalizer';
import { fetchCodexQuota } from './quota-fetcher-codex';
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
import type { CodexQuotaResult } from './quota-types';
@@ -7,8 +8,8 @@ import { info, warn } from '../utils/ui';
export type CodexPlanType = CodexQuotaResult['planType'];
const FREE_SAFE_DEFAULT_MODEL = 'gpt-5-codex';
const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini';
const FREE_SAFE_DEFAULT_MODEL = 'gpt-5.4';
const FREE_SAFE_FAST_MODEL = 'gpt-5.4-mini';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
@@ -19,7 +20,6 @@ const KNOWN_CODEX_MODELS = new Set(
const FREE_PLAN_FALLBACKS = new Map<string, string>([
['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL],
['gpt-5.3-codex-spark', FREE_SAFE_FAST_MODEL],
['gpt-5.4', FREE_SAFE_DEFAULT_MODEL],
]);
export interface CodexRuntimeFallbackModelMap {
@@ -52,13 +52,13 @@ function isKnownCodexModel(model: string): boolean {
}
export function normalizeCodexModelId(model: string): string {
return model
const stripped = model
.trim()
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
.replace(CODEX_PAREN_SUFFIX_REGEX, '')
.replace(CODEX_EFFORT_SUFFIX_REGEX, '')
.trim()
.toLowerCase();
.trim();
return normalizeModelIdForProvider(stripped, 'codex').trim().toLowerCase();
}
export function getDefaultCodexModel(): string {
+73 -4
View File
@@ -19,7 +19,10 @@ import {
normalizeProtocol,
CLIPROXY_DEFAULT_PORT,
} from './port-manager';
import { getProviderSettingsPath } from './path-resolver';
import {
getLegacyProviderSettingsPath,
migrateLegacyProviderSettingsIfNeeded,
} from './path-resolver';
import {
canonicalizeModelIdForProvider,
MODEL_ENV_VAR_KEYS,
@@ -49,6 +52,15 @@ const REQUIRED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_API_KEY',
]);
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stripCodexEffortSuffix(modelId: string): string {
return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
@@ -285,6 +297,63 @@ export function getClaudeEnvVars(
return normalizeModelEnvVarsForProvider(mergedEnv, provider);
}
function buildCursorProviderSettingsFromLegacy(
legacySettings: Record<string, unknown>
): Record<string, unknown> {
const defaultEnv = getClaudeEnvVars('cursor');
const legacyEnvSource = legacySettings.env;
const legacyEnv = isObjectRecord(legacyEnvSource) ? legacyEnvSource : {};
const migratedEnv: NodeJS.ProcessEnv = { ...defaultEnv };
for (const [key, value] of Object.entries(legacyEnv)) {
if (typeof value !== 'string' || CURSOR_LEGACY_ENV_OVERRIDE_KEYS.has(key)) {
continue;
}
migratedEnv[key] = value;
}
delete migratedEnv.ANTHROPIC_API_KEY;
return {
...legacySettings,
env: normalizeModelEnvVarsForProvider(migratedEnv, 'cursor'),
};
}
/**
* Resolve the provider settings path, migrating legacy Cursor provider settings into
* the dedicated cliproxy/providers namespace on first access.
*/
export function resolveProviderSettingsPath(provider: CLIProxyProvider): string {
const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider);
if (provider !== 'cursor' || fs.existsSync(settingsPath)) {
return settingsPath;
}
const legacySettingsPath = getLegacyProviderSettingsPath(provider);
if (!fs.existsSync(legacySettingsPath)) {
return settingsPath;
}
try {
const parsed = JSON.parse(fs.readFileSync(legacySettingsPath, 'utf-8')) as unknown;
if (!isObjectRecord(parsed)) {
return settingsPath;
}
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(
settingsPath,
JSON.stringify(buildCursorProviderSettingsFromLegacy(parsed), null, 2) + '\n',
{ mode: 0o600 }
);
} catch {
// Best-effort migration only. Callers will fall back to defaults if the legacy file is invalid.
}
return settingsPath;
}
/**
* Get global env vars to inject into all third-party profiles.
* Returns empty object if disabled.
@@ -465,7 +534,7 @@ export function getEffectiveEnvVars(
}
// Priority 2: Default provider settings file
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
// Check for user override file
if (fs.existsSync(settingsPath)) {
@@ -505,7 +574,7 @@ export function getEffectiveEnvVars(
* Called during installation/first run
*/
export function ensureProviderSettings(provider: CLIProxyProvider): void {
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
const defaultEnv = getClaudeEnvVars(provider);
const writeSettings = (settings: Record<string, unknown>): void => {
@@ -663,7 +732,7 @@ export function getRemoteEnvVars(
// Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json)
if (Object.keys(userEnvVars).length === 0) {
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
if (fs.existsSync(settingsPath)) {
try {
const content = fs.readFileSync(settingsPath, 'utf-8');
+42
View File
@@ -16,6 +16,13 @@ export function getCliproxyDir(): string {
return path.join(getCcsDir(), 'cliproxy');
}
/**
* Get CLIProxy provider settings directory.
*/
export function getCliproxyProvidersDir(): string {
return path.join(getCliproxyDir(), 'providers');
}
/**
* Get CLIProxy writable directory for logs and runtime files.
* This directory is set as WRITABLE_PATH env var when spawning CLIProxy.
@@ -75,5 +82,40 @@ export function getBinDir(): string {
* Example: ~/.ccs/gemini.settings.json
*/
export function getProviderSettingsPath(provider: CLIProxyProvider): string {
if (provider === 'cursor') {
return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`);
}
return getLegacyProviderSettingsPath(provider);
}
/**
* Get CLIProxy provider settings path in the dedicated cliproxy/providers namespace.
* Used only for providers that must not collide with legacy top-level settings files.
*/
export function getDedicatedProviderSettingsPath(provider: CLIProxyProvider): string {
return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`);
}
/**
* Get legacy provider settings file path in ~/.ccs root.
* This is kept for compatibility reads/migration of older provider settings.
*/
export function getLegacyProviderSettingsPath(provider: CLIProxyProvider): string {
return path.join(getCcsDir(), `${provider}.settings.json`);
}
/**
* Resolve the effective provider settings path.
*
* Cursor uses a dedicated cliproxy/providers namespace so it does not collide
* with the deprecated Cursor IDE bridge raw settings file.
*/
export function migrateLegacyProviderSettingsIfNeeded(provider: CLIProxyProvider): string {
if (provider !== 'cursor') {
return getProviderSettingsPath(provider);
}
const targetPath = getDedicatedProviderSettingsPath(provider);
return targetPath;
}
+47 -8
View File
@@ -66,11 +66,15 @@ import {
import {
appendBrowserToolArgs,
ensureBrowserMcpOrThrow,
getEffectiveClaudeBrowserAttachConfig,
resolveBrowserRuntimeEnv,
resolveConfiguredBrowserProfileDir,
syncBrowserMcpToConfigDir,
} from '../../utils/browser';
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
import {
getBrowserConfig,
loadOrCreateUnifiedConfig,
getThinkingConfig,
} from '../../config/unified-config-loader';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import {
isKiroAuthMethod,
@@ -152,6 +156,14 @@ export function readOptionValue(
return { present: true, value: next.trim(), missingValue: false };
}
export function hasGitLabTokenLoginFlag(args: string[]): boolean {
return args.includes('--gitlab-token-login') || args.includes('--token-login');
}
function getGitLabTokenLoginFlagName(args: string[]): '--gitlab-token-login' | '--token-login' {
return args.includes('--gitlab-token-login') ? '--gitlab-token-login' : '--token-login';
}
/**
* Execute Claude CLI with CLIProxy (main entry point)
*
@@ -252,8 +264,8 @@ export async function execClaudeWithCLIProxy(
// Setup first-class CCS WebSearch runtime
ensureWebSearchMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
const browserProfileDir = resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR);
if (browserProfileDir) {
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig());
if (browserAttachConfig.enabled) {
ensureBrowserMcpOrThrow();
}
displayWebSearchStatus();
@@ -321,7 +333,7 @@ export async function execClaudeWithCLIProxy(
spinner.start();
try {
binaryPath = await ensureCLIProxyBinary(verbose);
binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true });
spinner.succeed('CLIProxy binary ready');
} catch (error) {
spinner.fail('Failed to prepare CLIProxy');
@@ -353,6 +365,7 @@ export async function execClaudeWithCLIProxy(
const addAccount = argsWithoutProxy.includes('--add');
const showAccounts = argsWithoutProxy.includes('--accounts');
const forceImport = argsWithoutProxy.includes('--import');
const gitlabTokenLogin = hasGitLabTokenLoginFlag(argsWithoutProxy);
const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy);
const incognitoFlag = argsWithoutProxy.includes('--incognito');
@@ -444,6 +457,16 @@ export async function execClaudeWithCLIProxy(
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
}
let gitlabBaseUrl: string | undefined;
const gitlabBaseUrlValue = readOptionValue(argsWithoutProxy, '--gitlab-url');
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
} else if (gitlabBaseUrlValue.present) {
console.error(fail('--gitlab-url requires a value'));
process.exitCode = 1;
return;
}
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
process.exitCode = 1;
@@ -491,6 +514,15 @@ export async function execClaudeWithCLIProxy(
return;
}
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
const flagName = gitlabTokenLogin
? getGitLabTokenLoginFlagName(argsWithoutProxy)
: '--gitlab-url';
console.error(fail(`${flagName} is only valid for ccs gitlab`));
process.exitCode = 1;
return;
}
// Parse --thinking / --effort flags (aliases; first occurrence wins)
const thinkingParse = parseThinkingOverride(argsWithoutProxy);
if (thinkingParse.error) {
@@ -730,6 +762,8 @@ export async function execClaudeWithCLIProxy(
...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}),
...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}),
...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}),
...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
@@ -775,6 +809,8 @@ export async function execClaudeWithCLIProxy(
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}),
...(gitlabBaseUrl ? { gitlabBaseUrl } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
@@ -1018,7 +1054,7 @@ export async function execClaudeWithCLIProxy(
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
if (
browserProfileDir &&
browserAttachConfig.enabled &&
inheritedClaudeConfigDir &&
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
) {
@@ -1121,10 +1157,13 @@ export async function execClaudeWithCLIProxy(
}
// 11. Build final environment with all proxy chains
const browserRuntimeEnv = browserProfileDir
const browserRuntimeEnv = browserAttachConfig.enabled
? {
...(await resolveBrowserRuntimeEnv({
profileDir: browserProfileDir,
profileDir: browserAttachConfig.userDataDir,
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
? String(browserAttachConfig.devtoolsPort)
: undefined,
})),
}
: undefined;
+15 -59
View File
@@ -186,56 +186,12 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
codex: {
provider: 'codex',
displayName: 'Copilot Codex',
defaultModel: 'gpt-5-codex',
defaultModel: 'gpt-5.4',
models: [
{
id: 'gpt-5-codex',
name: 'GPT-5 Codex',
description: 'Cross-plan safe Codex default',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high'],
maxLevel: 'high',
dynamicAllowed: false,
},
},
{
id: 'gpt-5-codex-mini',
name: 'GPT-5 Codex Mini',
description: 'Faster and cheaper Codex option',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high'],
maxLevel: 'high',
dynamicAllowed: false,
},
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
description: 'Legacy mini model ID kept for backwards compatibility',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high'],
maxLevel: 'high',
dynamicAllowed: false,
},
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
description: 'Legacy fast Codex mini model',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high'],
maxLevel: 'high',
dynamicAllowed: false,
},
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
description: 'Higher-effort Codex model with xhigh support',
id: 'gpt-5.4',
name: 'GPT-5.4',
description: 'Recommended Codex default for most coding and agentic tasks',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
@@ -244,13 +200,13 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
},
},
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
description: 'Cross-plan Codex model with xhigh support',
id: 'gpt-5.4-mini',
name: 'GPT-5.4 Mini',
description: 'Fast, lower-cost Codex option for lighter tasks and haiku-tier routing',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
maxLevel: 'xhigh',
levels: ['low', 'medium', 'high'],
maxLevel: 'high',
dynamicAllowed: false,
},
},
@@ -258,7 +214,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
tier: 'pro',
description: 'Paid Codex plans only',
description: 'Previous flagship coding model whose capabilities now power GPT-5.4',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
@@ -270,7 +226,8 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
id: 'gpt-5.3-codex-spark',
name: 'GPT-5.3 Codex Spark',
tier: 'pro',
description: 'Paid Codex plans only, ultra-fast coding model',
description:
'Research preview model for ChatGPT Pro subscribers, optimized for near-instant coding iteration',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
@@ -279,10 +236,9 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
},
},
{
id: 'gpt-5.4',
name: 'GPT-5.4',
tier: 'pro',
description: 'Paid Codex plans only, latest GPT-5 family model',
id: 'gpt-5.2',
name: 'GPT-5.2',
description: 'Previous general-purpose Codex model',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
+5 -5
View File
@@ -9,7 +9,7 @@ import * as fs from 'fs';
import * as os from 'os';
import { InteractivePrompt } from '../utils/prompt';
import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog';
import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator';
import { getClaudeEnvVars, resolveProviderSettingsPath } from './config-generator';
import { CLIProxyProvider } from './types';
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
@@ -31,7 +31,7 @@ function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string)
* Check if provider has user settings configured
*/
export function hasUserSettings(provider: CLIProxyProvider): boolean {
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
return fs.existsSync(settingsPath);
}
@@ -46,7 +46,7 @@ export function getCurrentModel(
): string | undefined {
const settingsPath = customSettingsPath
? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
: resolveProviderSettingsPath(provider);
if (!fs.existsSync(settingsPath)) return undefined;
try {
@@ -116,7 +116,7 @@ export async function configureProviderModel(
// Use custom settings path for CLIProxy variants, otherwise use default provider path
const settingsPath = customSettingsPath
? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
: resolveProviderSettingsPath(provider);
// Skip if already configured with a model (unless --config flag).
// A settings file can exist without model env keys (e.g., hook-only writes).
@@ -249,7 +249,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
await initUI();
const currentModel = getCurrentModel(provider);
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
const normalizedCurrentModel = currentModel
? canonicalizeModelForProvider(provider, currentModel)
: undefined;
+24
View File
@@ -27,6 +27,16 @@ const DENIED_ANTIGRAVITY_SONNET_45_REGEX =
/claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi;
const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const CODEX_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = Object.freeze({
'gpt-5-codex': 'gpt-5.4',
'gpt-5-codex-mini': 'gpt-5.4-mini',
'gpt-5-mini': 'gpt-5.4-mini',
'gpt-5.1-codex': 'gpt-5.2',
'gpt-5.1-codex-mini': 'gpt-5.4-mini',
'gpt-5.1-codex-max': 'gpt-5.4',
'gpt-5.2-codex': 'gpt-5.2',
'gpt-5.2-codex-mini': 'gpt-5.4-mini',
});
const IFLOW_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = Object.freeze({
'iflow-default': 'qwen3-coder-plus',
'kimi-k2.5': 'kimi-k2',
@@ -92,6 +102,17 @@ export function stripCodexEffortSuffix(model: string): string {
return model.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
}
/** Normalize legacy Codex aliases to the current public Codex model IDs. */
export function normalizeCodexLegacyModelAliases(model: string): string {
const trimmed = trimModelId(model);
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
const replacement = CODEX_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()];
if (!replacement) {
return trimmed;
}
return `${replacement}${suffix}`;
}
/**
* Normalize known legacy iFlow model aliases to current upstream model IDs.
* Preserves suffixes such as (budget) and [1m].
@@ -186,6 +207,9 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik
if (isIFlowProvider(provider)) {
return normalizeIFlowLegacyModelAliases(trimmedModel);
}
if (isCodexProvider(provider)) {
return normalizeCodexLegacyModelAliases(trimmedModel);
}
if (!isAntigravityProvider(provider)) return trimmedModel;
const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel);
return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion);
+49 -1
View File
@@ -33,7 +33,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackPort: 8085,
callbackProviderName: 'gemini',
authUrlProviderName: 'gemini-cli',
refreshOwnership: 'ccs',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['gemini-', 'google-'],
tokenTypeValues: ['gemini'],
aliases: ['gemini-cli'],
@@ -134,6 +134,54 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
tokenTypeValues: ['kimi'],
aliases: ['moonshot'],
},
cursor: {
displayName: 'Cursor',
description: 'Cursor browser-authenticated provider',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'cursor',
authUrlProviderName: 'cursor',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['cursor.', 'cursor-'],
tokenTypeValues: ['cursor'],
aliases: [],
},
gitlab: {
displayName: 'GitLab Duo',
description: 'GitLab Duo with OAuth or PAT auth',
oauthFlow: 'authorization_code',
callbackPort: 17171,
callbackProviderName: 'gitlab',
authUrlProviderName: 'gitlab',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['gitlab-'],
tokenTypeValues: ['gitlab'],
aliases: ['gitlab-duo'],
},
codebuddy: {
displayName: 'CodeBuddy (Tencent)',
description: 'Tencent CodeBuddy AI assistant',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'codebuddy',
authUrlProviderName: 'codebuddy',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['codebuddy-'],
tokenTypeValues: ['codebuddy'],
aliases: ['tencent'],
},
kilo: {
displayName: 'Kilo AI',
description: 'Kilo AI coding assistant',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'kilo',
authUrlProviderName: 'kilo',
refreshOwnership: 'unsupported',
authFilePrefixes: ['kilo-'],
tokenTypeValues: ['kilo'],
aliases: [],
},
};
export const CLIPROXY_PROVIDER_IDS = Object.freeze(
@@ -1,7 +1,7 @@
/**
* Claude Quota Response Normalization Helpers
*
* Parses Anthropic policy limits payload into normalized windows and core usage summary.
* Parses Anthropic policy-limits or OAuth-usage payloads into normalized windows and core usage summary.
*/
import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types';
@@ -74,7 +74,14 @@ function getClaudeWindowLabel(rateLimitType: string): string {
case 'overage':
return 'Extra usage';
default:
return rateLimitType || 'Unknown limit';
if (!rateLimitType) return 'Unknown limit';
return rateLimitType
.split(/[_-]+/g)
.filter((part) => part.length > 0)
.map((part) =>
/^\d+$/.test(part) ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`
)
.join(' ');
}
}
@@ -185,8 +192,17 @@ function normalizeRestriction(
/**
* Parse raw policy limits response into normalized windows.
* Supports both array and object-map `restrictions` shapes.
* Supports both policy-limits `restrictions` payloads and OAuth usage payloads
* keyed by window name (`five_hour`, `seven_day`, `seven_day_sonnet`, ...).
*/
function isClaudeOAuthUsageWindowCandidate(key: string, raw: Record<string, unknown>): boolean {
if (key === 'extra_usage') return false;
if (asNumber(raw['utilization']) === null) return false;
const resetAt = raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at'] ?? null;
return resetAt !== null && resetAt !== undefined;
}
export function buildClaudeQuotaWindows(payload: Record<string, unknown>): ClaudeQuotaWindow[] {
const rawRestrictions = payload['restrictions'];
const windows: ClaudeQuotaWindow[] = [];
@@ -206,9 +222,19 @@ export function buildClaudeQuotaWindows(payload: Record<string, unknown>): Claud
if (window) windows.push(window);
}
} else if (toObject(payload)) {
for (const [key, value] of Object.entries(payload)) {
const raw = toObject(value);
if (!raw) continue;
if (!isClaudeOAuthUsageWindowCandidate(key, raw)) continue;
const window = normalizeRestriction(raw, key);
if (window) windows.push(window);
}
// Some responses may contain a single restriction object directly.
const direct = normalizeRestriction(payload);
if (direct) windows.push(direct);
if (windows.length === 0) {
const direct = normalizeRestriction(payload);
if (direct) windows.push(direct);
}
}
const seen = new Set<string>();
+21 -35
View File
@@ -1,7 +1,7 @@
/**
* Quota Fetcher for Claude (Anthropic) Accounts
*
* Fetches policy limits from Claude API and normalizes 5h + weekly windows.
* Fetches OAuth usage windows from Claude API and normalizes 5h + weekly windows.
*/
import * as path from 'node:path';
@@ -17,11 +17,10 @@ import {
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
export const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits';
export const CLAUDE_OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
const CLAUDE_QUOTA_TIMEOUT_MS = 10000;
const CLAUDE_QUOTA_MAX_ATTEMPTS = 2;
const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota';
const CLAUDE_OAUTH_UNSUPPORTED_MESSAGE = 'oauth authentication is currently not supported';
const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20';
interface ClaudeAuthData {
accessToken: string;
@@ -193,16 +192,6 @@ function buildEmptyResult(
};
}
function buildPolicyUnavailableResult(accountId: string): ClaudeQuotaResult {
return {
success: true,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
lastUpdated: Date.now(),
accountId,
};
}
/**
* Fetch quota for a single Claude account.
*/
@@ -230,46 +219,43 @@ export async function fetchClaudeQuota(
const timeoutId = setTimeout(() => controller.abort(), CLAUDE_QUOTA_TIMEOUT_MS);
try {
const response = await fetch(CLAUDE_POLICY_LIMITS_URL, {
const response = await fetch(CLAUDE_OAUTH_USAGE_URL, {
method: 'GET',
signal: controller.signal,
headers: {
Authorization: `Bearer ${authData.accessToken}`,
Accept: 'application/json',
'User-Agent': CLAUDE_USER_AGENT,
'Content-Type': 'application/json',
'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
},
});
clearTimeout(timeoutId);
if (verbose) {
console.error(`[i] Claude policy limits status: ${response.status} (attempt ${attempt})`);
console.error(`[i] Claude OAuth usage status: ${response.status} (attempt ${attempt})`);
}
if (response.status === 401) {
const errorMessage = await readResponseErrorMessage(response);
if (errorMessage && errorMessage.toLowerCase().includes(CLAUDE_OAUTH_UNSUPPORTED_MESSAGE)) {
if (verbose) {
console.error(
'[i] Claude policy limits endpoint does not support OAuth tokens; treating quota as unavailable'
);
}
return buildPolicyUnavailableResult(accountId);
}
return buildEmptyResult('Authentication required for policy limits', accountId, true);
return buildEmptyResult(
errorMessage || 'Authentication required for Claude OAuth usage',
accountId,
true
);
}
if (response.status === 404) {
// Some accounts may not expose policy limits; treat as unavailable but successful.
return buildPolicyUnavailableResult(accountId);
return buildEmptyResult('Claude OAuth usage endpoint not found', accountId);
}
if (response.status === 403) {
return buildEmptyResult('Not authorized for policy limits', accountId);
return buildEmptyResult('Not authorized for Claude OAuth usage', accountId);
}
if (!response.ok) {
lastError = `Policy limits API error: ${response.status}`;
lastError =
(await readResponseErrorMessage(response)) ||
`Claude OAuth usage API error: ${response.status}`;
if (
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
(response.status === 429 || response.status >= 500)
@@ -283,11 +269,11 @@ export async function fetchClaudeQuota(
try {
payload = await response.json();
} catch {
return buildEmptyResult('Invalid policy limits format', accountId);
return buildEmptyResult('Invalid Claude OAuth usage format', accountId);
}
if (!toObject(payload)) {
return buildEmptyResult('Invalid policy limits format', accountId);
return buildEmptyResult('Invalid Claude OAuth usage format', accountId);
}
const windows = buildClaudeQuotaWindows(payload as Record<string, unknown>);
@@ -304,7 +290,7 @@ export async function fetchClaudeQuota(
clearTimeout(timeoutId);
lastError =
error instanceof Error && error.name === 'AbortError'
? 'Policy limits request timeout'
? 'Claude OAuth usage request timeout'
: error instanceof Error
? error.message
: 'Unknown error';
@@ -313,7 +299,7 @@ export async function fetchClaudeQuota(
const errorDetails =
error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error);
console.error(
`[!] Claude policy limits failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
`[!] Claude OAuth usage failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
);
}
+2 -1
View File
@@ -624,11 +624,12 @@ export async function fetchCodexQuota(
// Extract plan type
const planTypeRaw = data.plan_type || data.planType;
let planType: 'free' | 'plus' | 'team' | null = null;
let planType: 'free' | 'plus' | 'pro' | 'team' | null = null;
if (planTypeRaw) {
const normalized = planTypeRaw.toLowerCase();
if (normalized === 'free') planType = 'free';
else if (normalized === 'plus') planType = 'plus';
else if (normalized === 'pro') planType = 'pro';
else if (normalized === 'team') planType = 'team';
}
+345 -103
View File
@@ -10,11 +10,12 @@ import * as path from 'node:path';
import { getAuthDir } from './config-generator';
import { getProviderAccounts, getPausedDir, setAccountTier } from './account-manager';
import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils';
import { refreshGeminiToken } from './auth/gemini-token-refresh';
import {
buildGeminiCliBucketsFromParsedBuckets,
type GeminiCliParsedBucket,
} from './gemini-cli-quota-normalizer';
import { mapExternalProviderName } from './provider-capabilities';
import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver';
import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types';
import {
buildProviderEntitlementEvidence,
@@ -32,6 +33,8 @@ const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERS
const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
const MANAGEMENT_API_TIMEOUT_MS = 5000;
const SECONDARY_REQUEST_TIMEOUT_MS = 2000;
/** Auth data extracted from Gemini CLI auth file */
interface GeminiCliAuthData {
@@ -93,6 +96,44 @@ interface GeminiCliSupplementaryInfo {
normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown';
}
interface ManagementAuthFile {
auth_index?: string | number;
provider?: string;
type?: string;
email?: string;
name?: string;
}
interface ManagementApiCallResponse {
status_code?: number;
body?: string;
}
interface ManagedResponse {
status: number;
bodyText: string;
json: unknown;
viaManagement: boolean;
}
interface ManagedGeminiAuthContext {
authIndexLookupPromise?: Promise<ManagedGeminiAuthLookupResult>;
}
interface ManagedGeminiAuthLookupResult {
authIndex: string | number | null;
unavailable: boolean;
}
interface ManagedGeminiRequestResult {
response: ManagedResponse | null;
unavailable: boolean;
}
function getRemainingTimeoutMs(deadlineMs: number): number {
return Math.max(1, deadlineMs - Date.now());
}
/**
* Extract project ID from account field
* Input: "user@example.com (cloudaicompanion-abc-123)"
@@ -167,6 +208,244 @@ function isGeminiAuthFile(filename: string): boolean {
return false;
}
function safeParseJson(bodyText: string): unknown {
try {
return JSON.parse(bodyText);
} catch {
return null;
}
}
async function readManagedResponse(
response: Response,
viaManagement: boolean
): Promise<ManagedResponse> {
const bodyText = await response.text();
return {
status: response.status,
bodyText,
json: safeParseJson(bodyText),
viaManagement,
};
}
function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
const rawProvider = normalizeStringValue(file.provider ?? file.type);
if (!rawProvider || mapExternalProviderName(rawProvider) !== 'gemini') {
return false;
}
const email = normalizeStringValue(file.email);
const normalizedAccountId = accountId.trim().toLowerCase();
if (email?.toLowerCase() === normalizedAccountId) {
return true;
}
const normalizedName = normalizeStringValue(file.name);
if (!normalizedName) {
return false;
}
const normalizedFileName = normalizedName.toLowerCase();
const sanitizedAccount = sanitizeEmail(accountId).toLowerCase();
return (
normalizedFileName === `gemini-${sanitizedAccount}.json` ||
normalizedFileName.startsWith(`${normalizedAccountId}-gen-lang-client-`) ||
normalizedFileName.includes(sanitizedAccount)
);
}
async function findManagedGeminiAuthIndex(
accountId: string,
timeoutMs: number
): Promise<ManagedGeminiAuthLookupResult> {
const target = getProxyTarget();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), {
signal: controller.signal,
headers: buildManagementHeaders(target),
});
clearTimeout(timeoutId);
if (!response.ok) {
return { authIndex: null, unavailable: true };
}
const data = (await response.json()) as { files?: ManagementAuthFile[] };
const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId));
return { authIndex: match?.auth_index ?? null, unavailable: false };
} catch {
clearTimeout(timeoutId);
return { authIndex: null, unavailable: true };
}
}
async function getManagedGeminiAuthIndex(
accountId: string,
timeoutMs: number,
context?: ManagedGeminiAuthContext
): Promise<ManagedGeminiAuthLookupResult> {
if (!context) {
return await findManagedGeminiAuthIndex(accountId, timeoutMs);
}
context.authIndexLookupPromise ??= findManagedGeminiAuthIndex(accountId, timeoutMs);
return await context.authIndexLookupPromise;
}
class GeminiManagedAuthUnavailableError extends Error {
constructor() {
super('CLIProxy managed Gemini auth is temporarily unavailable');
this.name = 'GeminiManagedAuthUnavailableError';
}
}
async function performManagedGeminiRequest(
accountId: string,
url: string,
body: string,
timeoutMs: number,
authContext?: ManagedGeminiAuthContext
): Promise<ManagedGeminiRequestResult> {
const deadlineMs = Date.now() + timeoutMs;
const lookupResult = await getManagedGeminiAuthIndex(
accountId,
getRemainingTimeoutMs(deadlineMs),
authContext
);
if (lookupResult.unavailable) {
return { response: null, unavailable: true };
}
const authIndex = lookupResult.authIndex;
if (authIndex === null || authIndex === undefined) {
return { response: null, unavailable: false };
}
const target = getProxyTarget();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs));
try {
const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), {
method: 'POST',
signal: controller.signal,
headers: buildManagementHeaders(target, {
'Content-Type': 'application/json',
}),
body: JSON.stringify({
auth_index: authIndex,
method: 'POST',
url,
header: {
Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json',
},
data: body,
}),
});
clearTimeout(timeoutId);
if (!response.ok) {
return { response: null, unavailable: true };
}
const apiResponse = (await response.json()) as ManagementApiCallResponse;
const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : '';
return {
response: {
status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500,
bodyText,
json: safeParseJson(bodyText),
viaManagement: true,
},
unavailable: false,
};
} catch {
clearTimeout(timeoutId);
return { response: null, unavailable: true };
}
}
async function performGeminiCliRequest(
accountId: string,
accessToken: string,
url: string,
body: string,
preferManagement = false,
authContext?: ManagedGeminiAuthContext
): Promise<ManagedResponse> {
let managementAttempted = false;
let managementUnavailable = false;
if (preferManagement) {
managementAttempted = true;
const managedResult = await performManagedGeminiRequest(
accountId,
url,
body,
MANAGEMENT_API_TIMEOUT_MS,
authContext
);
managementUnavailable = managedResult.unavailable;
if (managedResult.response) {
return managedResult.response;
}
}
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
managementAttempted ? SECONDARY_REQUEST_TIMEOUT_MS : MANAGEMENT_API_TIMEOUT_MS
);
try {
const response = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body,
});
clearTimeout(timeoutId);
const directResult = await readManagedResponse(response, false);
if (directResult.status !== 401) {
return directResult;
}
if (managementAttempted) {
if (managementUnavailable) {
throw new GeminiManagedAuthUnavailableError();
}
return directResult;
}
const managedResult = await performManagedGeminiRequest(
accountId,
url,
body,
SECONDARY_REQUEST_TIMEOUT_MS,
authContext
);
if (managedResult.response) {
return managedResult.response;
}
if (managedResult.unavailable) {
throw new GeminiManagedAuthUnavailableError();
}
return directResult;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* Read auth data from Gemini CLI auth file
* Supports multiple file naming conventions and JSON structures
@@ -308,42 +587,43 @@ function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | nu
}
async function fetchGeminiCliSupplementary(
accountId: string,
accessToken: string,
projectId: string,
verbose: boolean
verbose: boolean,
authContext?: ManagedGeminiAuthContext
): Promise<GeminiCliSupplementaryInfo> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const requestBody = JSON.stringify({
cloudaicompanionProject: projectId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: projectId,
},
});
try {
const response = await fetch(GEMINI_CLI_CODE_ASSIST_URL, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
cloudaicompanionProject: projectId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: projectId,
},
}),
});
const response = await performGeminiCliRequest(
accountId,
accessToken,
GEMINI_CLI_CODE_ASSIST_URL,
requestBody,
false,
authContext
);
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status !== 200) {
if (verbose) {
console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`);
const source = response.viaManagement ? 'managed' : 'direct';
console.error(
`[i] Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}`
);
}
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
}
const payload = (await response.json()) as GeminiCliCodeAssistResponse;
const payload = response.json as GeminiCliCodeAssistResponse | null;
return {
tierLabel: resolveGeminiCliTierLabel(payload),
tierId: resolveGeminiCliTierId(payload),
@@ -351,7 +631,6 @@ async function fetchGeminiCliSupplementary(
normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)),
};
} catch (error) {
clearTimeout(timeoutId);
if (verbose) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`);
@@ -680,41 +959,42 @@ async function fetchWithAuthData(
});
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const authContext: ManagedGeminiAuthContext = {};
const supplementaryPromise = fetchGeminiCliSupplementary(
accountId,
authData.accessToken,
authData.projectId,
verbose
verbose,
authContext
);
const requestBody = JSON.stringify({ project: authData.projectId });
try {
const response = await fetch(GEMINI_CLI_QUOTA_URL, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Bearer ${authData.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ project: authData.projectId }),
});
const response = await performGeminiCliRequest(
accountId,
authData.accessToken,
GEMINI_CLI_QUOTA_URL,
requestBody,
authData.isExpired,
authContext
);
clearTimeout(timeoutId);
if (verbose) {
const source = response.viaManagement ? 'managed' : 'direct';
console.error(`[i] Gemini CLI API status via ${source}: ${response.status}`);
}
if (verbose) console.error(`[i] Gemini CLI API status: ${response.status}`);
if (!response.ok) {
const bodyText = await response.text();
if (response.status !== 200) {
return buildGeminiCliHttpFailureResult(
accountId,
authData.projectId,
response.status,
bodyText
response.bodyText
);
}
const data = (await response.json()) as GeminiCliQuotaResponse;
const rawBuckets = data.buckets || [];
const data = response.json as GeminiCliQuotaResponse | null;
const rawBuckets = data?.buckets || [];
const buckets = buildGeminiCliBuckets(rawBuckets);
const supplementary = await supplementaryPromise;
@@ -744,7 +1024,16 @@ async function fetchWithAuthData(
accountId,
};
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof GeminiManagedAuthUnavailableError) {
return buildGeminiCliFailureResult(accountId, authData.projectId, {
error: 'Gemini delegated auth refresh is temporarily unavailable',
errorCode: 'managed_auth_unavailable',
errorDetail: err.message,
actionHint: 'Retry later. CLIProxy management could not refresh this Gemini account.',
retryable: true,
});
}
const errorMsg =
err instanceof Error && err.name === 'AbortError'
? 'Request timeout'
@@ -778,7 +1067,7 @@ export async function fetchGeminiCliQuota(
): Promise<GeminiCliQuotaResult> {
if (verbose) console.error(`[i] Fetching Gemini CLI quota for ${accountId}...`);
let authData = readGeminiCliAuthData(accountId);
const authData = readGeminiCliAuthData(accountId);
if (!authData) {
const error = 'Auth file not found for Gemini account';
if (verbose) console.error(`[!] Error: ${error}`);
@@ -790,62 +1079,15 @@ export async function fetchGeminiCliQuota(
});
}
// Proactive refresh: refresh if expired OR expiring within 5 minutes
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000;
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
const shouldRefresh =
authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS;
let refreshedBeforeQuotaFetch = false;
if (shouldRefresh) {
if (verbose)
console.error(
authData.isExpired
? '[i] Token expired, refreshing...'
: '[i] Token expiring soon, proactive refresh...'
);
const refreshResult = await refreshGeminiToken(accountId);
if (refreshResult.success) {
refreshedBeforeQuotaFetch = true;
if (verbose) console.error('[i] Token refreshed successfully');
// Re-read auth data after successful refresh
const refreshedAuthData = readGeminiCliAuthData(accountId);
if (refreshedAuthData) {
authData = refreshedAuthData;
}
} else if (authData.isExpired) {
// Only fail if token is actually expired (not just expiring soon)
const error = refreshResult.error || 'Token refresh failed';
if (verbose) console.error(`[!] Refresh failed: ${error}`);
return buildGeminiCliFailureResult(accountId, authData.projectId, {
error,
errorCode: 'reauth_required',
errorDetail: error,
actionHint: 'Run ccs gemini --auth to reconnect this account.',
needsReauth: true,
retryable: false,
});
}
// If proactive refresh fails but token isn't expired yet, continue with existing token
if (authData.isExpired && verbose) {
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown';
console.error(
`[i] Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.`
);
}
// First attempt with current token
const result = await fetchWithAuthData(authData, accountId, verbose);
// Retry once with an account-scoped refresh when the quota endpoint rejects auth.
if (result.needsReauth && !refreshedBeforeQuotaFetch) {
if (verbose) console.error('[i] Got 401, attempting refresh and retry...');
const refreshResult = await refreshGeminiToken(accountId);
if (refreshResult.success) {
const refreshedAuthData = readGeminiCliAuthData(accountId);
if (refreshedAuthData) {
return await fetchWithAuthData(refreshedAuthData, accountId, verbose);
}
}
}
return result;
return await fetchWithAuthData(authData, accountId, verbose);
}
/**
+2 -2
View File
@@ -74,8 +74,8 @@ export interface CodexQuotaResult extends QuotaErrorMetadata {
windows: CodexQuotaWindow[];
/** Explicit core usage windows (5h + weekly) for easier reset display */
coreUsage?: CodexCoreUsageSummary;
/** Plan type: free, plus, team, or null if unknown */
planType: 'free' | 'plus' | 'team' | null;
/** Plan type: free, plus, pro, team, or null if unknown */
planType: 'free' | 'plus' | 'pro' | 'team' | null;
/** Timestamp of fetch */
lastUpdated: number;
/** Error message if fetch failed */
+4 -1
View File
@@ -216,7 +216,10 @@ export async function ensureCliproxyService(
// 1. Ensure binary exists
let binaryPath: string;
try {
binaryPath = await ensureCLIProxyBinary(verbose);
binaryPath = await ensureCLIProxyBinary(verbose, {
allowInstall: false,
skipAutoUpdate: true,
});
log(`Binary ready: ${binaryPath}`);
} catch (error) {
const err = error as Error;
+23 -4
View File
@@ -50,6 +50,10 @@ export interface BinaryManagerConfig {
verbose: boolean;
/** Force specific version (skip auto-upgrade to latest) */
forceVersion: boolean;
/** Skip background update checks on runtime bootstrap paths */
skipAutoUpdate: boolean;
/** Allow downloading/installing the binary when it is missing */
allowInstall: boolean;
/** Backend variant (original vs plus) */
backend?: CLIProxyBackend;
}
@@ -122,6 +126,10 @@ export interface DownloadResult {
* - ghcp: GitHub Copilot via Device Code (OAuth through CLIProxyAPIPlus)
* - claude: Claude (Anthropic) via OAuth
* - kimi: Kimi (Moonshot AI) via Device Code OAuth
* - cursor: Cursor via PKCE browser polling
* - gitlab: GitLab Duo via OAuth or PAT
* - codebuddy: Tencent CodeBuddy via browser polling
* - kilo: Kilo AI via device flow
*/
export type CLIProxyProvider =
| 'gemini'
@@ -132,12 +140,16 @@ export type CLIProxyProvider =
| 'kiro'
| 'ghcp'
| 'claude'
| 'kimi';
| 'kimi'
| 'cursor'
| 'gitlab'
| 'codebuddy'
| 'kilo';
/**
* CLIProxy backend selection
* - original: CLIProxyAPI (no Kiro/ghcp support)
* - plus: CLIProxyAPIPlus (Kiro/ghcp support, default)
* - original: CLIProxyAPI (legacy provider subset)
* - plus: CLIProxyAPIPlus (expanded provider support, default)
*/
export type CLIProxyBackend = 'original' | 'plus';
@@ -149,7 +161,14 @@ export type CliproxyRoutingStrategy = 'round-robin' | 'fill-first';
/**
* Providers that require CLIProxyAPIPlus backend
*/
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = ['kiro', 'ghcp'];
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = [
'kiro',
'ghcp',
'cursor',
'gitlab',
'codebuddy',
'kilo',
];
/**
* CLIProxy config.yaml structure (minimal)
+149
View File
@@ -0,0 +1,149 @@
import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser';
import { getNodePlatformKey } from '../utils/browser/platform';
import { color, dim, header, initUI, subheader } from '../utils/ui';
type HelpWriter = (line: string) => void;
function summarizeBrowserHealth(status: BrowserStatusPayload): {
label: 'ready' | 'partial' | 'action required';
exitCode: 0 | 1;
} {
const claudeNeedsAttention = status.claude.enabled && status.claude.state !== 'ready';
if (claudeNeedsAttention) {
return { label: 'action required', exitCode: 1 };
}
if (status.codex.enabled && status.codex.state !== 'enabled') {
return { label: 'partial', exitCode: 0 };
}
return { label: 'ready', exitCode: 0 };
}
function writeCommandTable(writeLine: HelpWriter): void {
writeLine(subheader('Commands'));
writeLine(
` ${color('ccs browser status', 'command')} Show Claude attach and Codex browser readiness`
);
writeLine(
` ${color('ccs browser doctor', 'command')} Explain what is missing and how to fix it`
);
writeLine('');
}
function writeIntro(writeLine: HelpWriter): void {
writeLine(' Claude Browser Attach reuses a local Chrome session for Claude-target launches.');
writeLine(
' Codex Browser Tools inject managed Playwright MCP overrides into Codex-target launches.'
);
writeLine('');
}
function writeClaudeStatus(
status: BrowserStatusPayload['claude'],
writeLine: HelpWriter,
includeLaunchGuidance: boolean
): void {
writeLine(subheader('Claude Browser Attach'));
writeLine(` State: ${status.state}`);
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`);
writeLine(` User data dir: ${status.effectiveUserDataDir}`);
writeLine(` DevTools port: ${status.devtoolsPort}`);
writeLine(` Managed MCP: ${status.managedMcpServerName}`);
writeLine(` Managed path: ${status.managedMcpServerPath}`);
if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) {
writeLine(` DevTools endpoint: ${status.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL}`);
}
writeLine(` Detail: ${status.detail}`);
writeLine(` Next step: ${status.nextStep}`);
if (includeLaunchGuidance && status.enabled && status.state !== 'ready') {
const platform = getNodePlatformKey();
writeLine(` Launch command (${platform}): ${status.launchCommands[platform]}`);
}
writeLine('');
}
function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: HelpWriter): void {
writeLine(subheader('Codex Browser Tools'));
writeLine(` State: ${status.state}`);
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
writeLine(` Managed server: ${status.serverName}`);
writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`);
writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`);
if (status.version) {
writeLine(` Codex version: ${status.version}`);
}
writeLine(` Detail: ${status.detail}`);
writeLine(` Next step: ${status.nextStep}`);
writeLine('');
}
export async function showBrowserHelp(writeLine: HelpWriter = console.log): Promise<void> {
await initUI();
writeLine(header('CCS Browser Help'));
writeLine('');
writeIntro(writeLine);
writeLine(subheader('Usage'));
writeLine(` ${color('ccs browser <status|doctor>', 'command')}`);
writeLine(` ${color('ccs help browser', 'command')}`);
writeLine('');
writeCommandTable(writeLine);
writeLine(subheader('What Each Lane Does'));
writeLine(' Claude Browser Attach expects a Chrome user-data dir and remote debugging port.');
writeLine(' Codex Browser Tools depend on a Codex build that supports --config overrides.');
writeLine('');
writeLine(subheader('Examples'));
writeLine(` ${color('ccs browser status', 'command')} ${dim('# Quick readiness snapshot')}`);
writeLine(
` ${color('ccs browser doctor', 'command')} ${dim('# Detailed troubleshooting output')}`
);
writeLine(
` ${color('ccs config', 'command')} ${dim('# Open Settings > Browser in the dashboard')}`
);
writeLine('');
}
export async function handleBrowserCommand(
args: string[],
writeLine: HelpWriter = console.log
): Promise<void> {
const subcommand = args[0];
if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
await showBrowserHelp(writeLine);
return;
}
if (subcommand !== 'status' && subcommand !== 'doctor') {
await initUI();
writeLine(color(`Unknown browser subcommand: ${subcommand}`, 'error'));
writeLine('');
writeLine(` ${dim('Supported subcommands: status, doctor')}`);
writeLine('');
process.exitCode = 1;
return;
}
await initUI();
const status = await getBrowserStatus();
writeLine(header(`ccs browser ${subcommand}`));
writeLine('');
writeIntro(writeLine);
if (subcommand === 'doctor') {
const summary = summarizeBrowserHealth(status);
writeLine(subheader('Overall'));
writeLine(` Claude Browser Attach: ${status.claude.title}`);
writeLine(` Codex Browser Tools: ${status.codex.title}`);
writeLine(` Result: ${summary.label}`);
writeLine('');
}
writeClaudeStatus(status.claude, writeLine, subcommand === 'doctor');
writeCodexStatus(status.codex, writeLine);
if (subcommand === 'doctor') {
process.exitCode = summarizeBrowserHealth(status).exitCode;
}
}
+35 -4
View File
@@ -1,8 +1,13 @@
import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
import { CURSOR_SUBCOMMANDS } from '../cursor/constants';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets';
export type HelpTopicName =
| 'profiles'
| 'providers'
| 'kiro'
| 'browser'
| 'completion'
| 'targets';
export interface HelpTopicEntry {
name: HelpTopicName;
@@ -26,6 +31,7 @@ export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [
{ name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' },
{ name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' },
{ name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' },
{ name: 'browser', summary: 'Claude Browser Attach and Codex Browser Tools guidance' },
{ name: 'completion', summary: 'Shell completion install, refresh, and testing' },
{ name: 'targets', summary: 'Claude, Droid, and Codex target routing' },
] as const;
@@ -105,7 +111,19 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
},
{
name: 'cursor',
summary: 'Run or manage the Cursor bridge',
summary: 'Run Cursor via CLIProxy or manage Cursor provider auth',
group: 'runtime',
visibility: 'public',
},
{
name: 'proxy',
summary: 'Start or inspect the OpenAI-compatible local proxy',
group: 'runtime',
visibility: 'public',
},
{
name: 'browser',
summary: 'Inspect Claude Browser Attach and Codex Browser Tools readiness',
group: 'runtime',
visibility: 'public',
},
@@ -183,6 +201,10 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
ghcp: 'GitHub Copilot via CLIProxy OAuth',
claude: 'Claude via CLIProxy OAuth',
kimi: 'Kimi via CLIProxy OAuth',
cursor: 'Cursor via CLIProxy OAuth',
gitlab: 'GitLab Duo via CLIProxy OAuth',
codebuddy: 'CodeBuddy via CLIProxy OAuth',
kilo: 'Kilo AI via CLIProxy OAuth',
}[name] || 'CLIProxy OAuth provider',
})
);
@@ -252,6 +274,7 @@ export const CLIPROXY_SUBCOMMANDS = [
] as const;
export const CONFIG_SUBCOMMANDS = ['auth', 'channels', 'image-analysis', 'thinking'] as const;
export const DOCKER_SUBCOMMANDS = ['up', 'down', 'status', 'update', 'logs', 'config'] as const;
export const PROXY_SUBCOMMANDS = ['start', 'stop', 'status', 'activate'] as const;
export const TOKENS_FLAGS = [
'--show',
'--api-key',
@@ -297,6 +320,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'],
cursor: ['--help', '-h'],
doctor: ['--fix', '-f', '--help', '-h'],
browser: ['status', 'doctor', '--help', '-h'],
docker: ['--help', '-h', '--host'],
env: ['--format', '--shell', '--ide', '--help', '-h'],
migrate: MIGRATE_FLAGS,
@@ -304,7 +328,14 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
update: ['--force', '--beta', '--dev', '--help', '-h'],
};
export const CURSOR_COMPLETION_SUBCOMMANDS = [...CURSOR_SUBCOMMANDS] as const;
export const CURSOR_COMPLETION_SUBCOMMANDS = [
'--auth',
'--accounts',
'--config',
'--logout',
'--help',
'-h',
] as const;
export const COPILOT_COMPLETION_SUBCOMMANDS = [...COPILOT_SUBCOMMANDS, 'help'] as const;
export function getPublicRootCommands(): readonly RootCommandEntry[] {
+8
View File
@@ -11,6 +11,7 @@ import {
ROOT_COMMAND_FLAGS,
ROOT_HELP_TOPICS,
TOKENS_FLAGS,
PROXY_SUBCOMMANDS,
PROVIDER_FLAGS,
uniqueStrings,
getPublicRootCommandTokens,
@@ -189,6 +190,13 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker);
case 'cursor':
return completeSubcommands(CURSOR_COMPLETION_SUBCOMMANDS);
case 'proxy':
if (lastToken === '--shell')
return completeSubcommands(['auto', 'bash', 'zsh', 'fish', 'powershell']);
return completeSubcommands(
[...PROXY_SUBCOMMANDS],
['--port', '--shell', '--insecure', '--help', '-h']
);
case 'copilot':
return completeSubcommands(COPILOT_COMPLETION_SUBCOMMANDS);
case 'env':
+46 -30
View File
@@ -4,6 +4,9 @@ import type { CursorConfig } from '../config/unified-config-types';
import { getCcsDirDisplay } from '../utils/config-manager';
import { color } from '../utils/ui';
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
function printLines(lines: string[]): void {
for (const line of lines) {
console.log(line);
@@ -12,37 +15,46 @@ function printLines(lines: string[]): void {
export function renderCursorHelp(): number {
printLines([
'Cursor IDE Integration',
'Legacy Cursor Compatibility',
'',
'Usage: ccs cursor <subcommand>',
'Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.',
`Supported auth path: ${CLIPROXY_CURSOR_COMMAND} --auth`,
'Supported dashboard path: ccs config -> CLIProxy -> Cursor',
'',
'Subcommands:',
' auth Import Cursor IDE authentication token',
' status Show integration, authentication, and daemon status',
`Usage: ${LEGACY_CURSOR_COMMAND} <subcommand>`,
'',
'Subcommands (deprecated compatibility for the local reverse-engineered bridge):',
' auth Import Cursor IDE authentication token (deprecated)',
' status Show legacy integration, authentication, and daemon status',
' probe Run a live authenticated runtime probe',
' 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',
' start Start local cursor daemon',
' stop Stop local cursor daemon',
' enable Enable legacy cursor integration in unified config',
' disable Disable legacy cursor integration in unified config',
' help Show this help message',
'',
'Runtime entry:',
' ccs cursor [claude args] # Run Claude via the local Cursor proxy',
'Supported CLIProxy path:',
` ${CLIPROXY_CURSOR_COMMAND} --auth # Authenticate Cursor via CLIProxy`,
` ${CLIPROXY_CURSOR_COMMAND} --accounts # Manage CLIProxy Cursor accounts`,
` ${CLIPROXY_CURSOR_COMMAND} --config # Open CLIProxy Cursor settings`,
'',
'Auth options:',
' ccs cursor auth # Auto-detect from Cursor SQLite',
' ccs cursor auth --manual --token <t> --machine-id <id>',
'Legacy runtime entry (deprecated compatibility):',
` ${LEGACY_CURSOR_COMMAND} [claude args] # Run Claude via the local Cursor bridge`,
'',
'Quick start:',
' 1. ccs cursor enable # Enable integration',
' 2. ccs cursor auth # Import Cursor IDE token',
' 3. ccs cursor start # Start daemon',
' 4. ccs cursor probe # Verify live runtime health',
' 5. ccs cursor "task" # Run Claude through Cursor',
' 6. ccs cursor status # Inspect auth/daemon wiring',
'Legacy auth options:',
` ${LEGACY_CURSOR_COMMAND} auth # Auto-detect from Cursor SQLite (deprecated)`,
` ${LEGACY_CURSOR_COMMAND} auth --manual --token <t> --machine-id <id>`,
'',
'Or use the web UI: ccs config -> Cursor page',
'Legacy bridge quick start:',
` 1. ${LEGACY_CURSOR_COMMAND} enable # Deprecated compatibility: enable local bridge`,
` 2. ${LEGACY_CURSOR_COMMAND} auth # Deprecated compatibility: import Cursor IDE token`,
` 3. ${LEGACY_CURSOR_COMMAND} start # Start local daemon`,
` 4. ${LEGACY_CURSOR_COMMAND} probe # Verify live runtime health`,
` 5. ${LEGACY_CURSOR_COMMAND} "task" # Run Claude through the local bridge`,
` 6. ${LEGACY_CURSOR_COMMAND} status # Inspect auth/daemon wiring`,
'',
'Web UI: ccs config -> Deprecated -> Cursor IDE',
'',
]);
@@ -100,10 +112,13 @@ export function renderCursorStatus(
console.log('');
console.log('Client setup:');
console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`);
console.log(' Runtime entry: ccs cursor [claude args]');
console.log(' Live probe: ccs cursor probe');
console.log(' Status command: ccs cursor status');
console.log(' Help command: ccs cursor help');
console.log(
` Runtime entry: ${LEGACY_CURSOR_COMMAND} [claude args] (deprecated compatibility)`
);
console.log(` Supported auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` Live probe: ${LEGACY_CURSOR_COMMAND} probe`);
console.log(` Status command: ${LEGACY_CURSOR_COMMAND} status`);
console.log(` Help command: ${LEGACY_CURSOR_COMMAND} help`);
if (isReady) {
return;
@@ -113,15 +128,16 @@ export function renderCursorStatus(
console.log('Next steps:');
if (!cursorConfig.enabled) {
console.log(' - Enable: ccs cursor enable');
console.log(` - Enable: ${LEGACY_CURSOR_COMMAND} enable`);
}
if (!authStatus.authenticated || authStatus.expired) {
console.log(' - Auth: ccs cursor auth');
console.log(` - Supported: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` - Legacy auth: ${LEGACY_CURSOR_COMMAND} auth`);
}
if (!daemonStatus.running) {
console.log(' - Start: ccs cursor start');
console.log(` - Start: ${LEGACY_CURSOR_COMMAND} start`);
}
console.log(' - Help: ccs cursor help');
console.log(` - Help: ${LEGACY_CURSOR_COMMAND} help`);
}
export function renderCursorModels(models: CursorModel[], defaultModel: string): void {
+36 -13
View File
@@ -26,6 +26,18 @@ import {
} from './cursor-command-display';
import { ok, fail, info } from '../utils/ui';
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
function printLegacyCursorDeprecationNotice(): void {
console.log(
info(
`Deprecated compatibility path. \`${CLIPROXY_CURSOR_COMMAND}\` now belongs to the CLIProxy Cursor provider; use \`${LEGACY_CURSOR_COMMAND}\` for the old bridge.`
)
);
console.log('');
}
/**
* Handle cursor subcommand.
*/
@@ -114,6 +126,7 @@ function printAutoDetectFailure(result: {
* Handle auth subcommand.
*/
async function handleAuth(args: string[]): Promise<number> {
printLegacyCursorDeprecationNotice();
const manual = args.includes('--manual');
if (manual) {
@@ -125,7 +138,7 @@ async function handleAuth(args: string[]): Promise<number> {
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>'
`Manual auth requires both token and machine ID.\n\nExample:\n ${LEGACY_CURSOR_COMMAND} auth --manual --token <token> --machine-id <machine-id>`
)
);
return 1;
@@ -146,8 +159,9 @@ async function handleAuth(args: string[]): Promise<number> {
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');
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`);
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
return 0;
}
@@ -168,9 +182,10 @@ async function handleAuth(args: string[]): Promise<number> {
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');
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`);
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`);
return 0;
}
@@ -178,13 +193,14 @@ async function handleAuth(args: string[]): Promise<number> {
printAutoDetectFailure(autoResult);
console.log('');
console.log('Manual fallback:');
console.log(' ccs cursor auth --manual --token <token> --machine-id <machine-id>');
console.log(` ${LEGACY_CURSOR_COMMAND} auth --manual --token <token> --machine-id <machine-id>`);
console.log('');
return 1;
}
async function handleStatus(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
const authStatus = checkAuthStatus();
const daemonStatus = await getDaemonStatus(cursorConfig.port);
@@ -193,6 +209,7 @@ async function handleStatus(): Promise<number> {
}
async function handleProbe(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
const result = await probeCursorRuntime(cursorConfig);
renderCursorProbe(result);
@@ -200,6 +217,7 @@ async function handleProbe(): Promise<number> {
}
async function handleModels(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
const models = await getAvailableModels(cursorConfig.port);
const defaultModel = getDefaultModel();
@@ -211,20 +229,21 @@ async function handleModels(): Promise<number> {
* Handle start subcommand.
*/
async function handleStart(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
if (!cursorConfig.enabled) {
console.error(fail('Cursor integration is disabled. Run: ccs cursor enable'));
console.error(fail(`Cursor integration is disabled. Run: ${LEGACY_CURSOR_COMMAND} enable`));
return 1;
}
const authStatus = checkAuthStatus();
if (!authStatus.authenticated) {
console.error(fail('Not authenticated. Run: ccs cursor auth'));
console.error(fail(`Not authenticated. Run: ${LEGACY_CURSOR_COMMAND} auth`));
return 1;
}
if (authStatus.expired) {
console.error(fail('Credentials expired. Run: ccs cursor auth'));
console.error(fail(`Credentials expired. Run: ${LEGACY_CURSOR_COMMAND} auth`));
return 1;
}
@@ -248,6 +267,7 @@ async function handleStart(): Promise<number> {
* Handle stop subcommand.
*/
async function handleStop(): Promise<number> {
printLegacyCursorDeprecationNotice();
console.log(info('Stopping cursor daemon...'));
const result = await stopDaemon();
@@ -265,6 +285,7 @@ async function handleStop(): Promise<number> {
* Handle enable subcommand.
*/
async function handleEnable(): Promise<number> {
printLegacyCursorDeprecationNotice();
mutateUnifiedConfig((config) => {
if (!config.cursor) {
config.cursor = { ...DEFAULT_CURSOR_CONFIG };
@@ -276,9 +297,10 @@ async function handleEnable(): Promise<number> {
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');
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` 1. Authenticate: ${LEGACY_CURSOR_COMMAND} auth`);
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`);
return 0;
}
@@ -287,6 +309,7 @@ async function handleEnable(): Promise<number> {
* Handle disable subcommand.
*/
async function handleDisable(): Promise<number> {
printLegacyCursorDeprecationNotice();
mutateUnifiedConfig((config) => {
if (config.cursor) {
config.cursor.enabled = false;
+82 -3
View File
@@ -1,4 +1,5 @@
import packageJson from '../../package.json';
import type { CLIProxyProvider } from '../cliproxy';
import { color, dim, header, initUI, subheader } from '../utils/ui';
import {
BUILTIN_PROVIDER_SHORTCUTS,
@@ -82,10 +83,80 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
],
writeLine
);
writeCommandTable(
'GitLab Duo Flags',
[
{
name: 'ccs gitlab --auth --gitlab-token-login',
summary: 'Authenticate with a GitLab Personal Access Token',
},
{
name: 'ccs gitlab --auth --token-login',
summary: 'Legacy alias for GitLab PAT login (still supported)',
},
{
name: 'ccs gitlab --auth --gitlab-url <url>',
summary: 'Use a self-hosted GitLab base URL during OAuth or PAT auth',
},
],
writeLine
);
writeLine(` ${dim('Deep help: ccs cliproxy --help | ccs api --help')}`);
writeLine('');
}
export async function showProviderShortcutHelp(
provider: CLIProxyProvider,
writeLine: HelpWriter = console.log
): Promise<void> {
if (provider === 'kiro') {
await showKiroHelp(writeLine);
return;
}
await initUI();
const providerEntry = BUILTIN_PROVIDER_SHORTCUTS.find((entry) => entry.name === provider);
writeLine(header(`CCS ${provider} Shortcut Help`));
writeLine('');
writeLine(` ${providerEntry?.summary || 'CLIProxy OAuth provider shortcut'}.`);
writeLine('');
writeCommandTable(
'Common Commands',
[
{ name: `ccs ${provider} --auth`, summary: 'Authenticate the provider account via CLIProxy' },
{ name: `ccs ${provider} --accounts`, summary: 'List or manage stored CLIProxy accounts' },
{ name: `ccs ${provider} --config`, summary: 'Open the provider config flow' },
{ name: `ccs ${provider} "task"`, summary: 'Run Claude through this provider shortcut' },
],
writeLine
);
if (provider === 'gitlab') {
writeCommandTable(
'GitLab Duo Flags',
[
{
name: '--gitlab-token-login',
summary: 'Use a GitLab Personal Access Token instead of browser OAuth',
},
{
name: '--token-login',
summary: 'Legacy alias for `--gitlab-token-login`',
},
{
name: '--gitlab-url <url>',
summary: 'Target a self-hosted GitLab base URL',
},
],
writeLine
);
}
writeLine(` ${dim('See also: ccs help providers | ccs cliproxy --help')}`);
writeLine('');
}
async function showKiroHelp(writeLine: HelpWriter): Promise<void> {
await initUI();
writeLine(header('CCS Kiro Help'));
@@ -178,7 +249,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
writeLine(header(`CCS CLI v${packageJson.version}`));
writeLine('');
writeLine(' Claude profile switching, provider routing, and compatible runtime bridges.');
writeLine(' Claude profile switching, provider routing, runtime bridges, and browser tooling.');
writeLine('');
writeLine(subheader('Usage'));
@@ -208,6 +279,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
[
{ name: 'ccs help profiles', summary: getTopicSummary('profiles') },
{ name: 'ccs help providers', summary: getTopicSummary('providers') },
{ name: 'ccs help browser', summary: getTopicSummary('browser') },
{ name: 'ccs help completion', summary: getTopicSummary('completion') },
{ name: 'ccs help targets', summary: getTopicSummary('targets') },
{ name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' },
@@ -215,6 +287,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
name: 'ccs cliproxy --help',
summary: 'Deep help for variants, routing, quota, and lifecycle',
},
{ name: 'ccs proxy --help', summary: 'Deep help for the OpenAI-compatible local proxy' },
{ name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' },
{ name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' },
{ name: 'ccs copilot --help', summary: 'Deep help for GitHub Copilot commands' },
@@ -256,6 +329,10 @@ export async function handleHelpRoute(
await showTargetsHelp(writeLine);
return;
}
if (topic === 'browser') {
await (await import('./browser-command')).showBrowserHelp(writeLine);
return;
}
if (topic === 'completion') {
const { showShellCompletionHelp } = await import('./shell-completion-command');
showShellCompletionHelp(writeLine);
@@ -270,11 +347,13 @@ export async function handleHelpRoute(
await new AuthCommands().showHelp();
},
cleanup: async () => (await import('./cleanup-command')).handleCleanupCommand(['--help']),
browser: async () => (await import('./browser-command')).showBrowserHelp(writeLine),
cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(),
copilot: async () =>
process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])),
cursor: async () =>
process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])),
cursor: async () => await showProviderShortcutHelp('cursor', writeLine),
proxy: async () =>
process.exit(await (await import('./proxy-command')).handleProxyCommand(['--help'])),
docker: async () => (await import('./docker/help-subcommand')).showHelp(),
migrate: async () => (await import('./migrate-command')).printMigrateHelp(),
setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']),
+1
View File
@@ -13,6 +13,7 @@ export { handleDockerCommand } from './docker-command';
export { handleHelpCommand } from './help-command';
export { handleInstallCommand } from './install-command';
export { handleMigrateCommand } from './migrate-command';
export { handleProxyCommand } from './proxy-command';
export { handleShellCompletionCommand } from './shell-completion-command';
export { handleSyncCommand } from './sync-command';
export { handleUpdateCommand } from './update-command';
+182
View File
@@ -0,0 +1,182 @@
import { detectShell, formatExportLine } from './env-command';
import { getSettingsPath, loadSettings } from '../utils/config-manager';
import { expandPath } from '../utils/helpers';
import { fail, info, ok } from '../utils/ui';
import {
buildOpenAICompatProxyEnv,
getOpenAICompatProxyStatus,
resolveOpenAICompatProfileConfig,
startOpenAICompatProxy,
stopOpenAICompatProxy,
} from '../proxy';
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));
return withEquals ? withEquals.slice(prefix.length) : undefined;
}
function showHelp(): number {
console.log('OpenAI-Compatible Proxy');
console.log('');
console.log('Usage: ccs proxy <start|stop|status|activate> [profile] [options]');
console.log('');
console.log('Commands:');
console.log(
' start <profile> Start the local proxy for an OpenAI-compatible settings profile'
);
console.log(' stop Stop the running proxy');
console.log(' status Show daemon status and active profile');
console.log(' activate Print shell exports for the running proxy');
console.log('');
console.log('Options:');
console.log(' --port <n> Override the local proxy port (default: 3456)');
console.log(' --host <addr> Bind the proxy server to a specific host (default: 127.0.0.1)');
console.log(' --shell <name> activate only: auto|bash|zsh|fish|powershell');
console.log(' --fish activate only: shorthand for --shell fish');
console.log(' --insecure Disable upstream TLS verification');
console.log('');
console.log('Examples:');
console.log(' ccs proxy start hf');
console.log(' eval "$(ccs proxy activate)"');
console.log(' ccs proxy activate --fish');
console.log(' ccs proxy status');
console.log(' ccs proxy stop');
console.log('');
return 0;
}
function resolveProfile(profileName: string) {
const settingsPath = expandPath(getSettingsPath(profileName));
const settings = loadSettings(settingsPath);
const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, settings.env || {});
if (!profile) {
throw new Error(`Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`);
}
return profile;
}
async function handleStart(args: string[]): Promise<number> {
const profileName = args.find((arg) => !arg.startsWith('-'));
if (!profileName) {
console.error(
fail('Usage: ccs proxy start <profile> [--port <n>] [--host <addr>] [--insecure]')
);
return 1;
}
const portValue = parseOptionValue(args, '--port');
const host = parseOptionValue(args, '--host');
const port = portValue ? Number.parseInt(portValue, 10) || 3456 : undefined;
let profile;
try {
profile = resolveProfile(profileName);
} catch (error) {
console.error(fail((error as Error).message));
return 1;
}
const result = await startOpenAICompatProxy(profile, {
...(port ? { port } : {}),
...(host ? { host } : {}),
insecure: args.includes('--insecure'),
});
if (!result.success) {
console.error(fail(result.error || 'Failed to start proxy'));
return 1;
}
console.log(
result.alreadyRunning
? info(`Proxy already running on port ${result.port}`)
: ok(`Proxy started on port ${result.port}`)
);
return 0;
}
async function handleStatus(): Promise<number> {
const status = await getOpenAICompatProxyStatus();
if (!status.running) {
console.log(info('Proxy is not running'));
return 0;
}
console.log(ok(`Proxy running on port ${status.port}`));
if (status.host) {
console.log(` Host: ${status.host}`);
console.log(` Local URL: http://${status.host}:${status.port}`);
}
console.log(` Profile: ${status.profileName}`);
console.log(` Base URL: ${status.baseUrl}`);
if (status.model) {
console.log(` Model: ${status.model}`);
}
if (status.pid) {
console.log(` PID: ${status.pid}`);
}
return 0;
}
async function handleActivate(args: string[]): Promise<number> {
const status = await getOpenAICompatProxyStatus();
if (!status.running || !status.profileName || !status.port || !status.authToken) {
console.error(fail('Proxy is not running. Start it with: ccs proxy start <profile>'));
return 1;
}
const shell = detectShell(args.includes('--fish') ? 'fish' : parseOptionValue(args, '--shell'));
let profile;
try {
profile = resolveProfile(status.profileName);
} catch (error) {
console.error(fail((error as Error).message));
return 1;
}
const env = buildOpenAICompatProxyEnv(
profile,
status.port,
status.authToken,
undefined,
status.host || '127.0.0.1'
);
Object.entries(env).forEach(([key, value]) => {
console.log(formatExportLine(shell, key, value));
});
return 0;
}
export async function handleProxyCommand(args: string[]): Promise<number> {
const subcommand = args[0];
switch (subcommand) {
case undefined:
case 'help':
case '--help':
case '-h':
return showHelp();
case 'start':
return handleStart(args.slice(1));
case 'stop': {
const result = await stopOpenAICompatProxy();
if (!result.success) {
console.error(fail(result.error || 'Failed to stop proxy'));
return 1;
}
console.log(ok('Proxy stopped'));
return 0;
}
case 'status':
return handleStatus();
case 'activate':
return handleActivate(args.slice(1));
default:
console.error(fail(`Unknown proxy subcommand: ${subcommand}`));
return 1;
}
}
+14
View File
@@ -105,6 +105,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
await handleSyncCommand();
},
},
{
name: 'browser',
handle: async (args) => {
const { handleBrowserCommand } = await import('./browser-command');
await handleBrowserCommand(args);
},
},
{
name: 'cleanup',
aliases: ['--cleanup'],
@@ -136,6 +143,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
await handleCliproxyCommand(args);
},
},
{
name: 'proxy',
handle: async (args) => {
const { handleProxyCommand } = await import('./proxy-command');
process.exit(await handleProxyCommand(args));
},
},
{
name: 'docker',
handle: async (args) => {
+10
View File
@@ -9,14 +9,24 @@ export const RESERVED_PROFILE_NAMES = [
'agy',
'qwen',
'iflow',
'kiro',
'ghcp',
'claude',
'kimi',
'gitlab',
'codebuddy',
'kilo',
// Copilot API (GitHub Copilot proxy)
'copilot',
// Cursor IDE (Cursor proxy daemon)
'cursor',
'legacy-cursor',
'legacy',
// CLI commands and special names
'default',
'config',
'cliproxy',
'proxy',
] as const;
export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number];
+79
View File
@@ -23,6 +23,7 @@ import {
DEFAULT_THINKING_CONFIG,
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_BROWSER_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
DEFAULT_LOGGING_CONFIG,
} from './unified-config-types';
@@ -34,6 +35,7 @@ import type {
OfficialChannelsConfig,
OfficialChannelId,
DashboardAuthConfig,
BrowserConfig,
ImageAnalysisConfig,
LoggingConfig,
CursorConfig,
@@ -46,6 +48,7 @@ import {
normalizeOfficialChannelIds,
resolveLegacyDiscordSelection,
} from '../channels/official-channels-runtime';
import { getRecommendedBrowserUserDataDir } from '../utils/browser/browser-settings';
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
import { normalizeSearxngBaseUrl } from '../utils/websearch/types';
@@ -54,6 +57,32 @@ const CONFIG_JSON = 'config.json';
const CONFIG_LOCK = 'config.yaml.lock';
const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds
function normalizeBrowserDevtoolsPort(value: number | undefined): number {
if (!Number.isFinite(value)) {
return DEFAULT_BROWSER_CONFIG.claude.devtools_port;
}
const port = Math.floor(value as number);
if (port < 1 || port > 65535) {
return DEFAULT_BROWSER_CONFIG.claude.devtools_port;
}
return port;
}
function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig {
return {
claude: {
enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled,
user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(),
devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port),
},
codex: {
enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled,
},
};
}
/**
* Get path to unified config.yaml
*/
@@ -384,6 +413,18 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
: defaults.cliproxy.routing?.strategy,
},
},
proxy: {
routing: {
default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default,
background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background,
think: partial.proxy?.routing?.think ?? defaults.proxy?.routing?.think,
longContext: partial.proxy?.routing?.longContext ?? defaults.proxy?.routing?.longContext,
webSearch: partial.proxy?.routing?.webSearch ?? defaults.proxy?.routing?.webSearch,
longContextThreshold:
partial.proxy?.routing?.longContextThreshold ??
defaults.proxy?.routing?.longContextThreshold,
},
},
logging: {
enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
@@ -580,6 +621,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
partial.dashboard_auth?.session_timeout_hours ??
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
},
browser: canonicalizeBrowserConfig(partial.browser),
// Image analysis config - enabled by default for CLIProxy providers
image_analysis: canonicalizeImageAnalysisConfig({
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
@@ -674,6 +716,17 @@ function generateYamlWithComments(config: UnifiedConfig): string {
);
lines.push('');
if (config.proxy?.routing) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Proxy Routing: OpenAI-compatible local proxy model selection rules');
lines.push('# Use profile:model selectors to force a target profile and upstream model.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ proxy: config.proxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
if (config.logging) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Logging: CCS-owned structured runtime logs');
@@ -893,6 +946,23 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('');
}
// Browser automation section
if (config.browser) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Browser Automation: Claude browser attach and Codex browser tooling');
lines.push('# Claude attach reuses a running Chrome/Chromium session with remote debugging.');
lines.push('# Codex tooling controls whether CCS injects Playwright MCP overrides.');
lines.push('#');
lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the');
lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.');
lines.push('# Configure via: Settings > Browser or `ccs browser ...`.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ browser: config.browser }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// Image analysis section
if (config.image_analysis) {
lines.push('# ----------------------------------------------------------------------------');
@@ -1311,6 +1381,15 @@ export function getDashboardAuthConfig(): DashboardAuthConfig {
};
}
/**
* Get browser automation configuration.
* Returns canonicalized defaults if not configured.
*/
export function getBrowserConfig(): BrowserConfig {
const config = loadOrCreateUnifiedConfig();
return canonicalizeBrowserConfig(config.browser);
}
/**
* Get image_analysis configuration.
* Returns defaults if not configured.
+66
View File
@@ -498,6 +498,19 @@ export interface ProxyLocalConfig {
auto_start: boolean;
}
export interface OpenAICompatProxyRoutingConfig {
default?: string;
background?: string;
think?: string;
longContext?: string;
webSearch?: string;
longContextThreshold?: number;
}
export interface OpenAICompatProxyConfig {
routing?: OpenAICompatProxyRoutingConfig;
}
/**
* CLIProxy server configuration section.
* Controls whether CCS uses local or remote CLIProxyAPI instance.
@@ -799,6 +812,40 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
session_timeout_hours: 24,
};
/**
* Browser automation configuration.
* Controls Claude browser attach and Codex browser tooling.
*/
export interface BrowserClaudeConfig {
/** Enable Claude browser attach (default: false) */
enabled: boolean;
/** Chrome user-data directory used for attach mode */
user_data_dir: string;
/** DevTools port used for attach mode (default: 9222) */
devtools_port: number;
}
export interface BrowserCodexConfig {
/** Enable Codex browser tooling injection (default: true) */
enabled: boolean;
}
export interface BrowserConfig {
claude: BrowserClaudeConfig;
codex: BrowserCodexConfig;
}
export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
claude: {
enabled: false,
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: true,
},
};
/**
* Image analysis configuration.
* Routes image/PDF files through CLIProxy for vision analysis.
@@ -856,6 +903,8 @@ export interface UnifiedConfig {
profiles: Record<string, ProfileConfig>;
/** CLIProxy configuration */
cliproxy: CLIProxyConfig;
/** OpenAI-compatible local proxy configuration */
proxy?: OpenAICompatProxyConfig;
/** CCS-owned structured logging configuration */
logging?: LoggingConfig;
/** User preferences */
@@ -880,6 +929,8 @@ export interface UnifiedConfig {
channels?: OfficialChannelsConfig;
/** Dashboard authentication configuration (optional) */
dashboard_auth?: DashboardAuthConfig;
/** Browser automation configuration */
browser?: BrowserConfig;
/** Image analysis configuration (vision via CLIProxy) */
image_analysis?: ImageAnalysisConfig;
}
@@ -934,6 +985,12 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = {
},
};
export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = {
routing: {
longContextThreshold: 60_000,
},
};
/**
* Create an empty unified config with defaults.
*/
@@ -958,6 +1015,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
strategy: 'round-robin',
},
},
proxy: {
routing: {
...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.routing,
},
},
logging: { ...DEFAULT_LOGGING_CONFIG },
preferences: {
theme: 'system',
@@ -1015,6 +1077,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
thinking: { ...DEFAULT_THINKING_CONFIG },
channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG },
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
browser: {
claude: { ...DEFAULT_BROWSER_CONFIG.claude },
codex: { ...DEFAULT_BROWSER_CONFIG.codex },
},
image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG },
};
}
+29
View File
@@ -1,3 +1,5 @@
export const LEGACY_CURSOR_PROFILE_NAME = 'legacy-cursor';
export const CURSOR_SUBCOMMANDS = [
'auth',
'status',
@@ -12,8 +14,35 @@ export const CURSOR_SUBCOMMANDS = [
'-h',
] as const;
export const CURSOR_CLIPROXY_SHORTCUT_FLAGS = new Set([
'--auth',
'--logout',
'--config',
'--accounts',
]);
export function isCursorSubcommandToken(token?: string): boolean {
return (
Boolean(token) && CURSOR_SUBCOMMANDS.includes(token as (typeof CURSOR_SUBCOMMANDS)[number])
);
}
export function shouldUseCursorCliproxyShortcut(args: string[]): boolean {
if (args[0] !== 'cursor') {
return false;
}
for (const token of args.slice(1)) {
if (token === '--') {
break;
}
if (CURSOR_CLIPROXY_SHORTCUT_FLAGS.has(token)) {
return true;
}
if (!token.startsWith('-')) {
return false;
}
}
return false;
}
+4 -249
View File
@@ -1,249 +1,4 @@
import { DeltaAccumulator } from '../glmt/delta-accumulator';
import { GlmtTransformer } from '../glmt/glmt-transformer';
import { SSEParser } from '../glmt/sse-parser';
import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline';
const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response';
const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response';
type ResponseHeaders = Headers | Record<string, string> | Array<[string, string]>;
interface AnthropicErrorPayload {
type: 'error';
error: {
type: string;
message: string;
};
}
function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload {
return {
type: 'error',
error: {
type,
message,
},
};
}
function formatErrorForLog(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
function logTranslationError(context: string, error: unknown): void {
console.error(`[cursor-anthropic-response] ${context}: ${formatErrorForLog(error)}`);
}
export function createAnthropicErrorResponse(
status: number,
type: string,
message: string,
headers?: ResponseHeaders
): Response {
const responseHeaders = new Headers(headers);
responseHeaders.set('Content-Type', 'application/json');
responseHeaders.delete('Content-Length');
return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), {
status,
headers: responseHeaders,
});
}
function formatSseEvent(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
function hasTranslatableChoices(value: unknown): value is OpenAIResponse {
if (typeof value !== 'object' || value === null) {
return false;
}
const { choices } = value as OpenAIResponse;
if (!Array.isArray(choices) || choices.length === 0) {
return false;
}
const firstChoice = choices[0];
if (typeof firstChoice !== 'object' || firstChoice === null) {
return false;
}
const message = (firstChoice as { message?: unknown }).message;
return typeof message === 'object' && message !== null;
}
function isSyntheticTransformationFallback(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { id?: unknown }).id === 'string' &&
(value as { id: string }).id.startsWith('msg_error_')
);
}
async function createAnthropicErrorProxyResponse(response: Response): Promise<Response> {
const headers = new Headers(response.headers);
headers.delete('Content-Type');
headers.delete('Content-Length');
let type =
response.status === 401
? 'authentication_error'
: response.status === 429
? 'rate_limit_error'
: response.status >= 400 && response.status < 500
? 'invalid_request_error'
: 'api_error';
let message = `Cursor request failed with status ${response.status}`;
try {
const contentType = (response.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('application/json')) {
const payload = (await response.json()) as {
error?: { type?: string; message?: string };
message?: string;
};
if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) {
type = payload.error.type;
}
if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) {
message = payload.error.message;
} else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) {
message = payload.message;
}
} else {
const text = (await response.text()).trim();
if (text.length > 0) {
message = text;
}
}
} catch (error) {
logTranslationError('Failed to parse Cursor error response', error);
}
return createAnthropicErrorResponse(response.status, type, message, headers);
}
async function createAnthropicJsonResponse(response: Response): Promise<Response> {
try {
const openAiResponse = await response.json();
if (!hasTranslatableChoices(openAiResponse)) {
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
}
const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse);
if (isSyntheticTransformationFallback(anthropicResponse)) {
logTranslationError(
'Cursor JSON translation produced synthetic fallback response',
anthropicResponse
);
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
}
return new Response(JSON.stringify(anthropicResponse), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
logTranslationError('Cursor JSON translation failed', error);
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
}
}
function createAnthropicStreamingResponse(response: Response): Response {
const body = response.body;
if (!body) {
return createAnthropicErrorResponse(
502,
'api_error',
'Cursor stream ended before a response body was available'
);
}
const parser = new SSEParser({ throwOnMalformedJson: true });
const transformer = new GlmtTransformer();
const accumulator = new DeltaAccumulator({});
const encoder = new TextEncoder();
const readable = new ReadableStream<Uint8Array>({
async start(controller) {
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
const events = parser.parse(Buffer.from(value));
events.forEach((event) => {
const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator);
anthropicEvents.forEach((anthropicEvent) => {
controller.enqueue(
encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data))
);
});
});
}
if (!accumulator.isFinalized() && accumulator.isMessageStarted()) {
transformer.finalizeDelta(accumulator).forEach((anthropicEvent) => {
controller.enqueue(
encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data))
);
});
}
} catch (error) {
logTranslationError('Cursor SSE translation failed', error);
controller.enqueue(
encoder.encode(
formatSseEvent(
'error',
createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE)
)
)
);
} finally {
reader.releaseLock();
controller.close();
}
},
});
return new Response(readable, {
status: response.status,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
export async function createAnthropicProxyResponse(response: Response): Promise<Response> {
if (!response.ok) {
return createAnthropicErrorProxyResponse(response);
}
const contentType = (response.headers.get('content-type') || '').toLowerCase();
const isEventStream =
contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;');
return isEventStream
? createAnthropicStreamingResponse(response)
: createAnthropicJsonResponse(response);
}
export {
createAnthropicErrorResponse,
createAnthropicProxyResponse,
} from '../proxy/transformers/sse-stream-transformer';
+2 -2
View File
@@ -248,7 +248,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
const authStatus = checkAuthStatus();
if (!authStatus.authenticated || !authStatus.credentials) {
const message = 'Cursor credentials not found. Run `ccs cursor auth` first.';
const message = 'Cursor credentials not found. Run `ccs legacy cursor auth` first.';
if (isAnthropicRoute) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(401, 'authentication_error', message),
@@ -266,7 +266,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
}
if (authStatus.expired) {
const message = 'Cursor credentials expired. Run `ccs cursor auth` again.';
const message = 'Cursor credentials expired. Run `ccs legacy cursor auth` again.';
if (isAnthropicRoute) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(401, 'authentication_error', message),
+4 -4
View File
@@ -97,7 +97,7 @@ export async function executeCursorProfile(
if (!config.enabled) {
console.error(fail('Cursor integration is not enabled.'));
console.error('');
console.error('Enable it first: ccs cursor enable');
console.error('Enable it first: ccs legacy cursor enable');
return 1;
}
@@ -105,13 +105,13 @@ export async function executeCursorProfile(
if (!authStatus.authenticated) {
console.error(fail('Cursor credentials not found.'));
console.error('');
console.error('Authenticate first: ccs cursor auth');
console.error('Authenticate first: ccs legacy cursor auth');
return 1;
}
if (authStatus.expired) {
console.error(fail('Cursor credentials have expired.'));
console.error('');
console.error('Refresh them with: ccs cursor auth');
console.error('Refresh them with: ccs legacy cursor auth');
return 1;
}
@@ -133,7 +133,7 @@ export async function executeCursorProfile(
console.error(fail('Cursor daemon is not running.'));
console.error('');
console.error('Start the daemon:');
console.error(' ccs cursor start');
console.error(' ccs legacy cursor start');
console.error('Or enable auto_start in the Cursor config section.');
return 1;
}
+3 -3
View File
@@ -120,7 +120,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
stage: 'auth',
status: 401,
duration_ms: Date.now() - startedAt,
message: 'Cursor credentials not found. Run `ccs cursor auth` first.',
message: 'Cursor credentials not found. Run `ccs legacy cursor auth` first.',
error_type: 'authentication_error',
};
}
@@ -131,7 +131,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
stage: 'auth',
status: 401,
duration_ms: Date.now() - startedAt,
message: 'Cursor credentials expired. Run `ccs cursor auth` again.',
message: 'Cursor credentials expired. Run `ccs legacy cursor auth` again.',
error_type: 'authentication_error',
};
}
@@ -168,7 +168,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
status: 503,
duration_ms: Date.now() - startedAt,
message:
'Cursor daemon is not running. Start it with `ccs cursor start` or enable auto_start.',
'Cursor daemon is not running. Start it with `ccs legacy cursor start` or enable auto_start.',
error_type: 'daemon_not_running',
};
}
+56 -46
View File
@@ -49,63 +49,73 @@ export class SSEParser {
* @returns Array of parsed events
*/
parse(chunk: Buffer | string): SSEEvent[] {
this.buffer += chunk.toString();
this.buffer += chunk.toString().replace(/\r\n?/g, '\n');
// C-01 Fix: Prevent unbounded buffer growth (DoS protection)
if (this.buffer.length > this.maxBufferSize) {
throw new Error(`SSE buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`);
}
const lines = this.buffer.split('\n');
// Keep incomplete line in buffer
this.buffer = lines.pop() || '';
const events: SSEEvent[] = [];
let currentEvent: SSEEvent = { event: 'message', data: '' };
const segments = this.buffer.split('\n\n');
this.buffer = segments.pop() || '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent.event = line.substring(7).trim();
} else if (line.startsWith('data: ')) {
const data = line.substring(6);
for (const segment of segments) {
const lines = segment.split('\n');
const currentEvent: SSEEvent = { event: 'message', data: '' };
const dataLines: string[] = [];
if (data === '[DONE]') {
this.eventCount++;
events.push({
event: 'done',
data: null,
index: this.eventCount,
});
currentEvent = { event: 'message', data: '' };
} else {
try {
currentEvent.data = JSON.parse(data);
this.eventCount++;
currentEvent.index = this.eventCount;
events.push({ ...currentEvent });
currentEvent = { event: 'message', data: '' };
} catch (e) {
// H-01 Fix: Log parse errors for debugging
if (typeof console !== 'undefined' && console.error) {
console.error(
'[SSEParser] Malformed JSON event:',
(e as Error).message,
'Data:',
data.substring(0, 100)
);
}
if (this.throwOnMalformedJson) {
throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`);
}
}
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line || line.startsWith(':')) {
continue;
}
if (line.startsWith('event: ')) {
currentEvent.event = line.substring(7).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.substring(5).trimStart());
continue;
}
if (line.startsWith('id: ')) {
currentEvent.id = line.substring(4).trim();
continue;
}
if (line.startsWith('retry: ')) {
currentEvent.retry = parseInt(line.substring(7), 10);
}
}
const data = dataLines.join('\n');
if (!data) {
continue;
}
if (data === '[DONE]') {
this.eventCount++;
events.push({ event: 'done', data: null, index: this.eventCount });
continue;
}
try {
currentEvent.data = JSON.parse(data);
this.eventCount++;
currentEvent.index = this.eventCount;
events.push({ ...currentEvent });
} catch (e) {
if (typeof console !== 'undefined' && console.error) {
console.error(
'[SSEParser] Malformed JSON event:',
(e as Error).message,
'Data:',
data.substring(0, 100)
);
}
if (this.throwOnMalformedJson) {
throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`);
}
} else if (line.startsWith('id: ')) {
currentEvent.id = line.substring(4).trim();
} else if (line.startsWith('retry: ')) {
currentEvent.retry = parseInt(line.substring(7), 10);
}
// Empty lines separate events (already handled by JSON parsing)
}
return events;
+28 -13
View File
@@ -936,6 +936,10 @@ class SharedManager {
}
for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!this.isMarketplaceRegistryEntry(value)) {
continue;
}
merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized;
}
} catch (err) {
@@ -947,22 +951,20 @@ class SharedManager {
const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir);
for (const [name, value] of Object.entries(discoveredEntries)) {
const existing = merged[name];
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
merged[name] = {
...(existing as Record<string, unknown>),
installLocation: value.installLocation,
};
continue;
}
merged[name] = value;
}
// Keep only registry entries that have a physical directory, and update their
// installLocation. Entries only on disk (no registry record) are excluded —
// they lack required schema fields that Claude Code enforces.
for (const name of Object.keys(merged)) {
const entry = merged[name];
if (!(name in discoveredEntries)) {
delete merged[name];
} else if (this.isMarketplaceRegistryEntry(entry)) {
merged[name] = {
...entry,
installLocation: discoveredEntries[name].installLocation,
};
} else {
delete merged[name];
}
}
@@ -984,6 +986,11 @@ class SharedManager {
continue;
}
// Skip hidden dirs and Claude Code rename-dance leftovers (.staging/.bak).
if (this.isTransientMarketplaceDirectory(entry.name)) {
continue;
}
discovered[entry.name] = {
installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name),
};
@@ -992,6 +999,14 @@ class SharedManager {
return discovered;
}
private isTransientMarketplaceDirectory(name: string): boolean {
return name.startsWith('.') || name.endsWith('.staging') || name.endsWith('.bak');
}
private isMarketplaceRegistryEntry(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
private writePluginMetadataFile(
registryPath: string,
content: string,
+7
View File
@@ -0,0 +1,7 @@
export * from './profile-router';
export * from './proxy-daemon';
export * from './proxy-daemon-paths';
export * from './proxy-env';
export * from './upstream-url';
export * from './transformers/request-transformer';
export * from './transformers/sse-stream-transformer';
+77
View File
@@ -0,0 +1,77 @@
import {
inferDroidProviderFromBaseUrl,
resolveDroidProvider,
type DroidProvider,
} from '../targets/droid-provider';
export interface OpenAICompatProfileConfig {
profileName: string;
settingsPath: string;
baseUrl: string;
apiKey: string;
provider: DroidProvider;
insecure?: boolean;
model?: string;
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
}
export interface OpenAICompatProfileEnv {
ANTHROPIC_BASE_URL?: string;
ANTHROPIC_AUTH_TOKEN?: string;
ANTHROPIC_API_KEY?: string;
ANTHROPIC_MODEL?: string;
ANTHROPIC_DEFAULT_OPUS_MODEL?: string;
ANTHROPIC_DEFAULT_SONNET_MODEL?: string;
ANTHROPIC_DEFAULT_HAIKU_MODEL?: string;
ANTHROPIC_SMALL_FAST_MODEL?: string;
CCS_DROID_PROVIDER?: string;
CCS_OPENAI_PROXY_INSECURE?: string;
}
export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider {
return provider === 'openai' || provider === 'generic-chat-completion-api';
}
export function resolveOpenAICompatProfileConfig(
profileName: string,
settingsPath: string,
env: OpenAICompatProfileEnv
): OpenAICompatProfileConfig | null {
const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || '';
const apiKey = env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim() || '';
if (!baseUrl || !apiKey) {
return null;
}
const providerFromUrl = inferDroidProviderFromBaseUrl(baseUrl);
const provider = isOpenAICompatProvider(providerFromUrl)
? providerFromUrl
: resolveDroidProvider({
provider: env.CCS_DROID_PROVIDER,
baseUrl,
model: env.ANTHROPIC_MODEL,
});
if (!isOpenAICompatProvider(provider)) {
return null;
}
return {
profileName,
settingsPath,
baseUrl,
apiKey,
provider,
insecure:
env.CCS_OPENAI_PROXY_INSECURE === '1' ||
env.CCS_OPENAI_PROXY_INSECURE?.toLowerCase() === 'true',
model: env.ANTHROPIC_MODEL?.trim() || undefined,
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined,
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined,
haikuModel:
env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() ||
env.ANTHROPIC_SMALL_FAST_MODEL?.trim() ||
undefined,
};
}
+87
View File
@@ -0,0 +1,87 @@
import { loadSettings } from '../utils/config-manager';
import { resolveOpenAICompatProfileConfig } from './profile-router';
import { startOpenAICompatProxyServer } from './server/proxy-server';
interface RuntimeOptions {
port: number;
host: string;
profileName: string;
settingsPath: string;
authToken: string;
insecure: boolean;
}
function parseArgs(argv: string[]): RuntimeOptions {
let port = 3456;
let host = '127.0.0.1';
let profileName = '';
let settingsPath = '';
let authToken = '';
let insecure = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--port' && argv[i + 1]) {
port = Number.parseInt(argv[++i] || '', 10) || port;
continue;
}
if (arg === '--host' && argv[i + 1]) {
host = argv[++i] || host;
continue;
}
if (arg === '--profile' && argv[i + 1]) {
profileName = argv[++i] || '';
continue;
}
if (arg === '--settings-path' && argv[i + 1]) {
settingsPath = argv[++i] || '';
continue;
}
if (arg === '--auth-token' && argv[i + 1]) {
authToken = argv[++i] || '';
continue;
}
if (arg === '--insecure') {
insecure = true;
}
}
return { port, host, profileName, settingsPath, authToken, insecure };
}
function startRuntime(options: RuntimeOptions): void {
if (!options.authToken.trim()) {
throw new Error('Missing local proxy auth token');
}
const settings = loadSettings(options.settingsPath);
const profile = resolveOpenAICompatProfileConfig(
options.profileName,
options.settingsPath,
settings.env || {}
);
if (!profile) {
throw new Error(
`Profile "${options.profileName}" is not an OpenAI-compatible settings profile`
);
}
const server = startOpenAICompatProxyServer({
profile,
host: options.host,
port: options.port,
authToken: options.authToken,
insecure: options.insecure,
});
server.once('error', (error) => {
console.error((error as Error).message);
process.exit(1);
});
const shutdown = () => server.close();
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
if (require.main === module) {
startRuntime(parseArgs(process.argv.slice(2)));
}
+17
View File
@@ -0,0 +1,17 @@
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
export const OPENAI_COMPAT_PROXY_DEFAULT_PORT = 3456;
export const OPENAI_COMPAT_PROXY_SERVICE_NAME = 'ccs-openai-compat-proxy';
export function getOpenAICompatProxyDir(): string {
return path.join(getCcsDir(), 'proxy');
}
export function getOpenAICompatProxyPidPath(): string {
return path.join(getOpenAICompatProxyDir(), 'daemon.pid');
}
export function getOpenAICompatProxySessionPath(): string {
return path.join(getOpenAICompatProxyDir(), 'session.json');
}
+83
View File
@@ -0,0 +1,83 @@
import * as fs from 'fs';
import * as path from 'path';
import {
getOpenAICompatProxyDir,
getOpenAICompatProxyPidPath,
getOpenAICompatProxySessionPath,
} from './proxy-daemon-paths';
export interface OpenAICompatProxySession {
profileName: string;
settingsPath: string;
host: string;
port: number;
baseUrl: string;
authToken: string;
model?: string;
insecure?: boolean;
}
function ensureProxyDir(): void {
fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true });
}
export function getOpenAICompatProxyPid(): number | null {
try {
const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim();
const pid = Number.parseInt(raw, 10);
return Number.isInteger(pid) ? pid : null;
} catch {
return null;
}
}
export function writeOpenAICompatProxyPid(pid: number): void {
ensureProxyDir();
fs.writeFileSync(getOpenAICompatProxyPidPath(), String(pid), 'utf8');
}
export function removeOpenAICompatProxyPid(): void {
try {
fs.unlinkSync(getOpenAICompatProxyPidPath());
} catch {
// Best-effort cleanup.
}
}
export function readOpenAICompatProxySession(): OpenAICompatProxySession | null {
try {
return JSON.parse(
fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8')
) as OpenAICompatProxySession;
} catch {
return null;
}
}
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
ensureProxyDir();
fs.writeFileSync(
getOpenAICompatProxySessionPath(),
JSON.stringify(session, null, 2) + '\n',
'utf8'
);
}
export function removeOpenAICompatProxySession(): void {
try {
fs.unlinkSync(getOpenAICompatProxySessionPath());
} catch {
// Best-effort cleanup.
}
}
export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
const isBunRuntime = process.execPath.toLowerCase().includes('bun');
const runningFromDist = __filename.endsWith('.js');
if (runningFromDist) {
return [jsEntry];
}
return isBunRuntime ? [tsEntry, jsEntry] : [jsEntry];
}
+389
View File
@@ -0,0 +1,389 @@
import { spawn, type ChildProcess } from 'child_process';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as http from 'http';
import * as net from 'net';
import * as lockfile from 'proper-lockfile';
import { verifyProcessOwnership } from '../cursor/daemon-process-ownership';
import type { OpenAICompatProfileConfig } from './profile-router';
import {
OPENAI_COMPAT_PROXY_DEFAULT_PORT,
OPENAI_COMPAT_PROXY_SERVICE_NAME,
getOpenAICompatProxyDir,
} from './proxy-daemon-paths';
import {
getOpenAICompatProxyPid,
readOpenAICompatProxySession,
removeOpenAICompatProxyPid,
removeOpenAICompatProxySession,
resolveOpenAICompatProxyEntrypointCandidates,
type OpenAICompatProxySession,
writeOpenAICompatProxyPid,
writeOpenAICompatProxySession,
} from './proxy-daemon-state';
export interface OpenAICompatProxyStatus extends Partial<OpenAICompatProxySession> {
running: boolean;
pid?: number;
}
export interface StartOpenAICompatProxyResult {
success: boolean;
alreadyRunning?: boolean;
authToken?: string;
pid?: number;
port: number;
error?: string;
}
function generateProxyAuthToken(): string {
return crypto.randomBytes(24).toString('hex');
}
async function withOpenAICompatProxyLock<T>(operation: () => Promise<T>): Promise<T> {
const proxyDir = getOpenAICompatProxyDir();
await fs.promises.mkdir(proxyDir, { recursive: true });
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(proxyDir, {
stale: 10000,
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
realpath: false,
});
} catch (error) {
throw new Error(
`Failed to lock OpenAI-compatible proxy directory (${proxyDir}): ${(error as Error).message}`
);
}
try {
return await operation();
} finally {
if (release) {
try {
await release();
} catch {
// Best-effort release.
}
}
}
}
async function isPortOccupied(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
const finish = (occupied: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(occupied);
};
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(500, () => {
finish(false);
});
});
}
async function findOpenAICompatProxyPort(): Promise<number> {
for (
let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT;
candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10;
candidate += 1
) {
if (!(await isPortOccupied(candidate))) {
return candidate;
}
}
return 0;
}
async function resolveDaemonEntrypoint(): Promise<string | null> {
for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) {
try {
await fs.promises.access(candidate, fs.constants.R_OK);
return candidate;
} catch {
// Try next candidate.
}
}
return null;
}
export async function isOpenAICompatProxyRunning(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 === OPENAI_COMPAT_PROXY_SERVICE_NAME);
} catch {
resolve(false);
}
});
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
}
export async function getOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus> {
const session = readOpenAICompatProxySession();
const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT;
const running = await isOpenAICompatProxyRunning(port);
return {
running,
pid: running ? getOpenAICompatProxyPid() || undefined : undefined,
...session,
};
}
async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> {
const pid = getOpenAICompatProxyPid();
if (!pid) {
removeOpenAICompatProxySession();
return { success: true };
}
const ownership = verifyProcessOwnership(
pid,
(commandLine) =>
commandLine.includes('--ccs-openai-proxy-daemon') &&
commandLine.includes('proxy-daemon-entry')
);
if (ownership === 'not-owned') {
removeOpenAICompatProxyPid();
return { success: true };
}
if (ownership === 'unknown') {
return {
success: false,
error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`,
};
}
if (ownership === 'not-running') {
removeOpenAICompatProxyPid();
removeOpenAICompatProxySession();
return { success: true };
}
try {
process.kill(pid, 'SIGTERM');
let attempts = 0;
while (attempts < 10) {
await new Promise((resolve) => setTimeout(resolve, 500));
try {
process.kill(pid, 0);
attempts += 1;
} catch {
break;
}
}
if (attempts >= 10) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// Already exited.
}
}
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ESRCH') {
return { success: false, error: `Failed to stop daemon: ${err.message}` };
}
}
removeOpenAICompatProxyPid();
removeOpenAICompatProxySession();
return { success: true };
}
export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error?: string }> {
return withOpenAICompatProxyLock(() => stopOpenAICompatProxyUnlocked());
}
export async function startOpenAICompatProxy(
profile: OpenAICompatProfileConfig,
options: { port?: number; host?: string; insecure?: boolean } = {}
): Promise<StartOpenAICompatProxyResult> {
return withOpenAICompatProxyLock(async () => {
const status = await getOpenAICompatProxyStatus();
const host = options.host?.trim() || status.host || '127.0.0.1';
const port =
typeof options.port === 'number'
? options.port
: status.running && status.profileName === profile.profileName && status.port
? status.port
: await findOpenAICompatProxyPort();
if (port === 0) {
return {
success: false,
port: OPENAI_COMPAT_PROXY_DEFAULT_PORT,
error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`,
};
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return { success: false, port, error: `Invalid port: ${port}` };
}
if (
status.running &&
status.profileName === profile.profileName &&
status.port === port &&
(status.host || '127.0.0.1') === host
) {
return {
success: true,
alreadyRunning: true,
pid: status.pid,
port,
authToken: status.authToken,
};
}
if (status.running) {
if (status.profileName !== profile.profileName) {
return {
success: false,
port,
error: `Proxy already running for profile "${status.profileName}" on port ${status.port}. Stop it before starting a different profile.`,
};
}
const stopped = await stopOpenAICompatProxyUnlocked();
if (!stopped.success) {
return {
success: false,
port,
error: stopped.error || 'Failed to restart the running proxy',
};
}
}
const daemonEntry = await resolveDaemonEntrypoint();
if (!daemonEntry) {
return {
success: false,
port,
error: 'OpenAI proxy daemon entrypoint not found. Run `bun run build` and retry.',
};
}
return new Promise((resolve) => {
let resolved = false;
let timeout: NodeJS.Timeout | null = null;
const authToken = generateProxyAuthToken();
const finish = (result: StartOpenAICompatProxyResult) => {
if (resolved) return;
resolved = true;
if (timeout) clearTimeout(timeout);
if (!result.success) {
removeOpenAICompatProxyPid();
removeOpenAICompatProxySession();
}
resolve(result);
};
const proc: ChildProcess = spawn(
process.execPath,
[
daemonEntry,
'--port',
String(port),
'--host',
host,
'--profile',
profile.profileName,
'--settings-path',
profile.settingsPath,
'--auth-token',
authToken,
...(options.insecure ? ['--insecure'] : []),
'--ccs-openai-proxy-daemon',
],
{ stdio: 'ignore', detached: true }
);
proc.unref();
if (proc.pid) {
writeOpenAICompatProxyPid(proc.pid);
}
writeOpenAICompatProxySession({
profileName: profile.profileName,
settingsPath: profile.settingsPath,
host,
port,
baseUrl: profile.baseUrl,
authToken,
model: profile.model,
insecure: options.insecure,
});
let attempts = 0;
const poll = async () => {
attempts += 1;
if (await isOpenAICompatProxyRunning(port)) {
finish({ success: true, pid: proc.pid, port, authToken });
return;
}
if (attempts >= 30) {
finish({
success: false,
port,
error: `Proxy daemon did not start within 30 seconds on port ${port}`,
});
return;
}
timeout = setTimeout(poll, 1000);
};
timeout = setTimeout(poll, 1000);
proc.on('error', (error) => {
finish({ success: false, port, error: error.message });
});
proc.on('exit', (code, signal) => {
if (code === 0) {
finish({
success: false,
port,
error: 'Proxy daemon exited before becoming healthy',
});
return;
}
if (code !== null) {
finish({
success: false,
port,
error: `Proxy daemon exited with code ${code}`,
});
return;
}
finish({
success: false,
port,
error: `Proxy daemon was killed by signal ${signal}`,
});
});
});
});
}
+29
View File
@@ -0,0 +1,29 @@
import type { OpenAICompatProfileConfig } from './profile-router';
export function buildOpenAICompatProxyEnv(
profile: OpenAICompatProfileConfig,
port: number,
authToken: string,
claudeConfigDir?: string,
host = '127.0.0.1'
): Record<string, string> {
const localBaseUrl = `http://${host}:${port}`;
return {
ANTHROPIC_BASE_URL: localBaseUrl,
ANTHROPIC_AUTH_TOKEN: authToken,
DISABLE_TELEMETRY: '1',
DISABLE_COST_WARNINGS: '1',
API_TIMEOUT_MS: '600000',
NO_PROXY: host === '127.0.0.1' ? '127.0.0.1,localhost' : `${host},127.0.0.1,localhost`,
...(profile.model ? { ANTHROPIC_MODEL: profile.model } : {}),
...(profile.opusModel ? { ANTHROPIC_DEFAULT_OPUS_MODEL: profile.opusModel } : {}),
...(profile.sonnetModel ? { ANTHROPIC_DEFAULT_SONNET_MODEL: profile.sonnetModel } : {}),
...(profile.haikuModel
? {
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.haikuModel,
ANTHROPIC_SMALL_FAST_MODEL: profile.haikuModel,
}
: {}),
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
};
}
+222
View File
@@ -0,0 +1,222 @@
import { loadConfigSafe, loadSettings } from '../utils/config-manager';
import { expandPath } from '../utils/helpers';
import type { ProxyOpenAIRequest } from './transformers/request-transformer';
import {
loadOpenAICompatProxyRoutingConfig,
type OpenAICompatProxyRoutingConfig,
} from './routing-config';
import { resolveOpenAICompatProfileConfig, type OpenAICompatProfileConfig } from './profile-router';
export type ProxyRoutingScenario = 'default' | 'background' | 'think' | 'longContext' | 'webSearch';
export interface ProxyRequestRoute {
profile: OpenAICompatProfileConfig;
model?: string;
scenario?: ProxyRoutingScenario;
estimatedTokens: number;
source:
| 'explicit-profile'
| 'scenario'
| 'profile-model-match'
| 'profile-name'
| 'request-model'
| 'active-default';
}
function loadOpenAICompatProfiles(
activeProfile: OpenAICompatProfileConfig
): OpenAICompatProfileConfig[] {
const config = loadConfigSafe();
const profiles = [activeProfile];
for (const [profileName, settingsPath] of Object.entries(config.profiles)) {
if (profileName === activeProfile.profileName) {
continue;
}
try {
const expandedPath = expandPath(settingsPath);
const settings = loadSettings(expandedPath);
const profile = resolveOpenAICompatProfileConfig(
profileName,
expandedPath,
settings.env || {}
);
if (profile) {
profiles.push(profile);
}
} catch {
// Ignore invalid profiles while routing a live request.
}
}
return profiles;
}
function resolveSelectorTarget(
selector: string,
activeProfile: OpenAICompatProfileConfig,
profiles: OpenAICompatProfileConfig[]
): { profile: OpenAICompatProfileConfig; model?: string; explicitProfile: boolean } | null {
const trimmed = selector.trim();
if (!trimmed) {
return null;
}
const colonIndex = trimmed.indexOf(':');
if (colonIndex > 0) {
const profileName = trimmed.slice(0, colonIndex).trim();
const profile = profiles.find((candidate) => candidate.profileName === profileName);
if (profile) {
const model = trimmed.slice(colonIndex + 1).trim() || profile.model;
return { profile, model, explicitProfile: true };
}
}
const namedProfile = profiles.find((candidate) => candidate.profileName === trimmed);
if (namedProfile) {
return { profile: namedProfile, model: namedProfile.model, explicitProfile: false };
}
return {
profile: activeProfile,
model: trimmed,
explicitProfile: false,
};
}
function profileSupportsModel(profile: OpenAICompatProfileConfig, model: string): boolean {
return [profile.model, profile.opusModel, profile.sonnetModel, profile.haikuModel].some(
(candidate) => typeof candidate === 'string' && candidate === model
);
}
function estimateTokens(request: ProxyOpenAIRequest): number {
let characters = 0;
for (const message of request.messages) {
if (typeof message.content === 'string') {
characters += message.content.length;
continue;
}
if (Array.isArray(message.content)) {
for (const part of message.content) {
characters += part.type === 'text' ? part.text.length : part.image_url.url.length;
}
}
if (Array.isArray(message.tool_calls)) {
for (const toolCall of message.tool_calls) {
characters += toolCall.function.name.length + toolCall.function.arguments.length;
}
}
}
if (Array.isArray(request.tools)) {
characters += JSON.stringify(request.tools).length;
}
return Math.max(1, Math.ceil(characters / 4));
}
function detectScenario(
request: ProxyOpenAIRequest,
requestedModel: string | undefined,
routing: OpenAICompatProxyRoutingConfig
): { scenario?: ProxyRoutingScenario; selector?: string; estimatedTokens: number } {
const estimatedTokens = estimateTokens(request);
const hasWebSearchTool =
request.tools?.some((tool) => tool.function.name === 'web_search') === true;
const thinkingEnabled =
request.reasoning?.enabled === true || typeof request.reasoning_effort === 'string';
const modelId = requestedModel || '';
const longContextThreshold = routing.longContextThreshold ?? 60_000;
if (hasWebSearchTool && routing.webSearch) {
return { scenario: 'webSearch', selector: routing.webSearch, estimatedTokens };
}
if (thinkingEnabled && routing.think) {
return { scenario: 'think', selector: routing.think, estimatedTokens };
}
if (estimatedTokens > longContextThreshold && routing.longContext) {
return { scenario: 'longContext', selector: routing.longContext, estimatedTokens };
}
if (modelId.toLowerCase().includes('haiku') && routing.background) {
return { scenario: 'background', selector: routing.background, estimatedTokens };
}
if (!requestedModel && routing.default) {
return { scenario: 'default', selector: routing.default, estimatedTokens };
}
return { estimatedTokens };
}
export function resolveProxyRequestRoute(
activeProfile: OpenAICompatProfileConfig,
request: ProxyOpenAIRequest
): ProxyRequestRoute {
const profiles = loadOpenAICompatProfiles(activeProfile);
const requestedModel = request.model?.trim() || undefined;
const explicitTarget = requestedModel
? resolveSelectorTarget(requestedModel, activeProfile, profiles)
: null;
const routing = loadOpenAICompatProxyRoutingConfig();
if (explicitTarget?.explicitProfile) {
return {
profile: explicitTarget.profile,
model: explicitTarget.model,
estimatedTokens: estimateTokens(request),
source: 'explicit-profile',
};
}
const scenario = detectScenario(request, requestedModel, routing);
if (scenario.selector) {
const scenarioTarget = resolveSelectorTarget(scenario.selector, activeProfile, profiles);
if (scenarioTarget) {
return {
profile: scenarioTarget.profile,
model: scenarioTarget.model,
scenario: scenario.scenario,
estimatedTokens: scenario.estimatedTokens,
source: 'scenario',
};
}
}
if (explicitTarget && explicitTarget.profile.profileName !== activeProfile.profileName) {
return {
profile: explicitTarget.profile,
model: explicitTarget.model,
estimatedTokens: scenario.estimatedTokens,
source: 'profile-name',
};
}
if (requestedModel) {
const matchedProfile = profiles.find((profile) =>
profileSupportsModel(profile, requestedModel)
);
if (matchedProfile) {
return {
profile: matchedProfile,
model: requestedModel,
estimatedTokens: scenario.estimatedTokens,
source: 'profile-model-match',
};
}
return {
profile: activeProfile,
model: requestedModel,
estimatedTokens: scenario.estimatedTokens,
source: 'request-model',
};
}
return {
profile: activeProfile,
model: activeProfile.model,
estimatedTokens: scenario.estimatedTokens,
source: 'active-default',
};
}
+74
View File
@@ -0,0 +1,74 @@
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { getActiveConfigPath, getConfigPath } from '../utils/config-manager';
export interface OpenAICompatProxyRoutingConfig {
default?: string;
background?: string;
think?: string;
longContext?: string;
webSearch?: string;
longContextThreshold?: number;
}
function readRawConfigObject(configPath: string): Record<string, unknown> | null {
if (!fs.existsSync(configPath)) {
return null;
}
const raw = fs.readFileSync(configPath, 'utf8');
const parsed =
configPath.endsWith('.yaml') || configPath.endsWith('.yml') ? yaml.load(raw) : JSON.parse(raw);
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
}
function normalizeRoutingConfig(value: unknown): OpenAICompatProxyRoutingConfig {
if (typeof value !== 'object' || value === null) {
return {};
}
const config = value as Record<string, unknown>;
return {
default:
typeof config.default === 'string' && config.default.trim()
? config.default.trim()
: undefined,
background:
typeof config.background === 'string' && config.background.trim()
? config.background.trim()
: undefined,
think:
typeof config.think === 'string' && config.think.trim() ? config.think.trim() : undefined,
longContext:
typeof config.longContext === 'string' && config.longContext.trim()
? config.longContext.trim()
: undefined,
webSearch:
typeof config.webSearch === 'string' && config.webSearch.trim()
? config.webSearch.trim()
: undefined,
longContextThreshold:
typeof config.longContextThreshold === 'number' &&
Number.isFinite(config.longContextThreshold)
? config.longContextThreshold
: undefined,
};
}
export function loadOpenAICompatProxyRoutingConfig(): OpenAICompatProxyRoutingConfig {
const activePath = getActiveConfigPath();
const rawConfig = readRawConfigObject(activePath);
if (rawConfig?.proxy && typeof rawConfig.proxy === 'object') {
return normalizeRoutingConfig((rawConfig.proxy as Record<string, unknown>).routing);
}
const legacyPath = getConfigPath();
if (legacyPath !== activePath) {
const legacyConfig = readRawConfigObject(legacyPath);
if (legacyConfig?.proxy && typeof legacyConfig.proxy === 'object') {
return normalizeRoutingConfig((legacyConfig.proxy as Record<string, unknown>).routing);
}
}
return {};
}
+81
View File
@@ -0,0 +1,81 @@
import * as http from 'http';
import { Readable } from 'stream';
const MAX_BODY_SIZE = 10 * 1024 * 1024;
export function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
}
export function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
let settled = false;
const resolveOnce = (payload: unknown) => {
if (!settled) {
settled = true;
resolve(payload);
}
};
const rejectOnce = (error: Error) => {
if (!settled) {
settled = true;
reject(error);
}
};
req.on('data', (chunk: Buffer) => {
total += chunk.length;
if (total > MAX_BODY_SIZE) {
req.pause();
rejectOnce(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) {
resolveOnce({});
return;
}
try {
resolveOnce(JSON.parse(raw));
} catch {
rejectOnce(new Error('Invalid JSON in request body'));
}
});
req.on('error', (error) => {
rejectOnce(error instanceof Error ? error : new Error(String(error)));
});
});
}
export 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);
});
}
+280
View File
@@ -0,0 +1,280 @@
import * as http from 'http';
import type { Dispatcher } from 'undici';
import type { OpenAICompatProfileConfig } from '../profile-router';
import { resolveProxyRequestRoute } from '../request-router';
import { ProxyRequestTransformer } from '../transformers/request-transformer';
import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer';
import { resolveOpenAIChatCompletionsUrl } from '../upstream-url';
import { createLogger } from '../../services/logging';
import { pipeWebResponseToNode, readJsonBody, writeJson } from './http-helpers';
const REQUEST_TIMEOUT_MS = 600_000;
const logger = createLogger('proxy:openai-compat:messages');
class ProxyInputError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProxyInputError';
}
}
function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record<string, string> {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${profile.apiKey}`,
'User-Agent': 'CCS-OpenAI-Compat-Proxy/1.0',
};
}
function buildUpstreamRequest(
profile: OpenAICompatProfileConfig,
rawBody: unknown
): { body: string; route: ReturnType<typeof resolveProxyRequestRoute> } {
let transformed;
try {
const transformer = new ProxyRequestTransformer();
transformed = transformer.transform(rawBody);
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid Anthropic request';
throw new ProxyInputError(message);
}
const route = resolveProxyRequestRoute(profile, transformed);
const body = {
...transformed,
model: route.model || route.profile.model,
stream: transformed.stream === true,
};
return { body: JSON.stringify(body), route };
}
export function extractIncomingProxyToken(headers: http.IncomingHttpHeaders): string | null {
const xApiKey = headers['x-api-key'];
if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) {
return xApiKey.trim();
}
const anthropicApiKey = headers['anthropic-api-key'];
if (typeof anthropicApiKey === 'string' && anthropicApiKey.trim().length > 0) {
return anthropicApiKey.trim();
}
const authHeader = headers.authorization;
if (typeof authHeader === 'string' && authHeader.trim().length > 0) {
const trimmed = authHeader.trim();
const bearerPrefix = 'Bearer ';
return trimmed.startsWith(bearerPrefix) ? trimmed.slice(bearerPrefix.length).trim() : trimmed;
}
return null;
}
export function validateIncomingProxyAuth(
headers: http.IncomingHttpHeaders,
expectedToken: string
): boolean {
return extractIncomingProxyToken(headers) === expectedToken;
}
function buildFetchInit(
profile: OpenAICompatProfileConfig,
body: string,
signal: AbortSignal,
insecureDispatcher?: Dispatcher
): RequestInit {
const init: RequestInit = {
method: 'POST',
headers: buildUpstreamHeaders(profile),
body,
signal,
};
if (insecureDispatcher) {
(init as Record<string, unknown>).dispatcher = insecureDispatcher;
}
return init;
}
function getRequestTimeoutMs(): number {
const rawValue = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS;
if (!rawValue) {
return REQUEST_TIMEOUT_MS;
}
const parsed = Number.parseInt(rawValue, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : REQUEST_TIMEOUT_MS;
}
function formatTimeoutDuration(timeoutMs: number): string {
return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000} seconds` : `${timeoutMs}ms`;
}
function registerOnceListener(
emitter: NodeJS.EventEmitter | null | undefined,
event: string,
handler: () => void
): () => void {
if (!emitter) {
return () => {};
}
emitter.once(event, handler);
return () => {
emitter.removeListener(event, handler);
};
}
export function attachDisconnectAbortHandlers(
req: http.IncomingMessage,
res: http.ServerResponse,
controller: AbortController,
onDisconnect: (source: string) => void
): () => void {
const abortOnDisconnect = (source: string) => {
if (!controller.signal.aborted && !res.writableEnded) {
onDisconnect(source);
controller.abort();
}
};
const cleanupFns = [
registerOnceListener(req, 'aborted', () => abortOnDisconnect('req.aborted')),
registerOnceListener(req, 'close', () => abortOnDisconnect('req.close')),
registerOnceListener(req.socket, 'close', () => abortOnDisconnect('req.socket.close')),
registerOnceListener(res, 'close', () => abortOnDisconnect('res.close')),
registerOnceListener(res.socket, 'close', () => abortOnDisconnect('res.socket.close')),
];
const disconnectPoll = setInterval(() => {
if (
req.destroyed ||
res.destroyed ||
req.socket?.destroyed === true ||
res.socket?.destroyed === true
) {
abortOnDisconnect('poll.destroyed');
}
}, 50);
return () => {
clearInterval(disconnectPoll);
for (const cleanup of cleanupFns) {
cleanup();
}
};
}
export async function handleProxyMessagesRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
profile: OpenAICompatProfileConfig,
expectedAuthToken: string,
insecureDispatcher?: Dispatcher
): Promise<void> {
const transformer = new ProxySseStreamTransformer();
if (!validateIncomingProxyAuth(req.headers, expectedAuthToken)) {
logger.warn('auth.invalid', 'Rejected proxy message request with invalid auth token', {
remoteAddress: req.socket.remoteAddress || null,
});
await pipeWebResponseToNode(
transformer.error(401, 'authentication_error', 'Missing or invalid local proxy token'),
res
);
return;
}
let timeoutMs = REQUEST_TIMEOUT_MS;
try {
const rawBody = await readJsonBody(req);
const upstream = buildUpstreamRequest(profile, rawBody);
logger.info('request.forward', 'Forwarding Anthropic request to OpenAI-compatible upstream', {
profileName: upstream.route.profile.profileName,
provider: upstream.route.profile.provider,
baseUrl: upstream.route.profile.baseUrl,
model: upstream.route.model || upstream.route.profile.model || null,
routeSource: upstream.route.source,
scenario: upstream.route.scenario || null,
estimatedTokens: upstream.route.estimatedTokens,
});
const controller = new AbortController();
timeoutMs = getRequestTimeoutMs();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const cleanupDisconnectHandlers = attachDisconnectAbortHandlers(
req,
res,
controller,
(source) => {
logger.info(
'request.disconnect',
'Aborting upstream request after local client disconnect',
{
profileName: profile.profileName,
source,
}
);
}
);
try {
const upstreamResponse = await fetch(
resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl),
buildFetchInit(upstream.route.profile, upstream.body, controller.signal, insecureDispatcher)
);
logger.info('response.received', 'Received upstream response', {
profileName: profile.profileName,
routedProfileName: upstream.route.profile.profileName,
status: upstreamResponse.status,
});
const response = await transformer.transform(upstreamResponse);
await pipeWebResponseToNode(response, res);
} finally {
clearTimeout(timeout);
cleanupDisconnectHandlers();
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown proxy error';
logger.error('request.failed', 'Proxy message request failed', {
profileName: profile.profileName,
error: message,
abort: error instanceof Error && error.name === 'AbortError',
});
const status =
error instanceof Error && error.name === 'AbortError'
? 502
: error instanceof ProxyInputError
? 400
: message.includes('Request body too large')
? 413
: message.includes('Invalid JSON')
? 400
: 502;
const type = status >= 500 ? 'api_error' : 'invalid_request_error';
await pipeWebResponseToNode(
transformer.error(
status,
type,
error instanceof Error && error.name === 'AbortError'
? `The upstream provider did not respond within ${formatTimeoutDuration(timeoutMs)}`
: message
),
res
);
}
}
export function handleProxyModelsRequest(
res: http.ServerResponse,
profile: OpenAICompatProfileConfig
): void {
const data = [profile.model, profile.opusModel, profile.sonnetModel, profile.haikuModel]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map((id) => ({
id,
object: 'model',
created: 0,
owned_by: profile.provider,
}));
writeJson(res, 200, { object: 'list', data });
}
+110
View File
@@ -0,0 +1,110 @@
import * as http from 'http';
import { Agent } from 'undici';
import type { OpenAICompatProfileConfig } from '../profile-router';
import { OPENAI_COMPAT_PROXY_SERVICE_NAME } from '../proxy-daemon-paths';
import { createLogger } from '../../services/logging';
import {
handleProxyMessagesRequest,
handleProxyModelsRequest,
validateIncomingProxyAuth,
} from './messages-route';
import { writeJson } from './http-helpers';
export interface OpenAICompatProxyServerOptions {
profile: OpenAICompatProfileConfig;
host?: string;
port: number;
authToken: string;
insecure?: boolean;
}
export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOptions): http.Server {
const host = options.host?.trim() || '127.0.0.1';
const logger = createLogger('proxy:openai-compat', {
profileName: options.profile.profileName,
host,
port: options.port,
});
const insecureDispatcher = options.insecure
? new Agent({ connect: { rejectUnauthorized: false } })
: undefined;
const server = http.createServer(async (req, res) => {
const method = req.method || 'GET';
const requestUrl = req.url || '/';
const parsedUrl = new URL(requestUrl, 'http://127.0.0.1');
const pathname =
parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname;
if (method === 'GET' && pathname === '/health') {
writeJson(res, 200, {
ok: true,
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
host,
profile: options.profile.profileName,
port: options.port,
});
return;
}
if (method === 'GET' && pathname === '/') {
writeJson(res, 200, {
ok: true,
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
bind: {
host,
port: options.port,
},
profile: {
name: options.profile.profileName,
provider: options.profile.provider,
model: options.profile.model || null,
},
endpoints: ['/health', '/v1/messages', '/v1/models'],
});
return;
}
if (method === 'GET' && pathname === '/v1/models') {
if (!validateIncomingProxyAuth(req.headers, options.authToken)) {
writeJson(res, 401, {
type: 'error',
error: {
type: 'authentication_error',
message: 'Missing or invalid local proxy token',
},
});
return;
}
handleProxyModelsRequest(res, options.profile);
return;
}
if (method === 'POST' && pathname === '/v1/messages') {
await handleProxyMessagesRequest(
req,
res,
options.profile,
options.authToken,
insecureDispatcher
);
return;
}
logger.warn('http.not_found', 'Rejected unknown proxy route', {
method,
pathname,
});
writeJson(res, 404, { error: 'Not found' });
});
logger.info('server.start', 'OpenAI-compatible proxy server listening', {
baseUrl: `http://${host}:${options.port}`,
});
server.on('close', () => {
logger.info('server.stop', 'OpenAI-compatible proxy server stopped');
void insecureDispatcher?.close();
});
server.listen(options.port, host);
return server;
}
@@ -0,0 +1,427 @@
interface AnthropicThinking {
type?: 'enabled' | 'disabled' | string;
budget_tokens?: number;
}
interface AnthropicTextBlock {
type: 'text';
text?: string;
}
interface AnthropicImageBlock {
type: 'image';
source?: {
type?: string;
media_type?: string;
data?: string;
};
}
interface AnthropicToolUseBlock {
type: 'tool_use';
id?: string;
name?: string;
input?: Record<string, unknown>;
}
interface AnthropicToolResultBlock {
type: 'tool_result';
tool_use_id?: string;
content?: unknown;
}
type AnthropicContentBlock =
| AnthropicTextBlock
| AnthropicImageBlock
| AnthropicToolUseBlock
| AnthropicToolResultBlock
| { type: string; [key: string]: unknown };
interface AnthropicMessage {
role?: 'user' | 'assistant' | string;
content?: string | AnthropicContentBlock[];
}
interface AnthropicProxyRequestShape {
model?: unknown;
system?: unknown;
messages?: unknown;
max_tokens?: unknown;
temperature?: unknown;
top_p?: unknown;
stop_sequences?: unknown;
metadata?: unknown;
tools?: unknown;
stream?: unknown;
thinking?: AnthropicThinking;
}
interface OpenAITextPart {
type: 'text';
text: string;
}
interface OpenAIImagePart {
type: 'image_url';
image_url: {
url: string;
};
}
type OpenAIContentPart = OpenAITextPart | OpenAIImagePart;
interface OpenAIMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | OpenAIContentPart[] | null;
tool_call_id?: string;
tool_calls?: Array<{
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}>;
}
export interface ProxyOpenAIRequest {
model?: string;
stream: boolean;
reasoning_effort?: string;
reasoning?: {
enabled: boolean;
effort: string;
};
tools?: Array<{
type: 'function';
function: {
name: string;
description?: string;
parameters: Record<string, unknown>;
};
}>;
messages: OpenAIMessage[];
max_tokens?: number;
temperature?: number;
top_p?: number;
stop?: string[];
metadata?: Record<string, unknown>;
}
const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]';
const TOOL_USE_ARGUMENTS_FALLBACK = '{}';
function assertObject(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
throw new Error(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function asStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const result = value.filter(
(entry): entry is string => typeof entry === 'string' && entry.length > 0
);
return result.length > 0 ? result : undefined;
}
function asMetadata(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function safeJsonStringify(value: unknown, fallback: string): string {
try {
const serialized = JSON.stringify(value);
return typeof serialized === 'string' ? serialized : fallback;
} catch {
return fallback;
}
}
function flattenTextContent(content: unknown, label: string): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
throw new Error(`${label} must be a string or content block array`);
}
return content
.map((block, index) => {
const parsed = assertObject(block, `${label}[${index}]`);
if (parsed.type !== 'text') {
throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`);
}
return typeof parsed.text === 'string' ? parsed.text : '';
})
.join('\n');
}
function toToolResultContent(content: unknown, label: string): string {
if (content === undefined) {
return '';
}
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return flattenTextContent(content, label);
}
return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
}
function createFallbackToolId(messageIndex: number, blockIndex: number): string {
return `toolu_proxy_fallback_${messageIndex}_${blockIndex}`;
}
function toImagePart(block: AnthropicImageBlock, label: string): OpenAIImagePart {
const source = block.source;
if (!source || source.type !== 'base64' || !source.media_type || !source.data) {
throw new Error(`${label}.source must be a base64 image payload`);
}
return {
type: 'image_url',
image_url: {
url: `data:${source.media_type};base64,${source.data}`,
},
};
}
function isImageBlock(block: AnthropicContentBlock): block is AnthropicImageBlock {
return block.type === 'image';
}
function isToolUseBlock(block: AnthropicContentBlock): block is AnthropicToolUseBlock {
return block.type === 'tool_use';
}
function isToolResultBlock(block: AnthropicContentBlock): block is AnthropicToolResultBlock {
return block.type === 'tool_result';
}
function flushUserContent(messages: OpenAIMessage[], parts: OpenAIContentPart[]): void {
if (parts.length === 0) {
return;
}
const onlyText = parts.every((part) => part.type === 'text');
messages.push({
role: 'user',
content: onlyText ? parts.map((part) => (part as OpenAITextPart).text).join('\n') : [...parts],
});
parts.length = 0;
}
function transformTools(value: unknown): ProxyOpenAIRequest['tools'] {
if (!Array.isArray(value)) {
return undefined;
}
const tools = value
.filter(
(entry): entry is { name?: unknown; description?: unknown; input_schema?: unknown } =>
typeof entry === 'object' && entry !== null
)
.map((entry) => ({
type: 'function' as const,
function: {
name: typeof entry.name === 'string' ? entry.name : 'tool',
...(typeof entry.description === 'string' ? { description: entry.description } : {}),
parameters:
typeof entry.input_schema === 'object' && entry.input_schema !== null
? (entry.input_schema as Record<string, unknown>)
: { type: 'object', properties: {} },
},
}));
return tools.length > 0 ? tools : undefined;
}
function mapThinkingToReasoning(
thinking: AnthropicThinking | undefined
): Pick<ProxyOpenAIRequest, 'reasoning' | 'reasoning_effort'> {
if (!thinking || thinking.type === 'disabled') {
return {};
}
if (thinking.type !== 'enabled') {
throw new Error('thinking.type must be "enabled" or "disabled"');
}
const effort =
typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192
? 'high'
: 'medium';
return {
reasoning_effort: effort,
reasoning: {
enabled: true,
effort,
},
};
}
function transformMessages(messagesValue: unknown): OpenAIMessage[] {
if (!Array.isArray(messagesValue)) {
throw new Error('messages must be an array');
}
const translatedMessages: OpenAIMessage[] = [];
messagesValue.forEach((message, messageIndex) => {
const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage;
const role = parsedMessage.role;
if (role !== 'user' && role !== 'assistant') {
throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`);
}
const content = parsedMessage.content;
if (typeof content === 'string') {
translatedMessages.push({ role, content });
return;
}
if (!Array.isArray(content)) {
throw new Error(`messages[${messageIndex}].content must be a string or array`);
}
const userParts: OpenAIContentPart[] = [];
const assistantTextParts: string[] = [];
const toolCalls: NonNullable<OpenAIMessage['tool_calls']> = [];
let sawToolResult = false;
content.forEach((block, blockIndex) => {
const parsed = assertObject(
block,
`messages[${messageIndex}].content[${blockIndex}]`
) as AnthropicContentBlock;
if (parsed.type === 'text') {
const text = typeof parsed.text === 'string' ? parsed.text : '';
if (role === 'user') {
userParts.push({ type: 'text', text });
} else {
assistantTextParts.push(text);
}
return;
}
if (isImageBlock(parsed)) {
if (role !== 'user') {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] image requires user role`
);
}
userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`));
return;
}
if (isToolUseBlock(parsed)) {
if (role !== 'assistant') {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role`
);
}
toolCalls.push({
id:
typeof parsed.id === 'string' && parsed.id.length > 0
? parsed.id
: createFallbackToolId(messageIndex, blockIndex),
type: 'function',
function: {
name: typeof parsed.name === 'string' ? parsed.name : 'tool',
arguments: safeJsonStringify(parsed.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK),
},
});
return;
}
if (isToolResultBlock(parsed)) {
if (role !== 'user') {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] tool_result requires user role`
);
}
if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string`
);
}
sawToolResult = true;
flushUserContent(translatedMessages, userParts);
translatedMessages.push({
role: 'tool',
tool_call_id: parsed.tool_use_id,
content: toToolResultContent(
parsed.content,
`messages[${messageIndex}].content[${blockIndex}].content`
),
});
return;
}
throw new Error(
`messages[${messageIndex}].content[${blockIndex}].type "${String(parsed.type)}" is not supported`
);
});
if (role === 'assistant') {
translatedMessages.push({
role: 'assistant',
content: assistantTextParts.join('\n'),
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
});
return;
}
if (userParts.length > 0 || !sawToolResult) {
flushUserContent(translatedMessages, userParts);
}
});
return translatedMessages;
}
export class ProxyRequestTransformer {
transform(raw: unknown): ProxyOpenAIRequest {
const source = assertObject(raw || {}, 'request') as AnthropicProxyRequestShape;
const messages = transformMessages(source.messages);
const system = source.system;
const allMessages =
system !== undefined
? [
{ role: 'system', content: flattenTextContent(system, 'system') } as OpenAIMessage,
...messages,
]
: messages;
return {
model:
typeof source.model === 'string' && source.model.trim().length > 0
? source.model.trim()
: undefined,
stream: source.stream === true,
messages: allMessages,
max_tokens: asNumber(source.max_tokens),
temperature: asNumber(source.temperature),
top_p: asNumber(source.top_p),
stop: asStringArray(source.stop_sequences),
metadata: asMetadata(source.metadata),
tools: transformTools(source.tools),
...mapThinkingToReasoning(source.thinking),
};
}
}
@@ -0,0 +1,260 @@
import { DeltaAccumulator } from '../../glmt/delta-accumulator';
import { GlmtTransformer } from '../../glmt/glmt-transformer';
import { SSEParser } from '../../glmt/sse-parser';
import type { OpenAIResponse, SSEEvent } from '../../glmt/pipeline';
const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible JSON response';
const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible SSE response';
type ResponseHeaders = Headers | Record<string, string> | Array<[string, string]>;
interface AnthropicErrorPayload {
type: 'error';
error: {
type: string;
message: string;
};
}
function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload {
return {
type: 'error',
error: {
type,
message,
},
};
}
function formatErrorForLog(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
function logTranslationError(context: string, error: unknown): void {
console.error(`[proxy-sse-transformer] ${context}: ${formatErrorForLog(error)}`);
}
export function createAnthropicErrorResponse(
status: number,
type: string,
message: string,
headers?: ResponseHeaders
): Response {
const responseHeaders = new Headers(headers);
responseHeaders.set('Content-Type', 'application/json');
responseHeaders.delete('Content-Length');
return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), {
status,
headers: responseHeaders,
});
}
function formatSseEvent(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
function hasTranslatableChoices(value: unknown): value is OpenAIResponse {
if (typeof value !== 'object' || value === null) {
return false;
}
const { choices } = value as OpenAIResponse;
if (!Array.isArray(choices) || choices.length === 0) {
return false;
}
const firstChoice = choices[0];
if (typeof firstChoice !== 'object' || firstChoice === null) {
return false;
}
const message = (firstChoice as { message?: unknown }).message;
return typeof message === 'object' && message !== null;
}
function isSyntheticTransformationFallback(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { id?: unknown }).id === 'string' &&
(value as { id: string }).id.startsWith('msg_error_')
);
}
async function createAnthropicErrorProxyResponse(response: Response): Promise<Response> {
const headers = new Headers(response.headers);
headers.delete('Content-Type');
headers.delete('Content-Length');
let type =
response.status === 401
? 'authentication_error'
: response.status === 429
? 'rate_limit_error'
: response.status >= 400 && response.status < 500
? 'invalid_request_error'
: 'api_error';
let message = `Upstream request failed with status ${response.status}`;
try {
const contentType = (response.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('application/json')) {
const payload = (await response.json()) as {
error?: { type?: string; message?: string };
message?: string;
};
if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) {
type = payload.error.type;
}
if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) {
message = payload.error.message;
} else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) {
message = payload.message;
}
} else {
const text = (await response.text()).trim();
if (text.length > 0) {
message = text;
}
}
} catch (error) {
logTranslationError('Failed to parse upstream error response', error);
}
return createAnthropicErrorResponse(response.status, type, message, headers);
}
async function createAnthropicJsonResponse(response: Response): Promise<Response> {
try {
const openAIResponse = await response.json();
if (!hasTranslatableChoices(openAIResponse)) {
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
}
const anthropicResponse = new GlmtTransformer().transformResponse(openAIResponse);
if (isSyntheticTransformationFallback(anthropicResponse)) {
logTranslationError(
'OpenAI-compatible JSON translation produced synthetic fallback response',
anthropicResponse
);
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
}
return new Response(JSON.stringify(anthropicResponse), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
logTranslationError('OpenAI-compatible JSON translation failed', error);
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
}
}
function createAnthropicStreamingResponse(response: Response): Response {
const body = response.body;
if (!body) {
return createAnthropicErrorResponse(
502,
'api_error',
'Upstream stream ended before a response body was available'
);
}
const parser = new SSEParser({ throwOnMalformedJson: true });
const transformer = new GlmtTransformer();
const accumulator = new DeltaAccumulator({});
const encoder = new TextEncoder();
const readable = new ReadableStream<Uint8Array>({
async start(controller) {
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
const events = parser.parse(Buffer.from(value));
for (const event of events) {
const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator);
for (const anthropicEvent of anthropicEvents) {
controller.enqueue(
encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data))
);
}
}
}
if (!accumulator.isFinalized() && accumulator.isMessageStarted()) {
for (const anthropicEvent of transformer.finalizeDelta(accumulator)) {
controller.enqueue(
encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data))
);
}
}
} catch (error) {
logTranslationError('OpenAI-compatible SSE translation failed', error);
controller.enqueue(
encoder.encode(
formatSseEvent(
'error',
createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE)
)
)
);
} finally {
reader.releaseLock();
controller.close();
}
},
});
return new Response(readable, {
status: response.status,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
export async function createAnthropicProxyResponse(response: Response): Promise<Response> {
if (!response.ok) {
return createAnthropicErrorProxyResponse(response);
}
const contentType = (response.headers.get('content-type') || '').toLowerCase();
const isEventStream =
contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;');
return isEventStream
? createAnthropicStreamingResponse(response)
: createAnthropicJsonResponse(response);
}
export class ProxySseStreamTransformer {
async transform(response: Response): Promise<Response> {
return createAnthropicProxyResponse(response);
}
error(status: number, type: string, message: string): Response {
return createAnthropicErrorResponse(status, type, message);
}
}
+36
View File
@@ -0,0 +1,36 @@
function normalizePathname(pathname: string): string {
const trimmed = pathname.replace(/\/+$/, '');
return trimmed || '';
}
function ensureSupportedProtocol(parsed: URL): void {
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Unsupported upstream protocol: ${parsed.protocol}`);
}
}
function buildResolvedUrl(baseUrl: string, suffix: string): string {
const parsed = new URL(baseUrl);
ensureSupportedProtocol(parsed);
const pathname = normalizePathname(parsed.pathname);
if (pathname.endsWith(suffix)) {
return parsed.toString();
}
if (pathname.endsWith('/v1') || pathname.endsWith('/api')) {
parsed.pathname = `${pathname}${suffix.startsWith('/') ? suffix : `/${suffix}`}`;
return parsed.toString();
}
parsed.pathname = pathname ? `${pathname}/v1${suffix}` : `/v1${suffix}`;
return parsed.toString();
}
export function resolveOpenAIChatCompletionsUrl(baseUrl: string): string {
return buildResolvedUrl(baseUrl, '/chat/completions');
}
export function resolveOpenAIModelsUrl(baseUrl: string): string {
return buildResolvedUrl(baseUrl, '/models');
}
+1 -1
View File
@@ -121,7 +121,7 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] {
'default',
...all.accounts,
...all.settings,
...all.cliproxy,
...all.cliproxy.filter((profileName) => profileName !== 'cursor'),
...all.cliproxyVariants,
];
const deduped = [...new Set(orderedNames)];
+18 -9
View File
@@ -10,17 +10,26 @@ const CODEX_CONFIG_OVERRIDE_PROBE_ARGS = ['-c', 'model="gpt-5"', '--version'];
function buildWindowsCodexCandidates(matches: string[]): string[] {
const shellCandidates = matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry));
const bareCandidates = matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry));
const prioritized: string[] = [];
for (const entry of shellCandidates) {
if (/\.(cmd|bat)$/i.test(entry)) {
prioritized.push(entry.replace(/\.(cmd|bat)$/i, '.ps1'));
const prioritized = shellCandidates.map((entry) => {
if (!/\.ps1$/i.test(entry)) {
return entry;
}
prioritized.push(entry);
}
prioritized.push(...bareCandidates);
return [...new Set(prioritized)];
for (const preferredExtension of ['.cmd', '.bat', '.exe']) {
const siblingCandidate = entry.replace(/\.ps1$/i, preferredExtension);
try {
if (fs.statSync(siblingCandidate).isFile()) {
return siblingCandidate;
}
} catch {
// Ignore missing sibling wrappers and keep the original PowerShell path.
}
}
return entry;
});
return [...new Set([...prioritized, ...bareCandidates])];
}
function runCodexProbe(codexPath: string, args: string[]): string | undefined {
+2
View File
@@ -86,8 +86,10 @@ export function inferDroidProviderFromBaseUrl(
host.includes('api.deepinfra.com') ||
host.includes('api.fireworks.ai') ||
host.includes('inference.baseten.co') ||
host.includes('dashscope') ||
host.includes('huggingface.co') ||
host.includes('ollama.com') ||
pathname.includes('/compatible-mode') ||
pathname.includes('/openai') ||
pathname.includes('/chat/completions')
) {
+15
View File
@@ -40,6 +40,19 @@ export interface CLIProxyVariantsConfig {
[profileName: string]: CLIProxyVariantConfig;
}
export interface OpenAICompatProxyRoutingConfig {
default?: string;
background?: string;
think?: string;
longContext?: string;
webSearch?: string;
longContextThreshold?: number;
}
export interface OpenAICompatProxyConfig {
routing?: OpenAICompatProxyRoutingConfig;
}
/**
* Main CCS configuration
* Located at: ~/.ccs/config.json
@@ -51,6 +64,8 @@ export interface Config {
profile_targets?: Record<string, TargetType>;
/** User-defined CLIProxy profile variants (optional) */
cliproxy?: CLIProxyVariantsConfig;
/** OpenAI-compatible local proxy configuration (optional) */
proxy?: OpenAICompatProxyConfig;
/** Legacy continuity inheritance mapping (profile -> source account) */
continuity_inherit_from_account?: Record<string, string>;
}
+2
View File
@@ -14,6 +14,8 @@ export type {
ProfilesRegistry,
CLIProxyVariantConfig,
CLIProxyVariantsConfig,
OpenAICompatProxyConfig,
OpenAICompatProxyRoutingConfig,
} from './config';
export { isConfig, isSettings } from './config';
+103
View File
@@ -0,0 +1,103 @@
import * as path from 'path';
import type { BrowserConfig } from '../../config/unified-config-types';
import { getCcsDir } from '../config-manager';
import { expandPath } from '../helpers';
export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
export interface EffectiveClaudeBrowserAttachConfig {
enabled: boolean;
source: 'config' | BrowserOverrideSource;
overrideActive: boolean;
userDataDir: string;
devtoolsPort: number;
hasExplicitDevtoolsPort: boolean;
}
export function getRecommendedBrowserUserDataDir(): string {
return path.join(getCcsDir(), 'browser', 'chrome-user-data');
}
export function resolveBrowserUserDataDir(value?: string): string | undefined {
return value?.trim() ? expandPath(value) : undefined;
}
export function getBrowserAttachOverride(env: NodeJS.ProcessEnv = process.env): {
userDataDir?: string;
devtoolsPort?: number;
source?: BrowserOverrideSource;
} {
const explicitUserDataDir = resolveBrowserUserDataDir(env.CCS_BROWSER_USER_DATA_DIR);
if (explicitUserDataDir) {
return {
userDataDir: explicitUserDataDir,
devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT),
source: 'CCS_BROWSER_USER_DATA_DIR',
};
}
const legacyProfileDir = resolveBrowserUserDataDir(env.CCS_BROWSER_PROFILE_DIR);
if (legacyProfileDir) {
return {
userDataDir: legacyProfileDir,
devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT),
source: 'CCS_BROWSER_PROFILE_DIR',
};
}
return {};
}
export function getEffectiveClaudeBrowserAttachConfig(
config: BrowserConfig,
env: NodeJS.ProcessEnv = process.env
): EffectiveClaudeBrowserAttachConfig {
const override = getBrowserAttachOverride(env);
const configUserDataDir =
resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir();
const configPort = normalizeDevtoolsPort(config.claude.devtools_port);
if (override.userDataDir) {
return {
enabled: true,
source: override.source as BrowserOverrideSource,
overrideActive: true,
userDataDir: override.userDataDir,
devtoolsPort: override.devtoolsPort ?? configPort,
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
};
}
return {
enabled: config.claude.enabled,
source: 'config',
overrideActive: false,
userDataDir: configUserDataDir,
devtoolsPort: configPort,
// Config-backed browser attach always keeps an explicit port so launches
// stay aligned with Settings > Browser, even when the effective value is
// the default 9222.
hasExplicitDevtoolsPort: true,
};
}
function parseDevtoolsPort(value?: string): number | undefined {
if (!value?.trim() || !/^\d+$/.test(value.trim())) {
return undefined;
}
return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10));
}
function normalizeDevtoolsPort(value: number | undefined): number {
if (!Number.isFinite(value)) {
return 9222;
}
const port = Math.floor(value as number);
if (port < 1 || port > 65535) {
return 9222;
}
return port;
}
+188
View File
@@ -0,0 +1,188 @@
import { getBrowserConfig } from '../../config/unified-config-loader';
import { getCodexBinaryInfo } from '../../targets/codex-detector';
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer';
import { getNodePlatformKey } from './platform';
import {
getEffectiveClaudeBrowserAttachConfig,
getRecommendedBrowserUserDataDir,
} from './browser-settings';
export interface BrowserLaunchCommands {
darwin: string;
linux: string;
win32: string;
}
export interface ClaudeBrowserStatus {
enabled: boolean;
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
overrideActive: boolean;
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
title: string;
detail: string;
nextStep: string;
effectiveUserDataDir: string;
recommendedUserDataDir: string;
devtoolsPort: number;
managedMcpServerName: string;
managedMcpServerPath: string;
launchCommands: BrowserLaunchCommands;
runtimeEnv?: BrowserRuntimeEnv;
}
export interface CodexBrowserStatus {
enabled: boolean;
state: 'disabled' | 'enabled' | 'unsupported_build';
title: string;
detail: string;
nextStep: string;
serverName: string;
supportsConfigOverrides: boolean;
binaryPath: string | null;
version?: string;
}
export interface BrowserStatusPayload {
claude: ClaudeBrowserStatus;
codex: CodexBrowserStatus;
}
export async function getBrowserStatus(): Promise<BrowserStatusPayload> {
const browserConfig = getBrowserConfig();
return {
claude: await buildClaudeBrowserStatus(browserConfig),
codex: buildCodexBrowserStatus(browserConfig),
};
}
async function buildClaudeBrowserStatus(
browserConfig = getBrowserConfig()
): Promise<ClaudeBrowserStatus> {
const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort);
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
enabled: effective.enabled,
source: effective.source,
overrideActive: effective.overrideActive,
effectiveUserDataDir: effective.userDataDir,
recommendedUserDataDir: getRecommendedBrowserUserDataDir(),
devtoolsPort: effective.devtoolsPort,
managedMcpServerName: getBrowserMcpServerName(),
managedMcpServerPath: getBrowserMcpServerPath(),
launchCommands,
};
if (!effective.enabled) {
return {
...base,
state: 'disabled',
title: 'Claude Browser Attach is disabled.',
detail:
'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.',
nextStep:
'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.',
};
}
try {
const runtimeEnv = await resolveBrowserRuntimeEnv({
profileDir: effective.userDataDir,
devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined,
});
return {
...base,
state: 'ready',
title: 'Claude Browser Attach is ready.',
detail:
'CCS can reach the configured Chrome DevTools endpoint for the current attach session.',
nextStep: 'Launch a Claude-target CCS session to use the managed browser MCP runtime.',
runtimeEnv,
};
} catch (error) {
const message = (error as Error).message;
if (message.includes('Chrome profile directory is invalid')) {
return {
...base,
state: 'path_missing',
title: 'Claude Browser Attach path is missing.',
detail: message,
nextStep: `Create or choose a Chrome user-data directory, then launch Chrome with attach mode enabled. Example: ${launchCommands[getNodePlatformKey()]}`,
};
}
if (message.includes('Chrome reuse metadata')) {
return {
...base,
state: 'browser_not_running',
title: 'Claude Browser Attach could not find a running browser session.',
detail: message,
nextStep: `Start Chrome with remote debugging and the configured user-data dir. Example: ${launchCommands[getNodePlatformKey()]}`,
};
}
return {
...base,
state: 'endpoint_unreachable',
title: 'Claude Browser Attach could not reach the DevTools endpoint.',
detail: message,
nextStep: `Restart the attach browser session or confirm the configured port. Example: ${launchCommands[getNodePlatformKey()]}`,
};
}
}
function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrowserStatus {
if (!browserConfig.codex.enabled) {
return {
enabled: false,
state: 'disabled',
title: 'Codex Browser Tools are disabled.',
detail: 'CCS will not inject Playwright MCP browser tooling into Codex-target launches.',
nextStep:
'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.',
serverName: 'ccs_browser',
supportsConfigOverrides: false,
binaryPath: null,
};
}
const binaryInfo = getCodexBinaryInfo({ includeVersion: true, includeFeatures: true });
const supportsConfigOverrides = Boolean(binaryInfo?.features?.includes('config-overrides'));
if (!binaryInfo || !supportsConfigOverrides) {
return {
enabled: true,
state: 'unsupported_build',
title: 'Codex Browser Tools need a Codex build with --config override support.',
detail: binaryInfo
? `Detected Codex at ${binaryInfo.path}, but it does not advertise --config overrides.`
: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.',
nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.',
serverName: 'ccs_browser',
supportsConfigOverrides,
binaryPath: binaryInfo?.path ?? null,
version: binaryInfo?.version,
};
}
return {
enabled: true,
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.',
nextStep: 'Use a Codex-target CCS launch to access browser tools.',
serverName: 'ccs_browser',
supportsConfigOverrides,
binaryPath: binaryInfo.path,
version: binaryInfo.version,
};
}
function buildLaunchCommands(userDataDir: string, devtoolsPort: number): BrowserLaunchCommands {
const quotedPath = JSON.stringify(userDataDir);
return {
darwin: `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
linux: `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
win32: `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
};
}
+13
View File
@@ -17,9 +17,22 @@ export {
export { appendBrowserToolArgs } from './claude-tool-args';
export {
getRecommendedBrowserUserDataDir,
getBrowserAttachOverride,
getEffectiveClaudeBrowserAttachConfig,
} from './browser-settings';
export {
resolveBrowserRuntimeEnv,
resolveDefaultChromeUserDataDir,
resolveConfiguredBrowserProfileDir,
} from './chrome-reuse';
export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse';
export { getBrowserStatus } from './browser-status';
export type {
BrowserStatusPayload,
ClaudeBrowserStatus,
CodexBrowserStatus,
} from './browser-status';
+9
View File
@@ -0,0 +1,9 @@
export type BrowserPlatformKey = 'darwin' | 'linux' | 'win32';
export function getNodePlatformKey(
platform: NodeJS.Platform = process.platform
): BrowserPlatformKey {
if (platform === 'darwin') return 'darwin';
if (platform === 'win32') return 'win32';
return 'linux';
}
@@ -396,7 +396,7 @@ function resolveBackend(
};
}
if (profileType === 'cursor' || profileName === 'cursor') {
if (profileType === 'cursor') {
return {
backendId: null,
backendDisplayName: null,
+138
View File
@@ -0,0 +1,138 @@
import { Router, type Request, type Response } from 'express';
import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
import { getBrowserStatus } from '../../utils/browser';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router();
const BROWSER_LOCAL_ACCESS_ERROR =
'Browser endpoints require localhost access when dashboard auth is disabled.';
interface BrowserRouteBody {
claude?: {
enabled?: boolean;
userDataDir?: string;
devtoolsPort?: number;
};
codex?: {
enabled?: boolean;
};
}
function isValidDevtoolsPort(value: number): boolean {
return Number.isInteger(value) && value >= 1 && value <= 65535;
}
router.use((req: Request, res: Response, next) => {
if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) {
next();
}
});
router.get('/', async (_req: Request, res: Response): Promise<void> => {
try {
const config = getBrowserConfig();
const status = await getBrowserStatus();
res.json({
config: toBrowserRouteConfig(config),
status,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
try {
res.json(await getBrowserStatus());
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
router.put('/', async (req: Request, res: Response): Promise<void> => {
if (
req.body === null ||
req.body === undefined ||
typeof req.body !== 'object' ||
Array.isArray(req.body)
) {
res.status(400).json({ error: 'Invalid request body. Must be an object.' });
return;
}
const { claude, codex } = req.body as BrowserRouteBody;
if (claude && (typeof claude !== 'object' || Array.isArray(claude))) {
res.status(400).json({ error: 'Invalid value for claude. Must be an object.' });
return;
}
if (codex && (typeof codex !== 'object' || Array.isArray(codex))) {
res.status(400).json({ error: 'Invalid value for codex. Must be an object.' });
return;
}
if (claude?.enabled !== undefined && typeof claude.enabled !== 'boolean') {
res.status(400).json({ error: 'Invalid value for claude.enabled. Must be a boolean.' });
return;
}
if (claude?.userDataDir !== undefined && typeof claude.userDataDir !== 'string') {
res.status(400).json({ error: 'Invalid value for claude.userDataDir. Must be a string.' });
return;
}
if (
claude?.devtoolsPort !== undefined &&
(typeof claude.devtoolsPort !== 'number' || !isValidDevtoolsPort(claude.devtoolsPort))
) {
res.status(400).json({
error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.',
});
return;
}
if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') {
res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' });
return;
}
try {
const current = getBrowserConfig();
const nextClaudeUserDataDir =
claude?.userDataDir === undefined ? current.claude.user_data_dir : claude.userDataDir.trim();
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: claude?.enabled ?? current.claude.enabled,
user_data_dir: nextClaudeUserDataDir,
devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port,
},
codex: {
enabled: codex?.enabled ?? current.codex.enabled,
},
};
});
const config = getBrowserConfig();
const status = await getBrowserStatus();
res.json({
success: true,
browser: {
config: toBrowserRouteConfig(config),
status,
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
function toBrowserRouteConfig(config: ReturnType<typeof getBrowserConfig>) {
return {
claude: {
enabled: config.claude.enabled,
userDataDir: config.claude.user_data_dir,
devtoolsPort: config.claude.devtools_port,
},
codex: {
enabled: config.codex.enabled,
},
};
}
export default router;
+161 -1
View File
@@ -33,6 +33,7 @@ import {
} from '../../cliproxy/proxy-target-resolver';
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
import { invalidateQuotaCache } from '../../cliproxy/quota-response-cache';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
import {
@@ -42,6 +43,7 @@ import {
listProviderTokenSnapshots,
registerAccountFromToken,
} from '../../cliproxy/auth/token-manager';
import { parseGitLabPatAuthResponse } from '../../cliproxy/auth/gitlab-pat-response';
import {
CLIPROXY_CALLBACK_PROVIDER_MAP,
CLIPROXY_AUTH_URL_PROVIDER_MAP,
@@ -190,6 +192,13 @@ function shouldKeepWaitingForLocalToken(
);
}
function invalidateQuotaForRegisteredAccount(account: {
provider: CLIProxyProvider;
id: string;
}): void {
invalidateQuotaCache(account.provider, account.id);
}
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
if (raw === undefined || raw === null) {
return { method: normalizeKiroAuthMethod(), invalid: false };
@@ -221,6 +230,20 @@ function parseKiroIDCFlow(raw: unknown): { flow: KiroIDCFlow; invalid: boolean }
return { flow: normalizeKiroIDCFlow(normalized), invalid: false };
}
function parseGitLabAuthMode(raw: unknown): { mode: 'oauth' | 'pat'; invalid: boolean } {
if (raw === undefined || raw === null || raw === '') {
return { mode: 'oauth', invalid: false };
}
if (typeof raw !== 'string') {
return { mode: 'oauth', invalid: true };
}
const normalized = raw.trim().toLowerCase();
if (normalized === 'oauth' || normalized === 'pat') {
return { mode: normalized, invalid: false };
}
return { mode: 'oauth', invalid: true };
}
export function getKiroStartIDCValidationError(options: {
kiroMethod: KiroAuthMethod;
kiroIDCStartUrl?: string;
@@ -595,6 +618,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const kiroIDCRegion =
typeof requestBody.kiroIDCRegion === 'string' ? requestBody.kiroIDCRegion.trim() : undefined;
const kiroIDCFlowRaw = requestBody.kiroIDCFlow;
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
const gitlabBaseUrl =
typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined;
const gitlabPersonalAccessToken =
typeof requestBody.gitlabPersonalAccessToken === 'string'
? requestBody.gitlabPersonalAccessToken.trim()
: undefined;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const target = getProxyTarget();
if (target.isRemote) {
@@ -605,6 +635,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
const { flow: kiroIDCFlow, invalid: invalidKiroIDCFlow } = parseKiroIDCFlow(kiroIDCFlowRaw);
const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } =
parseGitLabAuthMode(gitlabAuthModeRaw);
// Validate provider
if (!validProviders.includes(provider as CLIProxyProvider)) {
@@ -620,6 +652,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
return;
}
if (provider === 'gitlab' && invalidGitLabAuthMode) {
res.status(400).json({
error: 'Invalid gitlabAuthMode. Supported: oauth, pat',
code: 'INVALID_GITLAB_AUTH_MODE',
});
return;
}
if (provider === 'kiro') {
const kiroIDCValidationError = getKiroStartIDCValidationError({
kiroMethod,
@@ -643,6 +683,92 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
}
}
if (provider === 'gitlab' && gitlabAuthMode === 'pat') {
if (!gitlabPersonalAccessToken) {
res.status(400).json({
error: 'gitlabPersonalAccessToken is required when gitlabAuthMode=pat',
code: 'MISSING_GITLAB_PAT',
});
return;
}
try {
const localProvider = provider as CLIProxyProvider;
const knownTokenFiles = listProviderTokenSnapshots(localProvider);
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildManagementHeaders(target),
},
body: JSON.stringify({
...(gitlabBaseUrl ? { base_url: gitlabBaseUrl } : {}),
personal_access_token: gitlabPersonalAccessToken,
}),
});
const responseBody = await response.text();
const parsedResponse = parseGitLabPatAuthResponse(
response.ok,
response.status,
responseBody,
gitlabPersonalAccessToken
);
if (!parsedResponse.ok) {
res.status(response.ok ? 400 : response.status).json({
error: parsedResponse.errorMessage || 'GitLab PAT authentication failed',
});
return;
}
const tokenSnapshot = findNewTokenSnapshot(
listProviderTokenSnapshots(localProvider),
knownTokenFiles
);
if (!tokenSnapshot) {
res.status(409).json({
error: 'GitLab PAT authentication completed, but CCS could not find the saved token.',
});
return;
}
const account = registerAccountFromToken(
localProvider,
getProviderTokenDir(localProvider),
nickname,
false,
tokenSnapshot.file
);
if (!account) {
res.status(409).json({
error: 'GitLab PAT authentication succeeded, but account registration failed.',
});
return;
}
try {
await ensureManagedModelPrefixes([account.provider]);
} catch {
// Keep auth success path non-fatal when prefix repair cannot run.
}
res.json({
success: true,
account: {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
},
});
return;
} catch (error) {
respondInternalError(res, error, 'Failed to start GitLab PAT flow.');
return;
}
}
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
@@ -673,6 +799,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
kiroIDCRegion: provider === 'kiro' ? kiroIDCRegion : undefined,
kiroIDCFlow: provider === 'kiro' && kiroMethod === 'idc' ? kiroIDCFlow : undefined,
gitlabAuthMode: provider === 'gitlab' ? gitlabAuthMode : undefined,
gitlabBaseUrl: provider === 'gitlab' ? gitlabBaseUrl : undefined,
fromUI: true, // Enable project selection prompt in UI
noIncognito, // Kiro: use normal browser if enabled
});
@@ -829,9 +957,14 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
const gitlabBaseUrl =
typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } =
parseGitLabAuthMode(gitlabAuthModeRaw);
// Check remote mode
const target = getProxyTarget();
@@ -854,6 +987,22 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return;
}
if (provider === 'gitlab' && invalidGitLabAuthMode) {
res.status(400).json({
error: 'Invalid gitlabAuthMode. Supported: oauth, pat',
code: 'INVALID_GITLAB_AUTH_MODE',
});
return;
}
if (provider === 'gitlab' && gitlabAuthMode === 'pat') {
res.status(400).json({
error: 'GitLab PAT login must use /api/cliproxy/auth/gitlab/start',
code: 'GITLAB_PAT_REQUIRES_START',
});
return;
}
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
if (!validation.valid) {
@@ -892,11 +1041,18 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
provider === 'kiro' && kiroManagementMethod
? `&method=${encodeURIComponent(kiroManagementMethod)}`
: '';
const gitlabQuery =
provider === 'gitlab' && gitlabBaseUrl
? `&base_url=${encodeURIComponent(gitlabBaseUrl)}`
: '';
// 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${kiroQuery}`),
buildProxyUrl(
target,
`/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}`
),
{ headers: buildManagementHeaders(target) }
);
@@ -1022,6 +1178,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
} catch {
// Keep manual callback success path non-fatal when prefix repair cannot run.
}
invalidateQuotaForRegisteredAccount(account);
res.json({
status: 'ok',
account: {
@@ -1173,6 +1330,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
// Keep manual callback success path non-fatal when prefix repair cannot run.
}
}
invalidateQuotaForRegisteredAccount(account);
res.json({
success: true,
@@ -1205,6 +1363,8 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
return;
}
invalidateQuotaForRegisteredAccount(account);
res.json({
success: true,
account: {
+4
View File
@@ -673,6 +673,10 @@ router.put('/', (req: Request, res: Response): void => {
currentConfig.cliproxy = mergeCliproxyConfig(currentConfig, config);
}
if (config.proxy !== undefined) {
currentConfig.proxy = config.proxy;
}
if (config.preferences !== undefined) {
currentConfig.preferences = config.preferences;
}
+3
View File
@@ -19,6 +19,7 @@ import settingsRoutes from './settings-routes';
import channelsRoutes from './channels-routes';
import websearchRoutes from './websearch-routes';
import imageAnalysisRoutes from './image-analysis-routes';
import browserRoutes from './browser-routes';
import cliproxyAuthRoutes from './cliproxy-auth-routes';
import cliproxyStatsRoutes from './cliproxy-stats-routes';
import cliproxyRoutingRoutes from './cliproxy-routing-routes';
@@ -97,6 +98,7 @@ apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
// ==================== WebSearch ====================
apiRoutes.use('/websearch', websearchRoutes);
apiRoutes.use('/browser', browserRoutes);
apiRoutes.use('/image-analysis', imageAnalysisRoutes);
// ==================== Copilot ====================
@@ -104,6 +106,7 @@ apiRoutes.use('/copilot', copilotRoutes);
// ==================== Cursor ====================
apiRoutes.use('/cursor', cursorRoutes);
apiRoutes.use('/legacy/cursor', cursorRoutes);
// ==================== Droid ====================
apiRoutes.use('/droid', droidRoutes);
+12
View File
@@ -30,6 +30,7 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar
import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import { resolveProviderSettingsPath } from '../../cliproxy/config/env-builder';
import { expandPath } from '../../utils/helpers';
import {
canonicalizeModelIdForProvider,
@@ -101,6 +102,17 @@ function resolveSettingsPath(profileOrVariant: string): string {
const ccsDir = getCcsDir();
const resolvedCcsDir = path.resolve(ccsDir);
const directProvider = mapExternalProviderName(profileOrVariant);
if (directProvider) {
if (profileOrVariant !== directProvider) {
return resolvePathWithin(
resolvedCcsDir,
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
);
}
return path.resolve(resolveProviderSettingsPath(directProvider));
}
// Check if this is a variant
const variants = listVariants();
const variant = variants[profileOrVariant];
@@ -114,6 +114,7 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record<string, CompatibleCliDocsRegistryEntr
'CLI --profile selects a named [profiles.<name>] overlay on top of base config',
'CCS-backed Codex launches may apply transient -c overrides and CCS_CODEX_API_KEY',
'Official docs treat model_providers, mcp_servers, features, and project trust as schema-backed config surfaces',
'CCS-managed browser tooling for Codex should be configured from Settings > Browser, not by editing the ccs_browser MCP entry directly',
],
links: [
{
@@ -0,0 +1,188 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
import * as path from 'path';
import { spawn, spawnSync } from 'child_process';
import getPort from 'get-port';
const DIST_ENTRY = path.join(process.cwd(), 'dist', 'ccs.js');
let originalCcsHome: string | undefined;
let tempDir: string;
let upstreamServer: http.Server;
let upstreamBody: unknown;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-e2e-'));
upstreamBody = undefined;
});
afterEach(() => {
try {
upstreamServer?.close();
} catch {
// Best-effort cleanup.
}
spawnSync(process.execPath, [DIST_ENTRY, 'proxy', 'stop'], {
env: { ...process.env, CCS_HOME: tempDir },
});
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
function startMockUpstream(port: number): Promise<void> {
return new Promise((resolve) => {
upstreamServer = http.createServer(async (req, res) => {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
upstreamBody = JSON.parse(body);
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
);
res.end('data: [DONE]\n\n');
});
upstreamServer.listen(port, '127.0.0.1', () => resolve());
});
}
function runCli(args: string[], env: Record<string, string>): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [DIST_ENTRY, ...args], {
env: {
...process.env,
...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('close', (code) => {
resolve({ code, stdout, stderr });
});
});
}
beforeAll(() => {
const result = spawnSync(process.execPath, ['run', 'build'], {
encoding: 'utf8',
env: process.env,
});
expect(result.status).toBe(0);
});
describe('openai provider routing e2e', () => {
it('routes a settings profile through the local proxy into an OpenAI-compatible upstream', async () => {
const upstreamPort = await getPort();
await startMockUpstream(upstreamPort);
const ccsDir = path.join(tempDir, '.ccs');
const binDir = path.join(tempDir, 'bin');
const outputPath = path.join(tempDir, 'claude-output.json');
fs.mkdirSync(ccsDir, { recursive: true });
fs.mkdirSync(binDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'hf.settings.json');
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { hf: settingsPath } }, null, 2),
'utf8'
);
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
ANTHROPIC_AUTH_TOKEN: 'hf_token',
ANTHROPIC_MODEL: 'hf-model',
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
},
}),
'utf8'
);
fs.writeFileSync(
path.join(binDir, 'claude'),
`#!/usr/bin/env node
const fs = require('fs');
(async () => {
const response = await fetch(\`\${process.env.ANTHROPIC_BASE_URL}/v1/messages\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'x-api-key': process.env.ANTHROPIC_AUTH_TOKEN,
},
body: JSON.stringify({
model: process.env.ANTHROPIC_MODEL,
stream: true,
tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }],
messages: [{ role: 'user', content: 'Find docs' }],
}),
});
const text = await response.text();
fs.writeFileSync(process.env.CCS_E2E_OUTPUT, JSON.stringify({
status: response.status,
baseUrl: process.env.ANTHROPIC_BASE_URL,
authToken: process.env.ANTHROPIC_AUTH_TOKEN,
text
}, null, 2));
})().catch((error) => {
console.error(error);
process.exit(1);
});
`,
{ mode: 0o755 }
);
const result = await runCli(['hf'], {
...process.env,
CCS_HOME: tempDir,
CCS_E2E_OUTPUT: outputPath,
PATH: `${binDir}:${process.env.PATH || ''}`,
});
expect(result.code).toBe(0);
const payload = JSON.parse(fs.readFileSync(outputPath, 'utf8')) as {
status: number;
baseUrl: string;
authToken: string;
text: string;
};
expect(payload.status).toBe(200);
expect(payload.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(payload.authToken).toMatch(/^[a-f0-9]{48}$/);
expect(payload.text).toContain('event: message_start');
expect(payload.text).toContain('tool_use');
expect(payload.text).toContain('message_stop');
const parsedUpstream = upstreamBody as {
messages?: Array<{ role: string; content: string }>;
tools?: Array<{ type: string }>;
};
expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' });
expect(parsedUpstream.tools?.[0]?.type).toBe('function');
}, 35000);
});
+110
View File
@@ -0,0 +1,110 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import getPort from 'get-port';
const DIST_ENTRY = path.join(process.cwd(), 'dist', 'ccs.js');
let originalCcsHome: string | undefined;
let tempDir: string;
function runCli(args: string[], extraEnv: Record<string, string> = {}) {
return spawnSync(process.execPath, [DIST_ENTRY, ...args], {
encoding: 'utf8',
env: {
...process.env,
CCS_HOME: tempDir,
...extraEnv,
},
});
}
beforeAll(() => {
const result = spawnSync(process.execPath, ['run', 'build'], {
encoding: 'utf8',
env: process.env,
});
expect(result.status).toBe(0);
});
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-e2e-'));
});
afterEach(() => {
runCli(['proxy', 'stop']);
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('proxy command e2e', () => {
it('starts, reports status, activates, and stops via the built CLI', async () => {
const port = await getPort();
const ccsDir = path.join(tempDir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'hf.settings.json');
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { hf: settingsPath } }, null, 2),
'utf8'
);
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
ANTHROPIC_AUTH_TOKEN: 'ollama',
ANTHROPIC_MODEL: 'qwen3-coder',
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
},
}),
'utf8'
);
const started = runCli(['proxy', 'start', 'hf', '--port', String(port), '--host', '127.0.0.1']);
expect(started.status).toBe(0);
const status = runCli(['proxy', 'status']);
expect(status.stdout).toContain(`Proxy running on port ${port}`);
expect(status.stdout).toContain('Host: 127.0.0.1');
expect(status.stdout).toContain('Profile: hf');
const activate = runCli(['proxy', 'activate', '--shell', 'bash']);
expect(activate.stdout).toContain(`export ANTHROPIC_BASE_URL='http://127.0.0.1:${port}'`);
expect(activate.stdout).toMatch(/export ANTHROPIC_AUTH_TOKEN='[a-f0-9]{48}'/);
expect(activate.stdout).toContain("export DISABLE_TELEMETRY='1'");
expect(activate.stdout).toContain("export DISABLE_COST_WARNINGS='1'");
expect(activate.stdout).toContain("export API_TIMEOUT_MS='600000'");
expect(activate.stdout).toContain("export NO_PROXY='127.0.0.1,localhost'");
const activateFish = runCli(['proxy', 'activate', '--fish']);
expect(activateFish.stdout).toContain(`set -gx ANTHROPIC_BASE_URL 'http://127.0.0.1:${port}'`);
const health = await fetch(`http://127.0.0.1:${port}/health`);
expect(health.status).toBe(200);
const info = await fetch(`http://127.0.0.1:${port}/`);
expect(info.status).toBe(200);
await expect(info.json()).resolves.toMatchObject({
ok: true,
service: 'ccs-openai-compat-proxy',
bind: {
host: '127.0.0.1',
port,
},
profile: {
name: 'hf',
},
});
const stopped = runCli(['proxy', 'stop']);
expect(stopped.status).toBe(0);
}, 35000);
});
@@ -74,7 +74,7 @@ describe('cursor daemon lifecycle smoke', () => {
};
expect(anthropicBody.type).toBe('error');
expect(anthropicBody.error?.type).toBe('authentication_error');
expect(anthropicBody.error?.message).toContain('Run `ccs cursor auth` first');
expect(anthropicBody.error?.message).toContain('Run `ccs legacy cursor auth` first');
const stopResult = await stopDaemon();
expect(stopResult.success).toBe(true);
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import getPort from 'get-port';
import {
getOpenAICompatProxyStatus,
startOpenAICompatProxy,
stopOpenAICompatProxy,
} from '../../../src/proxy/proxy-daemon';
import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
let originalCcsHome: string | undefined;
let tempDir: string;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-proxy-'));
process.env.CCS_HOME = tempDir;
});
afterEach(async () => {
await stopOpenAICompatProxy();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('openai proxy daemon lifecycle', () => {
it('starts, reports status, serves health/models, and stops', async () => {
const port = await getPort();
const settingsPath = path.join(tempDir, 'hf.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
ANTHROPIC_AUTH_TOKEN: 'ollama',
ANTHROPIC_MODEL: 'qwen3-coder',
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
},
}),
'utf8'
);
const profile = resolveOpenAICompatProfileConfig('hf', settingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
ANTHROPIC_AUTH_TOKEN: 'ollama',
ANTHROPIC_MODEL: 'qwen3-coder',
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
});
if (!profile) {
throw new Error('Expected an OpenAI-compatible profile');
}
const started = await startOpenAICompatProxy(profile, { port });
expect(started.success).toBe(true);
expect(started.authToken).toBeTruthy();
const status = await getOpenAICompatProxyStatus();
expect(status.running).toBe(true);
expect(status.profileName).toBe('hf');
expect(status.authToken).toBe(started.authToken);
const health = await fetch(`http://127.0.0.1:${port}/health`);
expect(health.status).toBe(200);
const models = (await (
await fetch(`http://127.0.0.1:${port}/v1/models`, {
headers: { 'x-api-key': started.authToken! },
})
).json()) as { data?: Array<{ id: string }> };
expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']);
const stopped = await stopOpenAICompatProxy();
expect(stopped.success).toBe(true);
expect((await getOpenAICompatProxyStatus()).running).toBe(false);
}, 35000);
it('refuses to replace a running proxy for a different profile', async () => {
const firstPort = await getPort();
const firstSettingsPath = path.join(tempDir, 'hf.settings.json');
fs.writeFileSync(
firstSettingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
ANTHROPIC_AUTH_TOKEN: 'ollama',
ANTHROPIC_MODEL: 'qwen3-coder',
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
},
}),
'utf8'
);
const firstProfile = resolveOpenAICompatProfileConfig('hf', firstSettingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
ANTHROPIC_AUTH_TOKEN: 'ollama',
ANTHROPIC_MODEL: 'qwen3-coder',
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
});
if (!firstProfile) {
throw new Error('Expected first OpenAI-compatible profile');
}
const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort });
expect(firstStart.success).toBe(true);
const secondProfile = resolveOpenAICompatProfileConfig('openai', path.join(tempDir, 'openai.settings.json'), {
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
ANTHROPIC_AUTH_TOKEN: 'sk-openai',
ANTHROPIC_MODEL: 'gpt-4.1',
});
if (!secondProfile) {
throw new Error('Expected second OpenAI-compatible profile');
}
const secondStart = await startOpenAICompatProxy(secondProfile, { port: await getPort() });
expect(secondStart.success).toBe(false);
expect(secondStart.error).toContain('Proxy already running for profile "hf"');
const health = await fetch(`http://127.0.0.1:${firstPort}/health`);
expect(health.status).toBe(200);
});
});
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff. Show more