mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-17 08:14:16 +00:00
Merge pull request #1695 from kaitranntt/kai/release/dev-to-main-20260808
feat: promote dev to main
This commit is contained in:
220 files changed
+16174
-7273
No files matched your search
@@ -1,6 +1,6 @@
|
||||
name: Documentation improvement
|
||||
description: Report missing, outdated, or confusing docs.
|
||||
title: "docs: "
|
||||
title: 'docs: '
|
||||
labels:
|
||||
- documentation
|
||||
body:
|
||||
@@ -24,7 +24,7 @@ body:
|
||||
attributes:
|
||||
label: File or page
|
||||
description: Path or URL if you know it.
|
||||
placeholder: README.md or docs/cursor-integration.md
|
||||
placeholder: README.md or https://docs.ccs.kaitran.ca/providers/oauth/cursor
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
|
||||
@@ -99,11 +99,12 @@ jobs:
|
||||
contents: read
|
||||
env:
|
||||
OPENAI_KEY: ${{ secrets.AI_REVIEW_API_KEY }}
|
||||
"OPENAI.API_BASE": ${{ vars.AI_REVIEW_BASE_URL }}
|
||||
"config.model": ${{ vars.AI_REVIEW_MODEL }}
|
||||
"github_action_config.auto_review": "true"
|
||||
"github_action_config.auto_describe": "false"
|
||||
"github_action_config.auto_improve": "false"
|
||||
'OPENAI.API_BASE': ${{ vars.AI_REVIEW_BASE_URL }}
|
||||
'config.model': ${{ vars.AI_REVIEW_MODEL }}
|
||||
'config.reasoning_effort': ${{ vars.AI_REVIEW_REASONING_EFFORT }}
|
||||
'github_action_config.auto_review': 'true'
|
||||
'github_action_config.auto_describe': 'false'
|
||||
'github_action_config.auto_improve': 'false'
|
||||
|
||||
steps:
|
||||
- name: Generate App Token
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
name: Docs – Quickstart Snippet Parity
|
||||
name: Docs – Parity and Freshness
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "docs/quickstart-snippet.md"
|
||||
- "README.md"
|
||||
- "docker/README.md"
|
||||
- "tests/docs/quickstart-parity.sh"
|
||||
- ".github/workflows/docs-parity.yml"
|
||||
- 'docs/quickstart-snippet.md'
|
||||
- 'README.md'
|
||||
- 'docker/README.md'
|
||||
- 'macos-bar/README.md'
|
||||
- 'docs/**'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- 'src/**'
|
||||
- 'scripts/hardening-inventory.js'
|
||||
- 'scripts/maintainability-metrics.js'
|
||||
- 'scripts/runtime-source-classifier.js'
|
||||
- 'tests/docs/quickstart-parity.sh'
|
||||
- 'tests/docs/documentation-freshness.js'
|
||||
- '.github/workflows/docs-parity.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- "docs/quickstart-snippet.md"
|
||||
- "README.md"
|
||||
- "docker/README.md"
|
||||
- "tests/docs/quickstart-parity.sh"
|
||||
- ".github/workflows/docs-parity.yml"
|
||||
- 'docs/quickstart-snippet.md'
|
||||
- 'README.md'
|
||||
- 'docker/README.md'
|
||||
- 'macos-bar/README.md'
|
||||
- 'docs/**'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- 'src/**'
|
||||
- 'scripts/hardening-inventory.js'
|
||||
- 'scripts/maintainability-metrics.js'
|
||||
- 'scripts/runtime-source-classifier.js'
|
||||
- 'tests/docs/quickstart-parity.sh'
|
||||
- 'tests/docs/documentation-freshness.js'
|
||||
- '.github/workflows/docs-parity.yml'
|
||||
|
||||
jobs:
|
||||
quickstart-parity:
|
||||
name: Assert quickstart snippet matches in README.md and docker/README.md
|
||||
docs-parity:
|
||||
name: Assert documentation parity and freshness
|
||||
if: >-
|
||||
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
|
||||
|| github.event_name == 'push'
|
||||
runs-on: [self-hosted, linux, x64, cliproxy]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -32,3 +47,9 @@ jobs:
|
||||
|
||||
- name: Run quickstart parity check
|
||||
run: bash tests/docs/quickstart-parity.sh
|
||||
|
||||
- name: Validate documentation pointers and relative links
|
||||
run: node tests/docs/documentation-freshness.js
|
||||
|
||||
- name: Validate generated hardening inventory
|
||||
run: node scripts/hardening-inventory.js --check
|
||||
@@ -38,6 +38,23 @@ Profile resolution priority:
|
||||
|
||||
All env values written into settings must be strings.
|
||||
|
||||
## Documentation Truth
|
||||
|
||||
Use the narrowest authoritative source:
|
||||
|
||||
1. Source, tests, package scripts, and workflows define implemented behavior.
|
||||
2. `CLAUDE.md` and `CONTRIBUTING.md` define repository workflow.
|
||||
3. `docs/README.md` maps maintainer documentation and its owners.
|
||||
4. The separate `kaitranntt/ccs-docs` repository and published site own user
|
||||
guides and CLI reference.
|
||||
5. Generated artifacts and live runtime checks define what shipped or is
|
||||
currently running.
|
||||
|
||||
Do not copy inventories, line counts, locale lists, target lists, or command
|
||||
details when a stable source link is enough. Update the owning documentation
|
||||
when behavior, commands, setup, architecture, security posture, or maintainer
|
||||
workflow changes. Remove stale claims instead of preserving them as TODOs.
|
||||
|
||||
## User-Facing Change Checklist
|
||||
|
||||
- Update the matching `--help` handler when CLI behavior changes.
|
||||
@@ -46,7 +63,10 @@ All env values written into settings must be strings.
|
||||
- Use neutral broad examples such as `ccs`, `ccs codex`, `ccs glm`, or
|
||||
`ccs <provider>` unless the page is provider-specific.
|
||||
- If CLI commands, config, providers, install steps, or user workflows change,
|
||||
update the public CCS docs in `/Users/kaitran/CloudPersonal/ccs/docs`.
|
||||
update the separate public CCS docs repository. Maintainers using the
|
||||
standard CloudPersonal checkout may have it at
|
||||
`/Users/kaitran/CloudPersonal/ccs/docs`; fork contributors can use their own
|
||||
checkout and coordinate the matching docs change in the PR.
|
||||
|
||||
Help locations:
|
||||
|
||||
|
||||
+17
-2
@@ -20,7 +20,7 @@ If you are new to the project, start with a docs fix, a focused bug fix, or an i
|
||||
| CLI runtime | `src/`, `lib/`, `config/`, `scripts/` | Add or update tests in `tests/` |
|
||||
| Dashboard UI | `ui/src/` | Run `cd ui && bun run validate` |
|
||||
| Web server and config APIs | `src/web-server/`, `src/api/`, `src/config/` | Add unit or integration coverage |
|
||||
| Documentation | `https://docs.ccs.kaitran.ca`, `README.md`, `docs/`, `CONTRIBUTING.md` | Keep user-facing docs in sync |
|
||||
| Documentation | `https://docs.ccs.kaitran.ca`, `README.md`, [`docs/`](./docs/README.md), `CONTRIBUTING.md` | Update the owning source, not every page |
|
||||
| Static assets | `assets/` | Verify screenshots and references still match |
|
||||
|
||||
Useful directories:
|
||||
@@ -92,6 +92,21 @@ If `CI` or `Push CI` stays queued for a long time, it is a maintainer infrastruc
|
||||
|
||||
`CONTRIBUTING.md` is the human entry point. For AI agents working in this repo, the authoritative automation and workflow rules live in [CLAUDE.md](./CLAUDE.md).
|
||||
|
||||
## Documentation Ownership
|
||||
|
||||
Documentation follows this truth order:
|
||||
|
||||
1. Source, tests, package scripts, and workflows define implemented behavior.
|
||||
2. [CLAUDE.md](./CLAUDE.md) and this guide define repository workflow.
|
||||
3. [docs/README.md](./docs/README.md) maps maintainer documentation.
|
||||
4. [docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca) owns detailed user guides
|
||||
and CLI reference.
|
||||
|
||||
Update documentation when a change affects behavior, commands, setup,
|
||||
architecture, security posture, or contributor workflow. Prefer links to
|
||||
authoritative source files over copied trees, counts, and option lists that
|
||||
drift quickly.
|
||||
|
||||
## AI Review Lane
|
||||
|
||||
CCS PR review no longer depends on `anthropics/claude-code-action`. The repository review lane is self-hosted PR-Agent:
|
||||
@@ -100,7 +115,7 @@ CCS PR review no longer depends on `anthropics/claude-code-action`. The reposito
|
||||
- Use `/review` on the PR when you need a fresh pass after follow-up commits.
|
||||
- Only the trusted `/review` comment path is enabled.
|
||||
- Keep repository-level reviewer instructions in the root `.pr_agent.toml`.
|
||||
- Keep runtime wiring and defaults in `ai-review.yml`, which still maps the existing `AI_REVIEW_BASE_URL`, `AI_REVIEW_MODEL`, and `AI_REVIEW_API_KEY` integrations onto PR-Agent's `OPENAI.*` and `config.*` settings.
|
||||
- Keep runtime wiring and defaults in `ai-review.yml`, which maps the existing `AI_REVIEW_BASE_URL`, `AI_REVIEW_MODEL`, `AI_REVIEW_REASONING_EFFORT`, and `AI_REVIEW_API_KEY` integrations onto PR-Agent's `OPENAI.*` and `config.*` settings.
|
||||
- If you change review defaults, update the workflow or `.pr_agent.toml` alongside the contributor or architecture docs in the same PR.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
# CCS - Claude Code Switch
|
||||
# CCS - Claude Codex Switch
|
||||
|
||||

|
||||
|
||||
@@ -94,7 +94,8 @@ 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)
|
||||
Guide:
|
||||
[OpenAI-Compatible Provider Routing](https://docs.ccs.kaitran.ca/features/proxy/openai-compatible-providers).
|
||||
|
||||
### Related Project: claude-code-router
|
||||
|
||||
@@ -166,7 +167,7 @@ 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. 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).
|
||||
[Browser Automation](https://docs.ccs.kaitran.ca/features/workflow/browser-automation).
|
||||
|
||||
## Docs Matrix
|
||||
|
||||
@@ -183,7 +184,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) |
|
||||
| Understand browser attach vs Codex browser tooling | [Browser Automation](https://docs.ccs.kaitran.ca/features/workflow/browser-automation) |
|
||||
| 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) |
|
||||
@@ -216,6 +217,7 @@ ccs ollama "summarize these logs"
|
||||
- Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||
- Daily local gate: `bun run format && bun run lint:fix && bun run validate` (`validate` is the fast path only)
|
||||
- Before review or merge confidence: `bun run validate:ci-parity`
|
||||
- Cross-runtime proxy transport: `bun run test:runtime-matrix` (Node 18/22/26 and Bun)
|
||||
- If PR checks stay queued for more than 10 minutes, assume the self-hosted runner is offline and notify a maintainer instead of retrying blindly
|
||||
- Starter work:
|
||||
[good first issue](https://github.com/kaitranntt/ccs/labels/good%20first%20issue),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,67 @@
|
||||
# CCS Maintainer Documentation
|
||||
|
||||
This directory explains how the CLI repository is organized and maintained.
|
||||
User-facing guides and command reference live at
|
||||
[docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca).
|
||||
|
||||
## Truth Hierarchy
|
||||
|
||||
Use the narrowest authoritative source:
|
||||
|
||||
1. Source, tests, package scripts, and workflows define implemented behavior.
|
||||
2. [`CLAUDE.md`](../CLAUDE.md) and
|
||||
[`CONTRIBUTING.md`](../CONTRIBUTING.md) define repository workflow.
|
||||
3. The documents below explain architecture, rationale, and maintainer
|
||||
decisions.
|
||||
4. Build artifacts, releases, and runtime checks define shipped or observed
|
||||
state.
|
||||
|
||||
When sources disagree, verify the implementation first and update or remove the
|
||||
stale prose. Avoid copying volatile counts, recursive trees, and exhaustive
|
||||
option lists when a source link can remain accurate.
|
||||
|
||||
## Start Here
|
||||
|
||||
| Need | Owner |
|
||||
| --- | --- |
|
||||
| Repository domains and source entry points | [Codebase summary](./codebase-summary.md) |
|
||||
| Coding, testing, error, and size conventions | [Code standards](./code-standards.md) |
|
||||
| System boundaries and data flow | [System architecture](./system-architecture/index.md) |
|
||||
| Current maintenance direction | [Project roadmap](./project-roadmap.md) |
|
||||
| Release mechanics | [Release process](./release-process.md) |
|
||||
| Dashboard localization | [Dashboard i18n](./i18n-dashboard.md) |
|
||||
| Test layout and commands | [Test suite](../tests/README.md) |
|
||||
| Dashboard development | [UI guide](../ui/README.md) |
|
||||
|
||||
Feature-specific maintainer notes remain in this directory. Discover them by
|
||||
filename, then verify referenced behavior against the linked source.
|
||||
|
||||
## Update Triggers
|
||||
|
||||
Update the owning documentation in the same change when any of these move:
|
||||
|
||||
- CLI or dashboard behavior, commands, flags, or configuration
|
||||
- installation, deployment, release, or contributor workflow
|
||||
- architecture, data flow, persistence, security, or public contracts
|
||||
- source ownership or the stable entry point named by a guide
|
||||
|
||||
Pure refactors need documentation changes only when they invalidate a
|
||||
maintainer decision or navigation link. Public behavior changes also require a
|
||||
matching update in the separate `kaitranntt/ccs-docs` repository on the same
|
||||
target branch. Maintainers using the standard CloudPersonal checkout may have
|
||||
that repository at `/Users/kaitran/CloudPersonal/ccs/docs`; fork contributors
|
||||
can use their own checkout and coordinate the matching docs change in the PR.
|
||||
|
||||
## Validation
|
||||
|
||||
Before review:
|
||||
|
||||
```bash
|
||||
bash tests/docs/quickstart-parity.sh
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The repository-owned docs test currently checks the canonical quickstart
|
||||
snippet only. For other changed Markdown, verify each relative link and
|
||||
referenced path directly, then run the focused validation command for any code
|
||||
or workflow the document describes.
|
||||
+46
-258
@@ -1,273 +1,61 @@
|
||||
# Browser Automation
|
||||
# Browser Automation Developer Contract
|
||||
|
||||
Last Updated: 2026-05-11
|
||||
User setup and troubleshooting live in the canonical
|
||||
[Browser Automation guide](https://docs.ccs.kaitran.ca/features/workflow/browser-automation).
|
||||
This local file retains security-sensitive runtime invariants that contributors
|
||||
must preserve until the public guide covers them fully.
|
||||
|
||||
CCS provides browser automation through two separate runtime paths:
|
||||
## Exposure And Runtime Ownership
|
||||
|
||||
- **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
|
||||
- Claude Browser Attach and Codex Browser Tools are separate lanes; they do not
|
||||
promise a shared browser session.
|
||||
- New installs and upgrades without saved browser settings default both lanes
|
||||
to `enabled: false` and `policy: manual`.
|
||||
- `--browser` and `--no-browser` are one-run exposure overrides. They do not
|
||||
change saved policy.
|
||||
- CCS owns `mcpServers.ccs-browser`, the
|
||||
`~/.ccs/mcp/ccs-browser-server.cjs` runtime, and Codex `ccs_browser`
|
||||
overrides. Generic MCP editors are not the primary setup surface.
|
||||
|
||||
These are related, but they are not the same implementation and they do not promise a shared browser session.
|
||||
On new installs, and on upgrades that do not already have explicit browser settings, both lanes
|
||||
start **disabled** and **manual** so browser tooling is not auto-exposed until you opt in.
|
||||
|
||||
## 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
|
||||
|
||||
Browser policy controls are CLI-first in this release. The dashboard remains the shared setup and
|
||||
status surface, while `ccs browser policy` is the authoritative place to decide whether browser
|
||||
tooling is auto-exposed or kept manual by default. Fresh installs, plus upgrades without an
|
||||
existing browser section, surface both lanes as off/manual until you explicitly enable them.
|
||||
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
ccs help browser
|
||||
ccs browser setup
|
||||
ccs browser status
|
||||
ccs browser doctor
|
||||
ccs browser policy
|
||||
ccs browser policy --all manual
|
||||
```
|
||||
|
||||
Use `ccs browser setup` for the primary one-command setup path. Use `ccs browser status` for
|
||||
the current state, `ccs browser doctor` for read-only troubleshooting guidance, and
|
||||
`ccs browser policy` to control default browser exposure. If you only want browser access for one
|
||||
run, keep policy manual and add `--browser` to that launch.
|
||||
|
||||
### Via Config File
|
||||
|
||||
Edit `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
browser:
|
||||
claude:
|
||||
enabled: false
|
||||
policy: manual
|
||||
user_data_dir: "~/.ccs/browser/chrome-user-data"
|
||||
devtools_port: 9222
|
||||
codex:
|
||||
enabled: false
|
||||
policy: manual
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `claude.policy` and `codex.policy` accept `auto` or `manual`
|
||||
- `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
|
||||
- New installs, plus upgrades without saved browser settings, default both lanes to `enabled: false` and `policy: manual`
|
||||
- `manual` keeps the lane configured but hidden until a launch explicitly opts in with `--browser`
|
||||
|
||||
## Runtime Policy Controls
|
||||
|
||||
CCS now separates **lane enablement** from **default exposure policy**:
|
||||
|
||||
- `enabled: false`
|
||||
- the lane is off; this is the default for both lanes on new installs and upgrades without saved browser settings
|
||||
- `enabled: true` + `policy: auto`
|
||||
- the lane is exposed automatically on matching launches
|
||||
- `enabled: true` + `policy: manual`
|
||||
- the lane stays configured, but CCS keeps browser tooling hidden unless the current launch uses
|
||||
`--browser`
|
||||
|
||||
One-run launch overrides:
|
||||
|
||||
```bash
|
||||
ccs browser policy --all manual
|
||||
ccs glm --browser "inspect the page"
|
||||
ccs glm --no-browser "summarize the docs"
|
||||
ccs default --target codex --browser "use the browser tools for this run"
|
||||
```
|
||||
|
||||
- `--browser` forces browser tooling on for the current launch when that lane is enabled
|
||||
- `--no-browser` suppresses browser tooling for the current launch even when policy is `auto`
|
||||
|
||||
## 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 |
|
||||
| `CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled` | Dangerous local-testing opt-in for Browser MCP response fulfillment; disabled by default |
|
||||
| `CCS_BROWSER_UPLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for local files that browser upload tools may read |
|
||||
| `CCS_BROWSER_DOWNLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for caller-provided browser download directories |
|
||||
|
||||
If an override is active, Browser status surfaces should report that the current session is being
|
||||
managed externally by environment variables.
|
||||
|
||||
Browser MCP request interception can continue or fail matched requests by default. Synthetic response
|
||||
fulfillment (`action: fulfill`) is more sensitive because it can serve caller-supplied response
|
||||
content inside the attached browser's target origin. CCS therefore hides and blocks fulfillment unless
|
||||
`CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled` is set for a trusted local test session.
|
||||
|
||||
The saved browser policy still controls default exposure. Env overrides change the effective attach
|
||||
path/port for the current shell; they do not bypass `policy: manual`.
|
||||
|
||||
Override precedence is:
|
||||
## Attach Override Precedence
|
||||
|
||||
1. `CCS_BROWSER_USER_DATA_DIR`
|
||||
2. `CCS_BROWSER_PROFILE_DIR`
|
||||
3. the persisted `browser.claude.user_data_dir` config value
|
||||
2. legacy `CCS_BROWSER_PROFILE_DIR`
|
||||
3. persisted `browser.claude.user_data_dir`
|
||||
|
||||
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.
|
||||
Config-backed attach always passes an explicit DevTools port, including the
|
||||
default `9222`. Metadata-based port discovery exists only for the legacy
|
||||
profile-dir override when `CCS_BROWSER_DEVTOOLS_PORT` is unset.
|
||||
|
||||
### Browser File Transfer Safety
|
||||
## Interception Safety
|
||||
|
||||
Claude Browser Attach file-transfer tools intentionally use a deny-by-default filesystem boundary:
|
||||
Request interception may continue or fail matched requests by default.
|
||||
Synthetic fulfillment can serve caller-supplied content inside the target
|
||||
origin, so it must stay hidden and blocked unless
|
||||
`CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled` is explicitly set for a trusted
|
||||
local test.
|
||||
|
||||
- Downloads without an explicit `downloadPath` go to a CCS-created temporary session directory.
|
||||
- A caller-provided `downloadPath` must be inside that temporary session directory or inside one of
|
||||
the directories listed in `CCS_BROWSER_DOWNLOAD_ROOTS`.
|
||||
- Local upload and drag-and-drop files must be inside the temporary session download directory or
|
||||
inside one of the directories listed in `CCS_BROWSER_UPLOAD_ROOTS`.
|
||||
- Hidden path segments and common secret locations/files, such as `.ssh`, `.aws`, `.ccs`,
|
||||
`.claude`, `.env`, and private-key filenames, are rejected even inside an allowed root.
|
||||
- Each file-transfer call is limited to 10 files, and each local file must be at most 10 MiB.
|
||||
## Event Observation Safety
|
||||
|
||||
Set upload/download roots only to purpose-built scratch directories. Do not point these variables at
|
||||
your home directory, a source checkout with secrets, or a real cloud/tooling config directory.
|
||||
- `browser_wait_for_event` requires `urlIncludes` for network-request events.
|
||||
- Download events require either `urlIncludes` or
|
||||
`suggestedFilenameIncludes`.
|
||||
- Returned navigation, request, and download URLs must redact query strings,
|
||||
fragments, and path-scoped bearer values before reaching the MCP caller.
|
||||
|
||||
## Managed Runtime Files
|
||||
## File Transfer Safety
|
||||
|
||||
- `~/.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
|
||||
Browser file transfer is deny-by-default:
|
||||
|
||||
Do not treat the generic Codex MCP editor as the primary browser setup path. CCS-managed browser
|
||||
entries should be configured from `Settings -> Browser`.
|
||||
- implicit downloads use a CCS-created temporary session directory;
|
||||
- explicit download paths must remain inside that session directory or a
|
||||
`CCS_BROWSER_DOWNLOAD_ROOTS` allowlisted root;
|
||||
- uploads and drag-and-drop files must remain inside the session directory or a
|
||||
`CCS_BROWSER_UPLOAD_ROOTS` allowlisted root;
|
||||
- hidden segments and common secret locations/files (`.ssh`, `.aws`, `.ccs`,
|
||||
`.claude`, `.env`, private keys) remain denied inside allowed roots;
|
||||
- each call permits at most 10 files, each no larger than 10 MiB.
|
||||
|
||||
## Primary Setup Flow
|
||||
|
||||
The shortest supported setup path is:
|
||||
|
||||
```bash
|
||||
ccs browser setup
|
||||
```
|
||||
|
||||
That flow:
|
||||
|
||||
1. enables Claude Browser Attach in the saved CCS browser config
|
||||
2. leaves launch exposure under the saved policy, so `policy: manual` still requires `--browser`
|
||||
3. keeps the configured DevTools port normalized
|
||||
4. creates the configured browser user-data directory if needed
|
||||
5. prints the exact browser launch command for the current platform
|
||||
6. re-checks readiness and reports the next step if Chrome still needs manual attention
|
||||
|
||||
## 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.
|
||||
|
||||
When Claude Browser Attach uses the recommended managed path (`~/.ccs/browser/chrome-user-data`),
|
||||
CCS now creates that directory automatically the first time it needs it. After that bootstrap step,
|
||||
the remaining requirement is a running Chrome session started with `--remote-debugging-port`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Browser status says Claude Browser Attach is disabled
|
||||
|
||||
Run `ccs browser setup`, enable Claude Browser Attach in `Settings -> Browser`, or edit 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. Run `ccs browser setup`
|
||||
2. If Chrome still is not ready, use the generated launch command
|
||||
3. Rerun `ccs browser doctor`
|
||||
|
||||
If you are using the CCS-managed default path, this usually means the path could not be created
|
||||
automatically and now needs manual attention.
|
||||
|
||||
### Browser status says no running browser session was found
|
||||
|
||||
CCS could not find usable DevTools attach metadata for the configured user-data directory.
|
||||
|
||||
1. Run `ccs browser setup`
|
||||
2. If needed, make sure Chrome was started with `--remote-debugging-port=<port>`
|
||||
3. Make sure it is using the same `user_data_dir` configured in CCS
|
||||
4. Rerun `ccs browser doctor`
|
||||
|
||||
For the CCS-managed default path, this is the normal first-run state after CCS bootstraps the
|
||||
directory for you.
|
||||
|
||||
### Browser status says the DevTools endpoint is unreachable
|
||||
|
||||
CCS found attach metadata, but the endpoint did not answer successfully.
|
||||
|
||||
1. Run `ccs browser setup`
|
||||
2. If needed, restart the attach browser session
|
||||
3. Confirm the expected port matches the real remote debugging port
|
||||
4. 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
|
||||
- `browser_wait_for_event` requires explicit scoping for network request events (`urlIncludes`) and download events (`urlIncludes` or `suggestedFilenameIncludes`)
|
||||
- Event details redact observed navigation, request, and download URLs before returning them to the MCP caller so observed browser metadata does not expose query strings, fragments, or path-scoped bearer values
|
||||
Allowlist only purpose-built scratch directories. Never allowlist a home
|
||||
directory, source checkout containing secrets, or real tooling configuration
|
||||
directory.
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
# CCS Bar — Native macOS Menu Bar App
|
||||
|
||||
CCS Bar is a native macOS menu-bar app that shows live subscription quota and usage at a glance for your Claude Code, Codex, and CLIProxy accounts, without opening the dashboard.
|
||||
|
||||
## What It Is
|
||||
|
||||
CCS Bar is a thin client of the CCS local web-server. It never talks to a provider directly: every call goes to `localhost`, and CCS performs any provider fetch server-side. Opening the menu fires a debounced force-refresh so the glance reflects live data without blocking the UI.
|
||||
|
||||
It is macOS only.
|
||||
|
||||
## What It Shows
|
||||
|
||||
- Per-account quota percent and reset countdown
|
||||
- Account tier
|
||||
- Today, 7-day, and 30-day cost
|
||||
- A 30-day usage sparkline
|
||||
- Account state (active, paused, default)
|
||||
- Native subscription rows for Claude Code and Codex
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS
|
||||
- CCS CLI installed and configured (`ccs config` works)
|
||||
- The CCS web-server reachable on loopback. `ccs bar` (or `ccs bar launch`) starts it for you.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
ccs bar install
|
||||
```
|
||||
|
||||
This downloads `CCS-Bar.app.zip` from the floating `ccs-bar-latest` GitHub release and installs `CCS Bar.app` into `~/Applications`. Downloads are restricted to `github.com` and `objects.githubusercontent.com`, and extraction is guarded against zip-slip.
|
||||
|
||||
If `CCS Bar.app` is already installed, the command shows the current version and proceeds as a reinstall.
|
||||
|
||||
After installation, CCS reads the app version directly from the bundle's `Info.plist` and pins it to `~/.ccs/bar/.version`. It then performs a reachability check against the bar API (`GET /api/bar/summary`). A 404 response means the running CCS server predates CCS Bar support — update CCS to a version that includes CCS Bar, then restart `ccs bar`.
|
||||
|
||||
After a successful install, CCS leaves the macOS Gatekeeper quarantine marker in place so macOS can perform its normal first-launch verification. CCS then asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely:
|
||||
|
||||
```bash
|
||||
ccs bar install --launch # install and launch immediately
|
||||
ccs bar install --no-launch # install, skip launch prompt
|
||||
```
|
||||
|
||||
### Gatekeeper note
|
||||
|
||||
The install command does not automatically clear the macOS Gatekeeper quarantine attribute on the downloaded app. This preserves macOS first-launch verification for the floating release download.
|
||||
|
||||
If macOS blocks the app on first launch, make an explicit trust decision by right-clicking the app and choosing Open.
|
||||
|
||||
## Run
|
||||
|
||||
There are two ways to run CCS Bar, and both share one background server.
|
||||
|
||||
**Open the app.** Launch `CCS Bar` from Spotlight, Finder, or the Dock. The first time you open the menu, the app looks for a running CCS server and, if it does not find one, starts it in the background using the launch details recorded at install (`~/.ccs/bar/launch.json`). No terminal needed.
|
||||
|
||||
**Or use the CLI.**
|
||||
|
||||
```bash
|
||||
ccs bar # alias: ccs bar launch
|
||||
```
|
||||
|
||||
`ccs bar` probes for a running server (ports 3000, 3001, 3002, 8000, 8080, with the previous `bar.json` port first). If one is live it is reused; otherwise `ccs bar` starts the server as a detached background process and returns your prompt right away, then opens the app. The server keeps running after the command exits, so closing the terminal does not take the bar offline.
|
||||
|
||||
### Server lifecycle
|
||||
|
||||
```bash
|
||||
ccs bar serve # run the server in the current terminal (advanced; ccs bar runs this detached for you)
|
||||
ccs bar stop # stop the background server
|
||||
ccs bar status # report whether the server is running
|
||||
```
|
||||
|
||||
### Files under ~/.ccs
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `~/.ccs/bar.json` | Discovery file the app reads to find the live server |
|
||||
| `~/.ccs/bar/launch.json` | How to start the server (recorded at install) so the app can start it without a shell PATH |
|
||||
| `~/.ccs/bar/server.pid` | PID of the background server, used by `ccs bar stop` and `ccs bar status` |
|
||||
| `~/.ccs/bar/serve.log` | stdout and stderr of the background server |
|
||||
|
||||
`~/.ccs/bar.json` looks like this:
|
||||
|
||||
```json
|
||||
{ "baseUrl": "http://127.0.0.1:3000", "port": 3000, "authMode": "loopback" }
|
||||
```
|
||||
|
||||
The Swift app reads `~/.ccs/bar.json` to find the server, and falls back to probing the candidate ports if it is missing or stale.
|
||||
|
||||
## Loopback / Localhost Requirement
|
||||
|
||||
CCS Bar talks only to `http://127.0.0.1:<port>`. v1 supports `authMode: "loopback"` only, meaning dashboard auth disabled on localhost.
|
||||
|
||||
If you bind the dashboard beyond localhost (for example `--host` set to a non-loopback address) with dashboard auth disabled, the bar's read endpoints (`GET /api/bar/summary`, `GET /api/bar/analytics`) are refused for non-loopback callers, and the app cannot reach the server. Keep the dashboard on loopback for CCS Bar to work.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
ccs bar uninstall
|
||||
```
|
||||
|
||||
This removes `~/Applications/CCS Bar.app` and the installed version pin. It is a no-op if the app is not present.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Install fails with "server predates CCS Bar" or bar API returns 404: the CCS server running does not yet include CCS Bar. Update CCS (`npm i -g ccs@latest` or equivalent), then restart `ccs bar`.
|
||||
- Server failed to start: `ccs bar` first checks whether a CCS server is already running on the candidate ports (3000, 3001, 3002, 8000, 8080) and reuses it if found. A true failure here means a non-CCS process is occupying all candidate ports. Free one of those ports and re-run `ccs bar`. Check `~/.ccs/bar/serve.log` for the background server's output.
|
||||
- App won't open (Gatekeeper): right-click the app and choose Open to make an explicit trust decision.
|
||||
- Menu shows "CCS is not running": open the menu again to let the app start the server, or run `ccs bar status` to check and `ccs bar` to start it.
|
||||
- Quota not updating: re-open the menu to force a refresh, or confirm the server is still reachable on loopback.
|
||||
|
||||
## Development
|
||||
|
||||
The source lives in `macos-bar/`. Contributors can build and run the logic checks with a Swift 5.9+ toolchain (CommandLineTools is enough, full Xcode not required):
|
||||
|
||||
```bash
|
||||
swift build # build all targets, including the app
|
||||
swift run ccs-bar-check # run the logic tests
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
The app ships as a single floating GitHub release asset, `CCS-Bar.app.zip` under the `ccs-bar-latest` tag, which is what `ccs bar install` downloads. The version comes from one file: `macos-bar/VERSION` (a single line of semver). Bump it in the same PR when you want a new number; the asset is always the latest build regardless.
|
||||
|
||||
### Automatic (preferred)
|
||||
|
||||
The `Bar Release` workflow (`.github/workflows/bar-release.yml`) builds and publishes the asset automatically. It is scoped tightly so it never affects other PRs or CI:
|
||||
|
||||
- It runs only on a push to `main` that touches `macos-bar/**`, or a manual run from the Actions tab (`workflow_dispatch`) when the selected ref is `main`.
|
||||
- It runs only on the dedicated self-hosted macOS runner (label `ccs-bar`); the Linux CI runners never pick it up and it never competes for them.
|
||||
|
||||
So bar changes reach users when they land on `main` (the stable cadence). To cut a release without a code change, or to re-publish, trigger the workflow manually with `main` selected as the workflow ref.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
From a macOS machine with the Swift toolchain and `gh`:
|
||||
|
||||
```bash
|
||||
cd macos-bar
|
||||
./Scripts/package_app.sh # uses macos-bar/VERSION; pass a version to override
|
||||
gh release upload ccs-bar-latest dist/CCS-Bar.app.zip --clobber
|
||||
```
|
||||
|
||||
Builds are ad-hoc signed by default. Developer ID notarization (for the no-prompt public install) is available via `CCS_BAR_SIGNING=developer-id` once a paid Apple cert is configured.
|
||||
+178
-728
@@ -1,749 +1,199 @@
|
||||
# CCS Code Standards
|
||||
|
||||
Last Updated: 2026-04-07
|
||||
|
||||
Code standards, modularization patterns, and conventions for the CCS codebase.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
### YAGNI (You Aren't Gonna Need It)
|
||||
- No features "just in case"
|
||||
- Only implement what is currently needed
|
||||
- Delete unused code rather than commenting it out
|
||||
|
||||
### KISS (Keep It Simple, Stupid)
|
||||
- Prefer simple solutions over clever ones
|
||||
- Reduce complexity at every opportunity
|
||||
- Use established patterns over custom implementations
|
||||
|
||||
### DRY (Don't Repeat Yourself)
|
||||
- One source of truth for configuration
|
||||
- Extract common logic into shared utilities
|
||||
- Use barrel exports to centralize imports
|
||||
|
||||
---
|
||||
|
||||
## File Organization
|
||||
|
||||
### Directory Structure Rules
|
||||
|
||||
1. **Domain-based organization**: Group files by business domain, not by file type
|
||||
2. **Barrel exports required**: Every directory must have an `index.ts` aggregating exports
|
||||
3. **Flat within depth**: Keep nesting to 3 levels maximum
|
||||
4. **Co-location**: Keep related files together (component + hooks + utils)
|
||||
|
||||
### File Naming Conventions
|
||||
|
||||
| Convention | Example | When to Use |
|
||||
|------------|---------|-------------|
|
||||
| kebab-case | `cliproxy-executor.ts` | All TypeScript/TSX files |
|
||||
| kebab-case | `profile-detector.ts` | Multi-word file names |
|
||||
| *-adapter.ts | `claude-adapter.ts`, `droid-adapter.ts` | TargetAdapter implementations |
|
||||
| *-detector.ts | `droid-detector.ts` | Binary detection logic |
|
||||
| *-manager.ts | `droid-config-manager.ts` | Config/state management |
|
||||
| PascalCase | `BinaryManager` | Class exports only |
|
||||
| camelCase | `detectProfile` | Function exports |
|
||||
|
||||
**File names should be descriptive**: LLMs should understand the file's purpose from its name alone without reading content.
|
||||
|
||||
### Correct Examples
|
||||
|
||||
```
|
||||
src/cliproxy/binary-manager.ts # Binary management logic
|
||||
src/commands/doctor-command.ts # Doctor CLI command handler
|
||||
ui/src/components/cliproxy/provider-editor/index.tsx
|
||||
```
|
||||
|
||||
### Incorrect Examples
|
||||
|
||||
```
|
||||
src/utils/helper.ts # Too vague
|
||||
src/cliproxy/manager.ts # Which manager?
|
||||
ui/src/components/Editor.tsx # Not kebab-case
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Size Limit: 200 Lines
|
||||
|
||||
**Target**: All code files should be under 200 lines.
|
||||
|
||||
**Exceptions** (with justification):
|
||||
- Data files (model-pricing.ts, model-catalog.ts)
|
||||
- Entry points with routing logic (ccs.ts)
|
||||
- Complex transformation logic that cannot be meaningfully split
|
||||
|
||||
### Why 200 Lines?
|
||||
|
||||
1. **Context efficiency**: LLMs process smaller files faster
|
||||
2. **Single responsibility**: Forces focused, testable modules
|
||||
3. **Navigation**: Easier to scan and understand
|
||||
4. **Maintainability**: Reduces merge conflicts
|
||||
|
||||
### When Files Exceed 200 Lines
|
||||
|
||||
If a file grows beyond 200 lines:
|
||||
|
||||
1. **Identify extraction candidates**:
|
||||
- Helper functions that could be utilities
|
||||
- Constants and type definitions
|
||||
- Subcomponents within React components
|
||||
- Related logic that forms a cohesive unit
|
||||
|
||||
2. **Create subdirectory structure**:
|
||||
```
|
||||
# Before
|
||||
provider-editor.tsx (921 lines)
|
||||
|
||||
# After
|
||||
provider-editor/
|
||||
├── index.tsx # Main component (200 lines)
|
||||
├── model-mapping-form.tsx
|
||||
├── endpoint-config.tsx
|
||||
├── auth-section.tsx
|
||||
├── hooks.ts
|
||||
├── types.ts
|
||||
└── utils.ts
|
||||
```
|
||||
|
||||
3. **Preserve public API**: Main export remains the same through barrel export
|
||||
|
||||
---
|
||||
|
||||
## Barrel Export Pattern
|
||||
|
||||
### What is a Barrel Export?
|
||||
|
||||
An `index.ts` file that aggregates and re-exports module contents:
|
||||
|
||||
```typescript
|
||||
// src/cliproxy/index.ts
|
||||
|
||||
// Types (with explicit type keyword)
|
||||
export type { PlatformInfo, BinaryInfo } from './types';
|
||||
|
||||
// Functions
|
||||
export { detectPlatform } from './platform-detector';
|
||||
export { BinaryManager } from './binary-manager';
|
||||
|
||||
// From subdirectories
|
||||
export * from './auth';
|
||||
export * from './services';
|
||||
```
|
||||
|
||||
### Rules for Barrel Exports
|
||||
|
||||
1. **Every domain directory must have `index.ts`**
|
||||
2. **Export types with `export type`** for tree-shaking
|
||||
3. **Re-export subdirectories** for deep access
|
||||
4. **Keep barrel exports flat** - no logic, only exports
|
||||
|
||||
### Import Patterns
|
||||
|
||||
```typescript
|
||||
// CORRECT: Import from domain barrel
|
||||
import { execClaudeWithCLIProxy, CLIProxyProvider } from '../cliproxy';
|
||||
import { Config, Settings } from '../types';
|
||||
|
||||
// INCORRECT: Import from specific file (bypasses barrel)
|
||||
import { execClaudeWithCLIProxy } from '../cliproxy/cliproxy-executor';
|
||||
```
|
||||
|
||||
### Exception: Deep Imports
|
||||
|
||||
Allowed when:
|
||||
- Importing private utilities not exposed in barrel
|
||||
- Circular dependency avoidance
|
||||
- Performance-critical tree-shaking
|
||||
|
||||
---
|
||||
|
||||
## Target Adapter Pattern
|
||||
|
||||
The target adapter pattern enables pluggable support for multiple CLI implementations (Claude Code, Factory Droid, Codex CLI, etc.) while preserving a unified profile system.
|
||||
|
||||
### Pattern Overview
|
||||
|
||||
**Each CLI target implements a `TargetAdapter` interface:**
|
||||
|
||||
```typescript
|
||||
interface TargetAdapter {
|
||||
readonly type: TargetType; // 'claude' | 'droid' | 'codex'
|
||||
readonly displayName: string; // Human-readable name
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null; // Find CLI on system
|
||||
prepareCredentials(creds: TargetCredentials): Promise<void>; // Deliver credentials
|
||||
buildArgs(profile: string, userArgs: string[]): string[]; // Build CLI args
|
||||
buildEnv(creds: TargetCredentials, type: string): Env; // Build env vars
|
||||
exec(args: string[], env: Env): void; // Spawn CLI process
|
||||
supportsProfileType(type: string): boolean; // Validate profile
|
||||
}
|
||||
```
|
||||
|
||||
### Key Differences Per Target
|
||||
|
||||
| Aspect | Claude | Droid | Codex |
|
||||
|--------|--------|-------|-------|
|
||||
| **Credential delivery** | Environment variables | Config file (~/.factory/settings.json) | Transient `-c` overrides + `CCS_CODEX_API_KEY` |
|
||||
| **Spawn args** | `claude <args>` | `droid -m custom:ccs-<profile> <args>` | `codex <args>` or `codex -c ... <args>` |
|
||||
| **Config write** | None (uses env) | `upsertCcsModel()` writes to settings | None at runtime; dashboard edits user-owned `~/.codex/config.toml` only |
|
||||
| **Binary detection** | `detectClaudeCli()` | `detectDroidCli()` with version check | `detectCodexCli()` plus `--config` capability probe |
|
||||
|
||||
### Target Resolution Priority
|
||||
|
||||
Resolves which adapter to use via `resolveTargetType()`:
|
||||
|
||||
```
|
||||
1. --target <name> flag (highest priority)
|
||||
↓
|
||||
2. explicit runtime entrypoint (`CCS_INTERNAL_ENTRY_TARGET`):
|
||||
- ccs-droid / ccsd → droid
|
||||
- ccs-codex / ccsx → codex
|
||||
- ccsxp → codex (native cliproxy shortcut)
|
||||
↓
|
||||
3. argv[0] detection (runtime alias pattern / custom alias map):
|
||||
- ccs-droid → droid
|
||||
- ccsd → droid
|
||||
- ccs-codex → codex
|
||||
- ccsx → codex
|
||||
- ccs → default
|
||||
↓
|
||||
4. Profile config: profileConfig.target field
|
||||
↓
|
||||
5. Fallback: 'claude' (lowest priority)
|
||||
```
|
||||
|
||||
### Registration Pattern
|
||||
|
||||
At startup, adapters self-register into the runtime registry:
|
||||
|
||||
```typescript
|
||||
// In ccs.ts or initialization
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
registerTarget(new CodexAdapter());
|
||||
|
||||
// Later, when executing
|
||||
const targetType = resolveTargetType(args, profileConfig);
|
||||
const adapter = getTarget(targetType);
|
||||
|
||||
await adapter.prepareCredentials(credentials);
|
||||
const spawnArgs = adapter.buildArgs(profile, userArgs);
|
||||
adapter.exec(spawnArgs, adapter.buildEnv(credentials, profileType));
|
||||
```
|
||||
|
||||
### Adding a New Target
|
||||
|
||||
To add support for a new CLI (e.g., `newcli`):
|
||||
|
||||
1. Create `src/targets/newcli-adapter.ts` implementing `TargetAdapter`
|
||||
2. Implement each required method (detection, credential delivery, spawning)
|
||||
3. Create `src/targets/newcli-detector.ts` for binary detection logic
|
||||
4. Export from `src/targets/index.ts`
|
||||
5. Register in `ccs.ts`: `registerTarget(new NewCliAdapter())`
|
||||
6. Update `TargetType` union to include `'newcli'`
|
||||
|
||||
---
|
||||
|
||||
## Monster File Splitting Methodology
|
||||
|
||||
When splitting large files (500+ lines), follow this process:
|
||||
|
||||
### Step 1: Analyze Structure
|
||||
|
||||
Identify logical boundaries:
|
||||
- Render sections in React components
|
||||
- Handler groups in route files
|
||||
- Related utility functions
|
||||
- Constants and types
|
||||
|
||||
### Step 2: Extract Types First
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
export interface ProviderEditorProps {
|
||||
providerId: string;
|
||||
onSave: (config: ProviderConfig) => void;
|
||||
}
|
||||
|
||||
export interface ModelMappingValues {
|
||||
model: string;
|
||||
endpoint: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Extract Utilities
|
||||
|
||||
```typescript
|
||||
// utils.ts
|
||||
export function validateEndpoint(url: string): boolean { ... }
|
||||
export function formatModelName(name: string): string { ... }
|
||||
```
|
||||
|
||||
### Step 4: Extract Hooks
|
||||
|
||||
```typescript
|
||||
// hooks.ts
|
||||
export function useProviderConfig(providerId: string) { ... }
|
||||
export function useModelValidation() { ... }
|
||||
```
|
||||
|
||||
### Step 5: Extract Subcomponents
|
||||
|
||||
```typescript
|
||||
// model-mapping-form.tsx
|
||||
export function ModelMappingForm({ values, onChange }: Props) { ... }
|
||||
```
|
||||
|
||||
### Step 6: Compose in Index
|
||||
|
||||
```typescript
|
||||
// index.tsx
|
||||
import { ModelMappingForm } from './model-mapping-form';
|
||||
import { useProviderConfig } from './hooks';
|
||||
import type { ProviderEditorProps } from './types';
|
||||
|
||||
export function ProviderEditor({ providerId, onSave }: ProviderEditorProps) {
|
||||
const config = useProviderConfig(providerId);
|
||||
return (
|
||||
<div>
|
||||
<ModelMappingForm values={config.mapping} onChange={...} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { ProviderEditorProps, ModelMappingValues } from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Standards
|
||||
|
||||
### Strict Mode Required
|
||||
|
||||
All projects use TypeScript strict mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Type Annotations
|
||||
|
||||
```typescript
|
||||
// CORRECT: Explicit return types for public functions
|
||||
export function detectProfile(args: string[]): DetectedProfile { ... }
|
||||
|
||||
// CORRECT: Inferred types for internal functions
|
||||
const formatName = (name: string) => name.trim().toLowerCase();
|
||||
|
||||
// INCORRECT: any type
|
||||
function processData(data: any) { ... } // Use unknown or proper type
|
||||
```
|
||||
|
||||
### Type Exports
|
||||
|
||||
```typescript
|
||||
// CORRECT: Use type keyword for type-only exports
|
||||
export type { Config, Settings } from './config';
|
||||
|
||||
// CORRECT: Group type exports in barrel
|
||||
export type {
|
||||
PlatformInfo,
|
||||
BinaryInfo,
|
||||
DownloadProgress,
|
||||
} from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ESLint Rules (Enforced)
|
||||
|
||||
| Rule | Level | Notes |
|
||||
|------|-------|-------|
|
||||
| `@typescript-eslint/no-unused-vars` | error | Ignore `_` prefix |
|
||||
| `@typescript-eslint/no-explicit-any` | error | Use proper types |
|
||||
| `@typescript-eslint/no-non-null-assertion` | error | No `!` assertions |
|
||||
| `prefer-const` | error | Immutable by default |
|
||||
| `no-var` | error | Use const/let |
|
||||
| `eqeqeq` | error | Strict equality |
|
||||
| `react-hooks/*` | recommended | (UI only) |
|
||||
|
||||
---
|
||||
|
||||
## Terminal Output Standards
|
||||
|
||||
### CCS Logging Standards
|
||||
|
||||
- Use the shared logger from `src/services/logging/` for CCS-owned runtime diagnostics, request tracing, and structured events.
|
||||
- Keep `utils/ui` and deliberate `console.log`/`console.error` output for user-facing CLI UX only.
|
||||
- Redact secrets before persistence; never write raw tokens, cookies, API keys, or password hashes into CCS-owned logs.
|
||||
- Persist CCS-owned logs only under `getCcsDir()/logs`; do not invent per-feature log roots.
|
||||
- When adding dashboard polling or diagnostics routes, prevent them from recursively logging the log viewer itself.
|
||||
|
||||
### ASCII Only
|
||||
|
||||
```typescript
|
||||
// CORRECT
|
||||
console.log('[OK] Operation successful');
|
||||
console.log('[!] Warning message');
|
||||
console.log('[X] Error occurred');
|
||||
console.log('[i] Information');
|
||||
|
||||
// INCORRECT - NO EMOJIS
|
||||
console.log('Operation successful'); // NO
|
||||
console.log('Warning message'); // NO
|
||||
```
|
||||
|
||||
### Color Handling
|
||||
|
||||
```typescript
|
||||
import { colors } from '../utils/ui';
|
||||
|
||||
// Colors are TTY-aware and respect NO_COLOR
|
||||
console.log(colors.green('[OK]') + ' Operation successful');
|
||||
```
|
||||
|
||||
### Box Borders
|
||||
|
||||
Use ASCII box drawing for error displays:
|
||||
|
||||
```
|
||||
+=====================================+
|
||||
| [X] ERROR: Configuration failed |
|
||||
| |
|
||||
| Details: Unable to parse config |
|
||||
+=====================================+
|
||||
```
|
||||
|
||||
### Cross-Platform Adapter Spawning
|
||||
|
||||
When implementing target adapters, handle platform differences for binary spawning:
|
||||
|
||||
```typescript
|
||||
// Window shell detection (.cmd, .bat, .ps1 require shell)
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(binaryPath);
|
||||
|
||||
if (needsShell) {
|
||||
// Escape arguments and use shell: true
|
||||
const cmdString = [binaryPath, ...args].map(escapeShellArg).join(' ');
|
||||
spawn(cmdString, { shell: true, stdio: 'inherit' });
|
||||
} else {
|
||||
// Direct spawn (Unix-like, unshelled Windows executables)
|
||||
spawn(binaryPath, args, { stdio: 'inherit' });
|
||||
}
|
||||
```
|
||||
|
||||
This pattern is used in both `ClaudeAdapter` and `DroidAdapter` to ensure cross-platform consistency.
|
||||
|
||||
For all Claude child-process launches (delegation, adapters, proxies, helper spawners), sanitize env before spawn:
|
||||
|
||||
```typescript
|
||||
const cleanEnv = stripClaudeCodeEnv(mergedEnv); // case-insensitive remove of CLAUDECODE
|
||||
spawn(binaryPath, args, { env: cleanEnv, stdio: 'inherit' });
|
||||
```
|
||||
|
||||
This prevents Claude Code nested-session guard failures when CCS runs inside parent Claude sessions.
|
||||
|
||||
---
|
||||
|
||||
## React Component Standards (UI)
|
||||
|
||||
### Component Structure
|
||||
|
||||
```typescript
|
||||
// component-name.tsx
|
||||
|
||||
// 1. Imports (grouped: react, external, internal, relative)
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useProfiles } from '@/hooks';
|
||||
import { formatName } from './utils';
|
||||
import type { ComponentProps } from './types';
|
||||
|
||||
// 2. Types (if not in separate file)
|
||||
interface Props {
|
||||
id: string;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
// 3. Component
|
||||
export function ComponentName({ id, onSave }: Props) {
|
||||
// Hooks first
|
||||
const profiles = useProfiles();
|
||||
const [state, setState] = useState(null);
|
||||
|
||||
// Handlers
|
||||
const handleClick = () => { ... };
|
||||
|
||||
// Render
|
||||
return ( ... );
|
||||
}
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Item | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Component files | kebab-case.tsx | `provider-editor.tsx` |
|
||||
| Component exports | PascalCase | `ProviderEditor` |
|
||||
| Hook files | use-*.ts | `use-profiles.ts` |
|
||||
| Hook exports | useCamelCase | `useProfiles` |
|
||||
| Utility files | kebab-case.ts | `path-utils.ts` |
|
||||
| Utility exports | camelCase | `formatPath` |
|
||||
|
||||
---
|
||||
|
||||
## Input State Persistence Patterns
|
||||
|
||||
When building forms and editors that allow users to make changes, follow these patterns to prevent data loss.
|
||||
|
||||
### Pattern 1: Key-Based Remounting
|
||||
|
||||
**Use when**: Component has complex local state that should reset on prop changes.
|
||||
|
||||
```typescript
|
||||
// Parent component
|
||||
<ProfileEditor
|
||||
key={profileId} // Forces remount when profile changes
|
||||
profileId={profileId}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
```
|
||||
|
||||
**Why**: Without `key`, React reuses the component instance. Local `useState` values persist even when props change, causing stale data bugs.
|
||||
|
||||
### Pattern 2: Unsaved Changes Confirmation
|
||||
|
||||
**Use when**: User might navigate away while editing.
|
||||
|
||||
```typescript
|
||||
// Parent tracks dirty state
|
||||
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
||||
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
||||
|
||||
// Child notifies parent of dirty state
|
||||
useEffect(() => {
|
||||
onHasChangesUpdate?.(computedHasChanges);
|
||||
}, [computedHasChanges, onHasChangesUpdate]);
|
||||
|
||||
// Intercept navigation
|
||||
const handleSelect = (id: string) => {
|
||||
if (editorHasChanges && currentId !== id) {
|
||||
setPendingSwitch(id); // Show confirmation dialog
|
||||
} else {
|
||||
setCurrentId(id);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Flow**:
|
||||
1. Child computes `hasChanges` from local state vs saved data
|
||||
2. Child notifies parent via callback
|
||||
3. Parent intercepts navigation when dirty
|
||||
4. Show confirmation dialog: "Discard & Switch" or "Cancel"
|
||||
5. On confirm: reset dirty state, then switch
|
||||
|
||||
### Pattern 3: Auto-Save with Visual Feedback
|
||||
|
||||
**Use when**: Simple inputs that should save immediately.
|
||||
|
||||
```typescript
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleBlur = async () => {
|
||||
if (value !== savedValue) {
|
||||
await saveToBackend(value);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={value} onChange={...} onBlur={handleBlur} />
|
||||
{saved && (
|
||||
<span className="text-green-600 text-xs flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" /> Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
**When to use which**:
|
||||
| Scenario | Pattern |
|
||||
|----------|---------|
|
||||
| Complex multi-field editor | Pattern 2 (confirmation dialog) |
|
||||
| Simple single input | Pattern 3 (auto-save + feedback) |
|
||||
| List item selection | Pattern 1 (key-based remount) + Pattern 2 |
|
||||
|
||||
---
|
||||
|
||||
## Quality Gates
|
||||
|
||||
### Pre-Commit Sequence
|
||||
These standards describe current repository practice. Enforced configuration
|
||||
and tests take precedence over prose.
|
||||
|
||||
## Source of Truth
|
||||
|
||||
| Contract | Authoritative source |
|
||||
| --- | --- |
|
||||
| TypeScript and CLI lint rules | [`eslint.config.mjs`](../eslint.config.mjs) |
|
||||
| Formatting | [`.prettierrc`](../.prettierrc) |
|
||||
| Root commands and test entry points | [`package.json`](../package.json) |
|
||||
| UI commands and dependencies | [`ui/package.json`](../ui/package.json) |
|
||||
| Commit syntax | [`commitlint.config.cjs`](../commitlint.config.cjs) |
|
||||
| Release effects | [`.releaserc.cjs`](../.releaserc.cjs) |
|
||||
| Repository workflow | [`CLAUDE.md`](../CLAUDE.md) and [`CONTRIBUTING.md`](../CONTRIBUTING.md) |
|
||||
|
||||
Update this document when one of those contracts changes. Do not duplicate
|
||||
exhaustive rule, command, target, or dependency lists here.
|
||||
|
||||
## Design Priorities
|
||||
|
||||
Apply these in order:
|
||||
|
||||
1. **YAGNI**: build only behavior required by the accepted scope.
|
||||
2. **KISS**: prefer a small local change over a new abstraction or dependency.
|
||||
3. **DRY**: share stable domain behavior, not incidental similarity.
|
||||
|
||||
Preserve public contracts unless the change intentionally updates them. Error
|
||||
messages should explain recovery, and configuration changes should remain
|
||||
CLI-complete with dashboard parity where the feature supports both surfaces.
|
||||
|
||||
## Organization and Imports
|
||||
|
||||
- Keep code inside the domain that owns its behavior.
|
||||
- Split files at cohesive boundaries such as types, pure utilities, lifecycle
|
||||
services, hooks, or view components.
|
||||
- Follow nearby naming and import patterns. New TypeScript files normally use
|
||||
descriptive kebab-case names.
|
||||
- There is no universal maximum directory depth.
|
||||
- Barrel exports are optional compatibility boundaries, not a repository-wide
|
||||
requirement. Preserve an existing barrel when consumers depend on it; use a
|
||||
direct import when that is the local pattern or the symbol is intentionally
|
||||
private.
|
||||
- Before creating a module, check whether the runtime, standard library,
|
||||
installed dependencies, or an existing repository utility already owns the
|
||||
behavior.
|
||||
|
||||
## File Size
|
||||
|
||||
Two different thresholds serve different purposes:
|
||||
|
||||
- **200 lines is a review heuristic.** When a code file grows beyond roughly
|
||||
200 lines, check whether it contains multiple responsibilities. A cohesive
|
||||
file may remain larger.
|
||||
- **400 lines is the current CLI lint warning.**
|
||||
[`eslint.config.mjs`](../eslint.config.mjs) configures `max-lines` as a
|
||||
warning for `src/**/*.ts`, excluding blank lines and comments.
|
||||
|
||||
Do not split mechanically to satisfy a number. Split when the result improves
|
||||
ownership, testing, reuse, or reviewability. Preserve existing exports and
|
||||
behavior when they are part of a consumed contract.
|
||||
|
||||
## TypeScript and Linting
|
||||
|
||||
The CLI uses strict TypeScript. Current enforced CLI rules include:
|
||||
|
||||
- no explicit `any`
|
||||
- no non-null assertions
|
||||
- no unused variables, except names intentionally prefixed with `_`
|
||||
- `prefer-const`, `no-var`, and strict equality
|
||||
|
||||
Use `unknown` at untrusted boundaries and narrow it before use. Keep types close
|
||||
to their owning domain; export types only when another module consumes them.
|
||||
|
||||
New generic `throw new Error(...)` sites are blocked by the local
|
||||
`ccs/no-new-throw-error` rule. Use the typed errors in
|
||||
[`src/errors/error-types.ts`](../src/errors/error-types.ts) so
|
||||
[`src/errors/error-handler.ts`](../src/errors/error-handler.ts) can map failures
|
||||
consistently. The generated baseline in
|
||||
[`eslint-rules/throw-error-baseline.json`](../eslint-rules/throw-error-baseline.json)
|
||||
grandfathers existing sites; do not expand it to bypass a new error design.
|
||||
|
||||
## Terminal and Process Behavior
|
||||
|
||||
- CLI terminal output is ASCII only: `[OK]`, `[!]`, `[X]`, `[i]`.
|
||||
- Respect `NO_COLOR` and TTY-aware output.
|
||||
- Prefer argument arrays when spawning processes. When a platform wrapper
|
||||
requires a shell, use the existing quoting and wrapper utilities rather than
|
||||
interpolating untrusted input.
|
||||
- Preserve target-specific stdio, signal, and exit-code behavior. The adapter
|
||||
contract lives in
|
||||
[`src/targets/target-adapter.ts`](../src/targets/target-adapter.ts).
|
||||
|
||||
## Target Adapters
|
||||
|
||||
Runtime target behavior is divided across:
|
||||
|
||||
| Concern | Source |
|
||||
| --- | --- |
|
||||
| Target names, aliases, persistence | [`src/targets/target-metadata.ts`](../src/targets/target-metadata.ts) |
|
||||
| Selection priority and flag parsing | [`src/targets/target-resolver.ts`](../src/targets/target-resolver.ts) |
|
||||
| Adapter contract | [`src/targets/target-adapter.ts`](../src/targets/target-adapter.ts) |
|
||||
| Registration and lookup | [`src/targets/target-registry.ts`](../src/targets/target-registry.ts) |
|
||||
| Startup registration | [`src/ccs.ts`](../src/ccs.ts) |
|
||||
|
||||
A target change should update metadata, implementation, registration, tests,
|
||||
help text, and public documentation as applicable. Do not copy the target list
|
||||
into new guides; link to metadata when maintainers need the current set.
|
||||
|
||||
## Configuration and Test Isolation
|
||||
|
||||
- Treat persisted environment values as strings.
|
||||
- Route CCS paths through `getCcsDir()` in
|
||||
[`src/utils/config-manager.ts`](../src/utils/config-manager.ts).
|
||||
- Never run tests against a contributor's real `~/.ccs/` or `~/.claude/`.
|
||||
Set `CCS_HOME` to a temporary directory.
|
||||
- Preserve documented configuration and profile-resolution priority. Add a
|
||||
focused test when changing precedence or fallback behavior.
|
||||
- Treat Docker service `ccs` and network `ccs-net` as public contracts; see
|
||||
[`CONTRIBUTING.md`](../CONTRIBUTING.md#if-you-change-the-docker-network-or-service-name).
|
||||
|
||||
## Dashboard Code
|
||||
|
||||
- Keep page orchestration in `ui/src/pages/`, reusable UI in
|
||||
`ui/src/components/`, server-state access in `ui/src/hooks/`, and shared
|
||||
helpers in `ui/src/lib/` when that matches the owning domain.
|
||||
- Preserve unsaved input across background refreshes. Destructive replacement
|
||||
of dirty state requires an explicit user action or confirmation.
|
||||
- Use the existing API and query helpers before adding a new data-access layer.
|
||||
- Keep accessibility, keyboard interaction, responsive layout, loading,
|
||||
empty, and error states in scope for user-facing changes.
|
||||
- Locale codes and normalization are owned by
|
||||
[`ui/src/lib/locales.ts`](../ui/src/lib/locales.ts); translations are wired
|
||||
through [`ui/src/lib/i18n.ts`](../ui/src/lib/i18n.ts).
|
||||
|
||||
The UI has its own ESLint, TypeScript, Prettier, and Vitest configuration.
|
||||
Verify commands against [`ui/package.json`](../ui/package.json).
|
||||
|
||||
## Tests and Quality Gates
|
||||
|
||||
The root TypeScript suites run with Bun's test runner. The dashboard uses
|
||||
Vitest. Native shell and PowerShell probes cover platform-specific behavior.
|
||||
See [`tests/README.md`](../tests/README.md) for ownership and commands.
|
||||
|
||||
Run the smallest relevant test first, then the normal gate:
|
||||
|
||||
```bash
|
||||
# Main project
|
||||
bun run format
|
||||
bun run lint:fix
|
||||
bun run validate
|
||||
bun run validate:ci-parity
|
||||
```
|
||||
|
||||
# UI project (if changed)
|
||||
Before review or merge confidence:
|
||||
|
||||
```bash
|
||||
bun run validate:ci-parity
|
||||
```
|
||||
|
||||
For dashboard changes:
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
bun run format
|
||||
bun run lint:fix
|
||||
bun run validate
|
||||
bun run test:run
|
||||
```
|
||||
|
||||
### Validate Runs
|
||||
Do not weaken or skip a failing check to make a change pass. If a full gate
|
||||
cannot run, report the exact focused checks completed and the blocker.
|
||||
|
||||
| Project | Command | Checks |
|
||||
|---------|---------|--------|
|
||||
| Main | `bun run validate` | typecheck + lint + format:check + test:fast |
|
||||
| UI | `bun run validate` | typecheck + lint + format:check |
|
||||
## Commits and Releases
|
||||
|
||||
---
|
||||
Commitlint accepts conventional types defined in
|
||||
[`commitlint.config.cjs`](../commitlint.config.cjs), with a 100-character header
|
||||
limit. Use focused subjects such as:
|
||||
|
||||
## Conventional Commits
|
||||
|
||||
All commits must follow conventional commit format:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```text
|
||||
fix(doctor): handle missing config
|
||||
feat(cliproxy): add provider quota check
|
||||
docs(contributing): clarify validation
|
||||
```
|
||||
|
||||
### Types
|
||||
Semantic-release owns versions, changelog entries, tags, npm publishing, and
|
||||
GitHub releases. Do not bump versions, tag, or publish manually. Release effects
|
||||
for each branch and commit type are defined in
|
||||
[`.releaserc.cjs`](../.releaserc.cjs).
|
||||
|
||||
| Type | When to Use | Version Bump |
|
||||
|------|-------------|--------------|
|
||||
| `feat` | New feature | MINOR |
|
||||
| `fix` | Bug fix | PATCH |
|
||||
| `perf` | Performance | PATCH |
|
||||
| `docs` | Documentation | None |
|
||||
| `style` | Formatting | None |
|
||||
| `refactor` | Code restructure | None |
|
||||
| `test` | Tests | None |
|
||||
| `chore` | Maintenance | None |
|
||||
## Documentation Triggers
|
||||
|
||||
### Examples
|
||||
Update the owning guide when a change affects behavior, commands, setup,
|
||||
architecture, security posture, public contracts, or future maintainer
|
||||
decisions. Start at [`docs/README.md`](./README.md). Public CLI, provider,
|
||||
configuration, installation, or workflow changes also require the matching page
|
||||
in the separate `kaitranntt/ccs-docs` repository; see the docs index for
|
||||
checkout guidance.
|
||||
|
||||
```bash
|
||||
# Correct
|
||||
git commit -m "feat(cliproxy): add OAuth token refresh"
|
||||
git commit -m "fix(doctor): handle missing config gracefully"
|
||||
git commit -m "refactor(ui): split provider-editor into modules"
|
||||
|
||||
# Incorrect - REJECTED
|
||||
git commit -m "added new feature"
|
||||
git commit -m "Fixed bug"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### 1. God Files
|
||||
|
||||
```typescript
|
||||
// BAD: One file doing everything
|
||||
// src/utils.ts (2000 lines with mixed concerns)
|
||||
|
||||
// GOOD: Split by domain
|
||||
// src/utils/ui/colors.ts
|
||||
// src/utils/ui/boxes.ts
|
||||
// src/utils/shell-executor.ts
|
||||
// src/utils/config-manager.ts
|
||||
```
|
||||
|
||||
### 2. Barrel Import Bypass
|
||||
|
||||
```typescript
|
||||
// BAD: Direct import bypassing barrel
|
||||
import { detectPlatform } from '../cliproxy/platform-detector';
|
||||
|
||||
// GOOD: Import from domain barrel
|
||||
import { detectPlatform } from '../cliproxy';
|
||||
```
|
||||
|
||||
### 3. Inline Everything
|
||||
|
||||
```typescript
|
||||
// BAD: Huge inline functions in components
|
||||
function Component() {
|
||||
const handleComplexOperation = () => {
|
||||
// 100 lines of logic...
|
||||
};
|
||||
}
|
||||
|
||||
// GOOD: Extract to hooks or utilities
|
||||
function Component() {
|
||||
const { handleComplexOperation } = useComplexOperation();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Type Duplication
|
||||
|
||||
```typescript
|
||||
// BAD: Same types defined in multiple files
|
||||
// file1.ts
|
||||
interface Config { ... }
|
||||
// file2.ts
|
||||
interface Config { ... }
|
||||
|
||||
// GOOD: Single source of truth
|
||||
// types/config.ts
|
||||
export interface Config { ... }
|
||||
```
|
||||
|
||||
### 5. Config Priority Pattern
|
||||
|
||||
When resolving configuration from multiple sources, follow this priority order:
|
||||
|
||||
```typescript
|
||||
// proxy-config-resolver.ts pattern
|
||||
// Priority: CLI flags > Environment variables > config.yaml > defaults
|
||||
|
||||
const resolved = {
|
||||
...DEFAULT_CONFIG, // 4. Defaults (lowest)
|
||||
...yamlConfig, // 3. config.yaml
|
||||
...envConfig, // 2. Environment variables
|
||||
...cliFlags, // 1. CLI flags (highest)
|
||||
};
|
||||
```
|
||||
|
||||
This pattern is used in:
|
||||
- `src/cliproxy/proxy-config-resolver.ts` - Remote proxy config
|
||||
- `src/config/unified-config-loader.ts` - Main config loading
|
||||
|
||||
---
|
||||
|
||||
## Lint Enforcement Gates
|
||||
|
||||
Two ESLint gates (`eslint.config.mjs`) lock in the maintainability epic's gains:
|
||||
|
||||
- **`ccs/no-new-throw-error`** (error): flags new `throw new Error(...)`. Use a typed error from `src/errors/error-types.ts` (`AuthError`, `ConfigError`, `ProfileError`, `ProviderError`, `NetworkError`, `ProxyError`, `MigrationError`, `ValidationError`, `RetryableError`) so `handleError` emits a differentiated exit code. Existing ~340 sites are grandfathered in `eslint-rules/throw-error-baseline.json`; only **new** violations error. Regenerate the baseline when intentionally grandfathering a new site, or quarterly to prune converted entries:
|
||||
```bash
|
||||
node scripts/generate-throw-error-baseline.js
|
||||
```
|
||||
- **`max-lines`** (warn, 400): warns on source files over 400 lines (`skipBlankLines`, `skipComments`). Split via the Monster File Splitting methodology above (barrel `index.ts` preserves the public API).
|
||||
|
||||
When the no-throw rule blocks a change, prefer converting to the matching typed error. Only add to the baseline when the throw is genuinely out of scope to convert (and regenerate the baseline so the entry is explicit, not silent).
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Codebase Summary](./codebase-summary.md) - Full directory structure
|
||||
- [System Architecture](./system-architecture/index.md) - Architecture diagrams
|
||||
- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance
|
||||
Prefer source links over copied trees and metrics. Remove stale sections rather
|
||||
than leaving TODO markers.
|
||||
+171
-671
@@ -1,673 +1,173 @@
|
||||
# CCS Codebase Summary
|
||||
|
||||
Last Updated: 2026-05-18
|
||||
|
||||
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, native Codex runtime target support, native Codex/Droid usage collectors, and models.dev-backed model pricing metadata.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
ccs/
|
||||
├── src/ # CLI TypeScript source
|
||||
├── dist/ # Compiled JavaScript (npm package)
|
||||
├── lib/ # Native shell scripts (bash, PowerShell)
|
||||
├── ui/ # React dashboard application
|
||||
│ ├── src/ # UI source code
|
||||
│ └── dist/ # Built UI bundle
|
||||
├── docker/ # Docker deployment configuration
|
||||
│ ├── Dockerfile # Multi-stage build (bun 1.2.21, node:20-bookworm-slim)
|
||||
│ ├── docker-compose.yml # Compose setup with resource limits, healthcheck
|
||||
│ ├── entrypoint.sh # Entrypoint with privilege dropping, usage help
|
||||
│ └── README.md # Docker deployment guide
|
||||
├── tests/ # Test suites
|
||||
├── docs/ # Documentation
|
||||
└── assets/ # Static assets (logos, screenshots)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Source (`src/`)
|
||||
|
||||
The main CLI is organized into domain-specific modules with barrel exports.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── ccs.ts # Main entry point & profile execution flow
|
||||
├── bin/ # Dedicated runtime entrypoints
|
||||
│ ├── droid-runtime.ts # Forces droid target for ccs-droid / ccsd package bins
|
||||
│ ├── codex-runtime.ts # Forces codex target for ccs-codex / ccsx package bins
|
||||
│ └── ccsxp-runtime.ts # Forces codex target + native cliproxy override for ccsxp
|
||||
├── types/ # TypeScript type definitions
|
||||
│ ├── index.ts # Barrel export (aggregates all types)
|
||||
│ ├── cli.ts # CLI types (ParsedArgs, ExitCode)
|
||||
│ ├── config.ts # Config types (Settings, EnvVars)
|
||||
│ ├── delegation.ts # Delegation types (sessions, events)
|
||||
│ ├── glmt.ts # Legacy transformer types (messages, transforms)
|
||||
│ └── utils.ts # Utility types (ErrorCode, LogLevel)
|
||||
│
|
||||
├── commands/ # CLI command handlers
|
||||
│ ├── api-command/ # API profile subcommands (split facade + handlers)
|
||||
│ │ ├── index.ts # API command facade/router
|
||||
│ │ ├── shared.ts # Shared API arg parsing helpers
|
||||
│ │ └── [subcommand files...]
|
||||
│ ├── cliproxy-command.ts # CLIProxy subcommand handling
|
||||
│ ├── config-command.ts # Config management commands
|
||||
│ ├── config-image-analysis-command.ts # First-class ImageAnalysis config (NEW v7.34)
|
||||
│ ├── named-command-router.ts # Reusable named-command dispatcher
|
||||
│ ├── doctor-command.ts # Health diagnostics
|
||||
│ ├── env-command.ts # Export shell env vars for third-party tools (v7.39)
|
||||
│ ├── help-command.ts # Help text generation
|
||||
│ ├── install-command.ts # Install/uninstall logic
|
||||
│ ├── root-command-router.ts # Extracted top-level command dispatch from ccs.ts
|
||||
│ ├── shell-completion-command.ts
|
||||
│ ├── sync-command.ts # Symlink synchronization
|
||||
│ ├── update-command.ts # Self-update logic
|
||||
│ └── version-command.ts # Version display
|
||||
│
|
||||
├── targets/ # Multi-target adapter system (NEW)
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── target-adapter.ts # TargetAdapter interface contract
|
||||
│ ├── target-registry.ts # Registry for runtime adapter lookup
|
||||
│ ├── target-resolver.ts # Resolution logic (flag > runtime entrypoint / argv[0] > config)
|
||||
│ ├── target-metadata.ts # Runtime vs persisted target metadata and alias lists
|
||||
│ ├── target-runtime-compatibility.ts # Guardrails for target/profile combinations
|
||||
│ ├── claude-adapter.ts # Claude Code CLI implementation
|
||||
│ ├── droid-adapter.ts # Factory Droid CLI implementation
|
||||
│ ├── codex-adapter.ts # Native Codex CLI implementation
|
||||
│ ├── codex-detector.ts # Codex binary detection and capability probing
|
||||
│ ├── droid-detector.ts # Droid binary detection & version checks
|
||||
│ └── droid-config-manager.ts # ~/.factory/settings.json management
|
||||
│
|
||||
├── auth/ # Authentication module
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── commands/ # Auth-specific CLI commands
|
||||
│ │ └── index.ts
|
||||
│ ├── account-switcher.ts # Account switching logic
|
||||
│ └── profile-detector.ts # Profile detection (474 lines)
|
||||
│
|
||||
├── config/ # Configuration management
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── unified-config-loader.ts # Central config loader (546 lines)
|
||||
│ └── migration-manager.ts # Config migration logic
|
||||
│
|
||||
├── proxy/ # OpenAI-compatible proxy runtime
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── proxy-daemon-entry.ts # Daemon entrypoint
|
||||
│ ├── proxy-daemon.ts # Lifecycle, health, and port binding
|
||||
│ ├── proxy-port-resolver.ts # Adaptive per-profile port selection
|
||||
│ ├── request-router.ts # Request-time profile/model routing
|
||||
│ ├── profile-router.ts # Profile resolution helpers
|
||||
│ ├── proxy-env.ts # Local runtime env construction
|
||||
│ ├── routing-config.ts # Proxy routing config parsing
|
||||
│ ├── upstream-url.ts # Upstream endpoint resolution
|
||||
│ ├── proxy-daemon-state.ts # Persistent running-state metadata
|
||||
│ ├── server/ # HTTP server and routes
|
||||
│ └── transformers/ # Request and SSE translation
|
||||
│
|
||||
├── channels/ # Official Claude channel integration
|
||||
│ ├── official-channels-runtime.ts # Runtime gating, plugin specs, setup guidance
|
||||
│ └── official-channels-store.ts # Claude channel token/env storage helpers
|
||||
│
|
||||
├── cliproxy/ # CLIProxyAPI integration (heavily modularized)
|
||||
│ ├── index.ts # Barrel export (137 lines, extensive)
|
||||
│ ├── auth/ # OAuth handlers, token management
|
||||
│ │ └── index.ts
|
||||
│ ├── binary/ # Binary management
|
||||
│ │ └── index.ts
|
||||
│ ├── services/ # Service layer
|
||||
│ │ └── index.ts
|
||||
│ ├── cliproxy-executor.ts # Main executor (666 lines)
|
||||
│ ├── config-generator.ts # Config file generation (531 lines)
|
||||
│ ├── account-manager.ts # Account management (509 lines)
|
||||
│ ├── quota-manager.ts # Hybrid quota management (NEW v7.14)
|
||||
│ ├── quota-fetcher.ts # Provider quota API integration (NEW v7.14)
|
||||
│ ├── platform-detector.ts # OS/arch detection
|
||||
│ ├── binary-manager.ts # Binary download/update
|
||||
│ ├── auth-handler.ts # Authentication handling
|
||||
│ ├── model-catalog.ts # Provider model definitions
|
||||
│ ├── model-config.ts # Model configuration
|
||||
│ ├── codex-plan-compatibility.ts # Codex free/paid model fallback guardrails
|
||||
│ ├── service-manager.ts # Background service
|
||||
│ ├── proxy-detector.ts # Running proxy detection
|
||||
│ ├── startup-lock.ts # Race condition prevention
|
||||
│ ├── remote-proxy-client.ts # Remote proxy health checks (v7.1)
|
||||
│ ├── proxy-config-resolver.ts # CLI/env/config merging (v7.1)
|
||||
│ ├── types.ts # ResolvedProxyConfig for local/remote modes
|
||||
│ └── [more files...]
|
||||
│
|
||||
├── copilot/ # GitHub Copilot integration
|
||||
│ ├── index.ts # Barrel export
|
||||
│ └── copilot-package-manager.ts # Package management (515 lines)
|
||||
│
|
||||
├── glmt/ # Legacy transformer internals kept for compatibility
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── pipeline/ # Processing pipeline
|
||||
│ │ └── index.ts
|
||||
│ ├── glmt-proxy.ts # Legacy proxy runtime kept for internal compatibility
|
||||
│ └── delta-accumulator.ts # Delta processing (484 lines)
|
||||
│
|
||||
├── delegation/ # Task delegation & headless execution
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── executor/ # Execution engine
|
||||
│ └── [delegation files...]
|
||||
│
|
||||
├── errors/ # Centralized error handling
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── error-handler.ts # Main error handler
|
||||
│ ├── exit-codes.ts # Exit code definitions
|
||||
│ └── cleanup.ts # Cleanup logic
|
||||
│
|
||||
├── management/ # Doctor diagnostics
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── checks/ # Diagnostic checks
|
||||
│ │ ├── index.ts
|
||||
│ │ └── image-analysis-check.ts # ImageAnalysis runtime validation (NEW v7.34)
|
||||
│ └── repair/ # Auto-repair logic
|
||||
│ └── index.ts
|
||||
│
|
||||
├── api/ # API utilities & services
|
||||
│ ├── index.ts # Barrel export
|
||||
│ └── services/ # API services
|
||||
│ ├── index.ts
|
||||
│ ├── profile-reader.ts
|
||||
│ └── profile-writer.ts
|
||||
│
|
||||
├── utils/ # Utilities (modularized into subdirs)
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── ui/ # Terminal UI utilities
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── boxes.ts # Box drawing
|
||||
│ │ ├── colors.ts # Terminal colors
|
||||
│ │ └── spinners.ts # Progress spinners
|
||||
│ ├── websearch/ # Search tool integrations
|
||||
│ │ └── index.ts
|
||||
│ ├── hooks/ # Claude Code compatibility hooks (NEW v7.34)
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── image-analyzer-hook-installer.ts
|
||||
│ │ ├── image-analyzer-hook-configuration.ts
|
||||
│ │ ├── image-analyzer-profile-hook-injector.ts
|
||||
│ │ └── get-image-analysis-hook-env.ts
|
||||
│ ├── image-analysis/ # ImageAnalysis MCP/runtime utilities (NEW v7.34)
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── hook-installer.ts
|
||||
│ │ ├── mcp-installer.ts
|
||||
│ │ └── claude-tool-args.ts
|
||||
│ └── [utility files...]
|
||||
│
|
||||
└── web-server/ # Express web server (heavily modularized)
|
||||
├── index.ts # Server entry & barrel export
|
||||
├── routes/ # 15+ route handlers
|
||||
│ ├── index.ts
|
||||
│ ├── accounts-route.ts
|
||||
│ ├── auth-route.ts
|
||||
│ ├── channels-routes.ts
|
||||
│ ├── cliproxy-route.ts
|
||||
│ ├── copilot-route.ts
|
||||
│ ├── doctor-route.ts
|
||||
│ ├── glmt-route.ts
|
||||
│ ├── health-route.ts
|
||||
│ ├── profiles-route.ts
|
||||
│ └── [more routes...]
|
||||
├── health/ # Health check system
|
||||
│ └── index.ts
|
||||
├── usage/ # Usage analytics module (default Claude, CCS instances, native Codex/Droid, CLIProxy snapshots)
|
||||
│ ├── index.ts
|
||||
│ ├── handlers.ts # Request handlers (633 lines)
|
||||
│ ├── aggregator.ts # Data aggregation (538 lines)
|
||||
│ ├── codex-native-usage-collector.ts # Native Codex rollout JSONL collector
|
||||
│ ├── droid-native-usage-collector.ts # Native Droid SQLite collector
|
||||
│ └── data-aggregator.ts
|
||||
├── models-dev/ # Cached models.dev metadata/pricing registry integration
|
||||
│ ├── registry-cache.ts
|
||||
│ ├── pricing-resolver.ts
|
||||
│ └── types.ts
|
||||
├── services/ # Shared services
|
||||
│ └── index.ts
|
||||
└── model-pricing.ts # Static pricing fallback + models.dev resolver
|
||||
```
|
||||
|
||||
### Module Categories
|
||||
|
||||
| Category | Directories | Purpose |
|
||||
|----------|-------------|---------|
|
||||
| Core | `commands/`, `errors/` | CLI commands, error handling |
|
||||
| Targets | `bin/`, `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, Codex CLI, extensible) |
|
||||
| Auth | `auth/`, `cliproxy/auth/` | Authentication across providers |
|
||||
| Config | `config/`, `types/` | Configuration & type definitions |
|
||||
| OpenAI Proxy | `proxy/` | Adaptive local OpenAI-compatible proxy runtime, profile routing, and SSE transforms |
|
||||
| Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations plus retained legacy transformer internals |
|
||||
| Quota | `cliproxy/quota-*.ts`, `account-manager.ts` | Hybrid quota management (v7.14) |
|
||||
| Remote Proxy | `cliproxy/remote-*.ts`, `proxy-config-resolver.ts` | Remote CLIProxy support (v7.1) |
|
||||
| Image Analysis | `utils/image-analysis/`, `utils/hooks/` | Vision model proxying (v7.34) |
|
||||
| Services | `web-server/`, `api/` | HTTP server, API services |
|
||||
| Utilities | `utils/`, `management/` | Helpers, diagnostics |
|
||||
|
||||
### Account Context Metadata Flow
|
||||
|
||||
- Source fields: `accounts.<name>.context_mode`, `accounts.<name>.context_group`, `accounts.<name>.continuity_mode` in `~/.ccs/config.yaml`.
|
||||
- Runtime policy resolver: `src/auth/account-context.ts`.
|
||||
- Metadata storage normalization: `src/auth/profile-registry.ts`.
|
||||
- API write validation: `PUT /api/config` in `src/web-server/routes/config-routes.ts`.
|
||||
- Rules:
|
||||
- mode is isolation-first (`isolated` default, `shared` opt-in)
|
||||
- shared mode requires non-empty valid `context_group`
|
||||
- shared mode continuity depth is `standard` by default, optional `deeper`
|
||||
- `context_group` is normalized (trim + lowercase + whitespace collapse to `-`)
|
||||
- API route rejects `context_group`/`continuity_mode` when mode is not `shared`
|
||||
- registry normalization drops malformed persisted `context_group` values
|
||||
|
||||
### Shared Plugin Layout
|
||||
|
||||
- Shared payload owner: `src/management/shared-manager.ts`.
|
||||
- Profile entry point: `src/management/instance-manager.ts`.
|
||||
- `plugins/marketplaces/`, `plugins/cache/`, and `installed_plugins.json` stay shared through the `~/.ccs/shared/` topology.
|
||||
- `known_marketplaces.json` is now instance-local under `~/.ccs/instances/<profile>/plugins/` so Claude Code validates `installLocation` against the active `CLAUDE_CONFIG_DIR` instead of a last-writer-wins shared file.
|
||||
|
||||
### Official Claude Channels
|
||||
|
||||
- Runtime contract lives in `src/channels/official-channels-runtime.ts` and is consumed from `src/ccs.ts`, `src/commands/config-channels-command.ts`, and `src/web-server/routes/channels-routes.ts`.
|
||||
- Canonical config lives under `channels.*` in `~/.ccs/config.yaml`; legacy `discord_channels.*` remains read-compatible only when canonical fields are absent.
|
||||
|
||||
### Native Codex Runtime Target
|
||||
|
||||
- Dedicated runtime entrypoints: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts`, while `ccsxp` resolves through `src/bin/ccsxp-runtime.ts`; all three set `CCS_INTERNAL_ENTRY_TARGET=codex` before delegating to `src/targets/target-resolver.ts`.
|
||||
- Native Codex passthrough: `ccsx --help`, `ccsx --version`, and known upstream Codex subcommands such as `ccsx exec ...`, `ccsx apply ...`, `ccsx mcp ...`, `ccsx plugin ...`, `ccsx completion ...`, and `ccsx resume ...` short-circuit before CCS profile detection; upstream aliases such as `ccsx e ...` and `ccsx a ...` are included. CCS-owned `ccsx auth`, `ccsx doctor`, and `ccsx update` remain reserved for CCS.
|
||||
- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, preserves a valid custom `base_url`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process.
|
||||
- Implicit Codex launches such as `ccs --target codex` and `ccsxp` use native Codex default mode even when the CCS default profile is a Claude account. Explicit unsupported profiles such as `ccs work --target codex` still fail fast with native-vs-pool guidance.
|
||||
- `argv[0]` alias mapping still exists in `src/targets/target-resolver.ts` for same-binary/custom alias scenarios, but the built-in npm bins above do not depend on that map at runtime.
|
||||
- Metadata boundary: `src/targets/target-metadata.ts` keeps Codex runtime-only in v1, so persisted default targets remain `claude | droid`.
|
||||
- Compatibility guardrails: `src/targets/target-runtime-compatibility.ts` centralizes which profile types can execute on Codex.
|
||||
- Adapter behavior: `src/targets/codex-adapter.ts` and `src/targets/codex-detector.ts` launch native Codex without rewriting `~/.codex/config.toml`; CCS-backed routes use transient `codex -c key=value` overrides and env-key injection.
|
||||
- Dashboard control center: `src/web-server/services/codex-dashboard-service.ts`, `src/web-server/routes/codex-routes.ts`, `ui/src/pages/codex.tsx`, and `ui/src/components/compatible-cli/codex-*.tsx` expose a split-view Codex dashboard with guided editors for top-level settings, trust, profiles, providers, MCP servers, and feature flags plus a raw TOML fallback.
|
||||
- Structured-edit boundary: guided Codex saves intentionally reserialize the whole TOML document, so comments/formatting are normalized and the raw editor remains the fidelity-preserving escape hatch.
|
||||
- Follow-up behavior: structured saves refresh the raw snapshot immediately, refresh discards stale raw drafts, structured controls stay disabled while raw TOML is dirty/invalid/unreadable, project trust paths must be absolute or `~/...`, unsupported upstream top-level shapes are preserved instead of deleted, and feature flags can be reset to default.
|
||||
- Supported Codex flows in v1:
|
||||
- `default`
|
||||
- CLIProxy provider `codex`
|
||||
- settings/API profiles only when they resolve to a Codex CLIProxy bridge
|
||||
- Telegram and Discord bot tokens are intentionally written into Claude-managed machine state under `~/.claude/channels/<channel>/.env`, unless the official `*_STATE_DIR` environment override redirects that channel elsewhere.
|
||||
- iMessage is tokenless, macOS-only, and still depends on Claude-side plugin install plus OS permissions.
|
||||
- Auto-enable is gated on Bun availability, verified Claude Code v2.1.80+, verified `claude.ai` auth, native Claude `default/account` sessions, and per-channel setup readiness.
|
||||
- The dashboard channels section surfaces Bun/version/auth/state-scope status from `/api/channels`, preserves token drafts when save-follow-up refresh fails, and keeps unsupported selected iMessage visible only so it can be turned off.
|
||||
|
||||
### Structured Logging Domain
|
||||
|
||||
- CCS-owned runtime logging now lives in `src/services/logging/`.
|
||||
- The shared domain owns path resolution, redaction, rotation/pruning, buffered recent-entry reads, and the logger factory used by CLI/server/runtime code.
|
||||
- Dashboard exposure lives in `src/web-server/routes/logs-routes.ts`, `src/web-server/services/logs-dashboard-service.ts`, and `src/web-server/middleware/request-logging-middleware.ts`.
|
||||
- The native dashboard viewer lives at `ui/src/pages/logs.tsx` with supporting components under `ui/src/components/logs/` and hooks in `ui/src/hooks/use-logs.ts`.
|
||||
- Legacy CLIProxy error files still exist under `~/.ccs/cliproxy/logs` and are surfaced as a labeled legacy source rather than the primary CCS logging model.
|
||||
|
||||
### Target Adapter Module
|
||||
|
||||
The targets module provides an extensible interface for dispatching profiles to different CLI implementations.
|
||||
|
||||
**Key components:**
|
||||
|
||||
1. **TargetAdapter Interface** - Contract that each CLI implementation must fulfill:
|
||||
- binary detection
|
||||
- credential preparation
|
||||
- target-specific args/env construction
|
||||
- process execution
|
||||
- profile compatibility checks
|
||||
|
||||
2. **Target Resolution** - Priority order:
|
||||
- `--target <cli>` flag (CLI argument)
|
||||
- Explicit runtime entrypoint via `CCS_INTERNAL_ENTRY_TARGET` (used by `src/bin/droid-runtime.ts`, `src/bin/codex-runtime.ts`, and `src/bin/ccsxp-runtime.ts`)
|
||||
- `argv[0]` detection for custom/same-binary runtime aliases
|
||||
- Per-profile `target` field (from config.yaml)
|
||||
- Default: `claude`
|
||||
|
||||
3. **Implementations:**
|
||||
- **ClaudeAdapter** - Wraps existing behavior; delivers credentials via environment variables
|
||||
- **DroidAdapter** - New; writes to ~/.factory/settings.json and spawns with `-m custom:ccs-<profile>` flag
|
||||
|
||||
4. **Registry** - Map-based lookup (O(1)) for registered adapters at runtime
|
||||
|
||||
**Usage flow:**
|
||||
```
|
||||
Profile resolution (existing)
|
||||
↓
|
||||
Target resolution (via resolver.ts)
|
||||
↓
|
||||
Get adapter from registry
|
||||
↓
|
||||
Prepare credentials (adapter.prepareCredentials)
|
||||
↓
|
||||
Build args & env (adapter.buildArgs, buildEnv)
|
||||
↓
|
||||
Spawn target CLI (adapter.exec)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Source (`ui/src/`)
|
||||
|
||||
The React dashboard organized by domain with barrel exports at every level.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
ui/src/
|
||||
├── components/
|
||||
│ ├── index.ts # Main barrel (aggregates all domains)
|
||||
│ │
|
||||
│ ├── account/ # Account management
|
||||
│ │ ├── index.ts # Barrel export
|
||||
│ │ ├── accounts-table.tsx
|
||||
│ │ ├── add-account-dialog.tsx
|
||||
│ │ └── flow-viz/ # Flow visualization (split from 1,144-line file)
|
||||
│ │ ├── index.tsx # Main component (200 lines)
|
||||
│ │ ├── account-card.tsx
|
||||
│ │ ├── account-card-stats.tsx
|
||||
│ │ ├── connection-timeline.tsx
|
||||
│ │ ├── flow-paths.tsx
|
||||
│ │ ├── flow-viz-header.tsx
|
||||
│ │ ├── provider-card.tsx
|
||||
│ │ ├── hooks.ts
|
||||
│ │ ├── types.ts
|
||||
│ │ ├── utils.ts
|
||||
│ │ ├── path-utils.ts
|
||||
│ │ └── zone-utils.ts
|
||||
│ │
|
||||
│ ├── analytics/ # Usage charts, stats cards
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── cliproxy-stats-card.tsx
|
||||
│ │ └── usage-trend-chart.tsx
|
||||
│ │
|
||||
│ ├── cliproxy/ # CLIProxy configuration
|
||||
│ │ ├── index.ts # Barrel export (30 lines)
|
||||
│ │ ├── provider-editor/ # Split from 921-line file
|
||||
│ │ │ ├── index.tsx # Main editor (250 lines)
|
||||
│ │ │ └── [13 focused modules]
|
||||
│ │ ├── config/ # YAML editor, file tree
|
||||
│ │ │ ├── config-split-view.tsx
|
||||
│ │ │ ├── diff-dialog.tsx
|
||||
│ │ │ ├── file-tree.tsx
|
||||
│ │ │ └── yaml-editor.tsx
|
||||
│ │ ├── overview/ # Health lists, preferences
|
||||
│ │ │ ├── credential-health-list.tsx
|
||||
│ │ │ ├── model-preferences-grid.tsx
|
||||
│ │ │ └── quick-stats-row.tsx
|
||||
│ │ └── [7 top-level component files]
|
||||
│ │
|
||||
│ ├── copilot/ # Copilot settings
|
||||
│ │ ├── index.ts
|
||||
│ │ └── config-form/ # Split from 846-line file
|
||||
│ │ └── [13 focused modules]
|
||||
│ │
|
||||
│ ├── health/ # System health gauges
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── layout/ # App structure
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── sidebar.tsx
|
||||
│ │ └── footer.tsx
|
||||
│ │
|
||||
│ ├── monitoring/ # Error logs, auth monitor
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── proxy-status-widget.tsx
|
||||
│ │ ├── auth-monitor/ # Split from 465-line file (8 files)
|
||||
│ │ │ ├── index.tsx # Main component
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ ├── hooks.ts
|
||||
│ │ │ ├── utils.ts
|
||||
│ │ │ └── components/
|
||||
│ │ │ ├── live-pulse.tsx
|
||||
│ │ │ ├── inline-stats-badge.tsx
|
||||
│ │ │ ├── provider-card.tsx
|
||||
│ │ │ └── summary-card.tsx
|
||||
│ │ └── error-logs/ # Split from 617-line file
|
||||
│ │ └── [6 focused modules]
|
||||
│ │
|
||||
│ ├── profiles/ # Profile management
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── profile-dialog.tsx
|
||||
│ │ ├── profile-create-dialog.tsx
|
||||
│ │ └── editor/ # Split from 531-line file
|
||||
│ │ └── [10 focused modules]
|
||||
│ │
|
||||
│ ├── setup/ # Quick setup wizard
|
||||
│ │ ├── index.ts
|
||||
│ │ └── wizard/ # Step-based wizard
|
||||
│ │ ├── index.tsx
|
||||
│ │ └── steps/
|
||||
│ │
|
||||
│ ├── shared/ # Reusable components (19 components)
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── ccs-logo.tsx
|
||||
│ │ ├── code-editor.tsx
|
||||
│ │ ├── confirm-dialog.tsx
|
||||
│ │ ├── provider-icon.tsx
|
||||
│ │ ├── settings-dialog.tsx
|
||||
│ │ ├── stat-card.tsx
|
||||
│ │ └── [13 more shared components]
|
||||
│ │
|
||||
│ └── ui/ # shadcn/ui primitives
|
||||
│ ├── button.tsx
|
||||
│ ├── card.tsx
|
||||
│ ├── dialog.tsx
|
||||
│ ├── searchable-select.tsx # Shared searchable combobox for model pickers
|
||||
│ ├── sidebar.tsx # Custom sidebar (674 lines)
|
||||
│ └── [UI primitives...]
|
||||
│
|
||||
├── contexts/ # React Contexts
|
||||
│ ├── privacy-context.tsx
|
||||
│ ├── theme-context.tsx
|
||||
│ └── websocket-context.tsx
|
||||
│
|
||||
├── hooks/ # Custom hooks (domain-prefixed)
|
||||
│ ├── use-accounts.ts
|
||||
│ ├── use-cliproxy.ts
|
||||
│ ├── use-health.ts
|
||||
│ ├── use-profiles.ts
|
||||
│ ├── use-websocket.ts
|
||||
│ └── [more hooks...]
|
||||
│
|
||||
├── lib/ # Utilities
|
||||
│ ├── api.ts # API client
|
||||
│ ├── model-catalogs.ts # Model definitions
|
||||
│ └── utils.ts # Helper functions
|
||||
│
|
||||
├── pages/ # Page components (lazy-loaded)
|
||||
│ ├── analytics/ # Split from 420-line file (8 files)
|
||||
│ │ ├── index.tsx # Main layout
|
||||
│ │ ├── types.ts # Analytics types
|
||||
│ │ ├── hooks.ts # Data fetching hooks
|
||||
│ │ ├── utils.ts # Utility functions
|
||||
│ │ └── components/
|
||||
│ │ ├── analytics-header.tsx
|
||||
│ │ ├── analytics-skeleton.tsx
|
||||
│ │ ├── charts-grid.tsx
|
||||
│ │ └── cost-by-model-card.tsx
|
||||
│ ├── settings/ # Split from 1,781-line file (20 files)
|
||||
│ │ ├── index.tsx # Main layout with lazy loading
|
||||
│ │ ├── context.tsx # Settings provider wrapper
|
||||
│ │ ├── settings-context.ts
|
||||
│ │ ├── types.ts
|
||||
│ │ ├── hooks.ts # Legacy re-exports
|
||||
│ │ ├── hooks/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── context-hooks.ts
|
||||
│ │ │ ├── use-official-channels-config.ts
|
||||
│ │ │ ├── use-settings-tab.ts
|
||||
│ │ │ ├── use-proxy-config.ts
|
||||
│ │ │ ├── use-websearch-config.ts
|
||||
│ │ │ ├── use-globalenv-config.ts
|
||||
│ │ │ └── use-raw-config.ts
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── section-skeleton.tsx
|
||||
│ │ │ └── tab-navigation.tsx
|
||||
│ │ └── sections/
|
||||
│ │ ├── channels.tsx
|
||||
│ │ ├── globalenv-section.tsx
|
||||
│ │ ├── websearch/
|
||||
│ │ │ ├── index.tsx
|
||||
│ │ │ └── provider-card.tsx
|
||||
│ │ └── proxy/
|
||||
│ │ ├── index.tsx
|
||||
│ │ ├── local-proxy-card.tsx
|
||||
│ │ └── remote-proxy-card.tsx
|
||||
│ ├── api.tsx # API profiles page (350 lines)
|
||||
│ ├── cliproxy.tsx # CLIProxy page (405 lines)
|
||||
│ ├── copilot.tsx # Copilot page (295 lines)
|
||||
│ └── health.tsx # Health page (256 lines)
|
||||
│
|
||||
└── providers/ # Context providers
|
||||
└── websocket-provider.tsx
|
||||
```
|
||||
|
||||
### Component Statistics
|
||||
|
||||
| Domain | Components | Subdirs | Split Files |
|
||||
|--------|------------|---------|-------------|
|
||||
| account | 3 | flow-viz (12 files) | 1 monster split |
|
||||
| analytics | 3 | - | - |
|
||||
| cliproxy | 10 | provider-editor, config, overview | 1 monster split |
|
||||
| copilot | 2 | config-form (13 files) | 1 monster split |
|
||||
| health | 2 | - | - |
|
||||
| layout | 3 | - | - |
|
||||
| monitoring | 3 | auth-monitor (8 files), error-logs (6 files) | 2 monster splits |
|
||||
| profiles | 4 | editor (10 files) | 1 monster split |
|
||||
| setup | 2 | wizard/steps | - |
|
||||
| shared | 19 | - | - |
|
||||
| **Total** | **51+** | **10 subdirs** | **7 splits** |
|
||||
|
||||
### Page Statistics
|
||||
|
||||
| Page | Structure | Files | Notes |
|
||||
|------|-----------|-------|-------|
|
||||
| analytics | Directory | 8 | Split 2025-12-21 |
|
||||
| settings | Directory | 20 | Split 2025-12-21, lazy-loaded sections |
|
||||
| api | Single file | 1 | 350 lines |
|
||||
| cliproxy | Single file | 1 | 405 lines |
|
||||
| copilot | Single file | 1 | 295 lines |
|
||||
| health | Single file | 1 | 256 lines |
|
||||
|
||||
---
|
||||
|
||||
## Key File Metrics
|
||||
|
||||
### Largest Files (Acceptable Exceptions)
|
||||
|
||||
**CLI (`src/`):**
|
||||
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| model-pricing.ts | 920 | Static pricing fallback and resolver entrypoint |
|
||||
| glmt-proxy.ts | 675 | Legacy internal compatibility path - acceptable for now |
|
||||
| cliproxy-executor.ts | 666 | Core logic - acceptable |
|
||||
| cliproxy-command.ts | 634 | Could split if needed |
|
||||
| usage/handlers.ts | 633 | Could split if needed |
|
||||
| ccs.ts | 596 | Entry point - acceptable |
|
||||
| unified-config-loader.ts | 546 | Complex - acceptable |
|
||||
|
||||
**UI (`ui/src/`):**
|
||||
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| components/ui/sidebar.tsx | 674 | shadcn - acceptable |
|
||||
| pages/cliproxy.tsx | 405 | Acceptable |
|
||||
| pages/api.tsx | 350 | Acceptable |
|
||||
| pages/copilot.tsx | 295 | Acceptable |
|
||||
| pages/health.tsx | 256 | Acceptable |
|
||||
|
||||
**Split Files (Completed):**
|
||||
|
||||
| Original | Lines | New Location | Files |
|
||||
|----------|-------|--------------|-------|
|
||||
| pages/settings.tsx | 1,781 | pages/settings/ | 20 |
|
||||
| pages/analytics.tsx | 420 | pages/analytics/ | 8 |
|
||||
| monitoring/auth-monitor.tsx | 465 | monitoring/auth-monitor/ | 8 |
|
||||
|
||||
---
|
||||
|
||||
## Import Patterns
|
||||
|
||||
### Standard Import Path
|
||||
|
||||
```typescript
|
||||
// From any file in src/
|
||||
import { Config, Settings } from '../types';
|
||||
import { execClaudeWithCLIProxy } from '../cliproxy';
|
||||
import { handleError } from '../errors';
|
||||
|
||||
// From any file in ui/src/
|
||||
import { AccountsTable, ProviderIcon, StatCard } from '@/components';
|
||||
import { useAccounts, useProfiles } from '@/hooks';
|
||||
```
|
||||
|
||||
### Barrel Export Pattern
|
||||
|
||||
Every domain directory has an `index.ts` that aggregates exports:
|
||||
|
||||
```typescript
|
||||
// ui/src/components/cliproxy/index.ts
|
||||
export { CategorizedModelSelector } from './categorized-model-selector';
|
||||
export { CliproxyDialog } from './cliproxy-dialog';
|
||||
// ...
|
||||
|
||||
// From subdirectories
|
||||
export { ProviderEditor } from './provider-editor';
|
||||
export type { ProviderEditorProps } from './provider-editor';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Unit tests (7 core test files)
|
||||
│ ├── data-aggregator.test.ts
|
||||
│ ├── cliproxy/
|
||||
│ │ └── remote-proxy-client.test.ts
|
||||
│ ├── commands/
|
||||
│ │ └── env-command.test.ts
|
||||
│ ├── jsonl-parser.test.ts
|
||||
│ ├── model-pricing.test.ts
|
||||
│ ├── unified-config.test.ts
|
||||
│ └── mcp-manager.test.ts
|
||||
├── integration/ # Integration tests
|
||||
├── native/ # Native install tests
|
||||
│ ├── linux/
|
||||
│ ├── macos/
|
||||
│ └── windows/
|
||||
├── npm/ # npm package tests
|
||||
├── shared/ # Shared test utilities
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Test Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tests | 1440 |
|
||||
| Passing | 1440 |
|
||||
| Skipped | 6 |
|
||||
| Failed | 0 |
|
||||
| Coverage Threshold | 90% |
|
||||
| Test Files | 41 |
|
||||
|
||||
---
|
||||
|
||||
## Build Outputs
|
||||
|
||||
| Output | Source | Purpose |
|
||||
|--------|--------|---------|
|
||||
| `dist/` | `src/` | npm package (CLI) |
|
||||
| `dist/ui/` | `ui/src/` | Built React app (served by Express) |
|
||||
| `lib/` | N/A | Native shell scripts |
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Code Standards](./code-standards.md) - Modularization patterns, file size rules
|
||||
- [System Architecture](./system-architecture/index.md) - High-level architecture diagrams
|
||||
- [Project Roadmap](./project-roadmap.md) - Modularization phases and future work
|
||||
- [WebSearch](./websearch.md) - WebSearch feature documentation
|
||||
- [Image Analysis](./image-analysis.md) - First-class ImageAnalysis runtime documentation
|
||||
- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance
|
||||
CCS is a TypeScript/Bun CLI and local React dashboard for selecting profiles,
|
||||
preparing provider credentials, and launching Claude Code, Codex CLI, Factory
|
||||
Droid, and compatible proxy-backed workflows. This page maps stable ownership;
|
||||
source and tests remain the implementation truth.
|
||||
|
||||
## Runtime Surfaces
|
||||
|
||||
| Surface | Entry point | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| `ccs` CLI | [`src/ccs.ts`](../src/ccs.ts) | Parse global input, register targets, resolve profiles, dispatch commands or runtimes |
|
||||
| Runtime aliases | [`package.json`](../package.json) | Expose packaged binaries such as `ccs`, `ccsx`, and target-specific entry points |
|
||||
| Command handlers | [`src/commands/`](../src/commands/) | Implement CCS-owned command families and help text |
|
||||
| Local web server | [`src/web-server/index.ts`](../src/web-server/index.ts) | Serve configuration APIs, WebSocket updates, and the built dashboard |
|
||||
| Dashboard | [`ui/src/main.tsx`](../ui/src/main.tsx) | Mount the React application used by `ccs config` |
|
||||
| Bootstrap wrappers | [`lib/`](../lib/) | Start the packaged CLI on Unix and Windows |
|
||||
|
||||
Detailed user workflows and command reference live at
|
||||
[docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca). Do not infer current flags
|
||||
from this overview; inspect the owning handler and its tests.
|
||||
|
||||
## CLI Domain Ownership
|
||||
|
||||
| Domain | Main source | Owns |
|
||||
| --- | --- | --- |
|
||||
| Command routing | [`src/commands/`](../src/commands/) | Command parsing, command-specific help, setup, doctor, config, Docker, and management flows |
|
||||
| Profile dispatch | [`src/dispatcher/`](../src/dispatcher/) | Resolve a launch into target-specific execution flows |
|
||||
| Runtime targets | [`src/targets/`](../src/targets/) | Target metadata, resolution, adapters, binary detection, and execution |
|
||||
| Configuration | [`src/config/`](../src/config/) | Schema validation, loading, and normalized configuration access |
|
||||
| CLIProxy | [`src/cliproxy/`](../src/cliproxy/) | Provider auth, configuration, routing, quota, lifecycle, and execution |
|
||||
| Authentication | [`src/auth/`](../src/auth/) and [`src/codex-auth/`](../src/codex-auth/) | Account and OAuth-oriented authentication flows |
|
||||
| Channels | [`src/channels/`](../src/channels/) | Official Claude channel readiness and configuration |
|
||||
| Local proxy | [`src/proxy/`](../src/proxy/) | Anthropic-compatible proxy server and request/response transformers |
|
||||
| Web API | [`src/api/`](../src/api/) and [`src/web-server/`](../src/web-server/) | Local dashboard services, routes, middleware, health, usage, and live updates |
|
||||
| Shared services | [`src/services/`](../src/services/) | Cross-cutting runtime services, including structured logging |
|
||||
| Errors | [`src/errors/`](../src/errors/) | Typed error taxonomy, handling, and exit behavior |
|
||||
| Utilities | [`src/utils/`](../src/utils/) | CCS path handling and bounded shared helpers for browser, hooks, web search, image analysis, and UI support |
|
||||
| Compatibility | [`src/glmt/`](../src/glmt/), [`src/copilot/`](../src/copilot/), [`src/cursor/`](../src/cursor/) | Legacy translation and integration-specific behavior |
|
||||
| Packaging/runtime bins | [`src/bin/`](../src/bin/) | Target-specific packaged entry points |
|
||||
|
||||
The table is intentionally domain-level. Use repository search and nearby tests
|
||||
to find the current implementation instead of relying on a recursive file tree.
|
||||
|
||||
## Profile and Target Dispatch
|
||||
|
||||
Profile resolution follows the repository contract in
|
||||
[`CLAUDE.md`](../CLAUDE.md):
|
||||
|
||||
1. built-in CLIProxy providers
|
||||
2. user-defined `config.cliproxy` providers
|
||||
3. settings-based `config.profiles`
|
||||
4. account-based `profiles.json` entries with isolated `CLAUDE_CONFIG_DIR`
|
||||
|
||||
Target selection is a separate layer:
|
||||
|
||||
| Concern | Source |
|
||||
| --- | --- |
|
||||
| Target names, aliases, and persistence | [`src/targets/target-metadata.ts`](../src/targets/target-metadata.ts) |
|
||||
| Selection priority and `--target` parsing | [`src/targets/target-resolver.ts`](../src/targets/target-resolver.ts) |
|
||||
| Adapter interface | [`src/targets/target-adapter.ts`](../src/targets/target-adapter.ts) |
|
||||
| Adapter registry | [`src/targets/target-registry.ts`](../src/targets/target-registry.ts) |
|
||||
| Claude implementation | [`src/targets/claude-adapter.ts`](../src/targets/claude-adapter.ts) |
|
||||
| Droid implementation | [`src/targets/droid-adapter.ts`](../src/targets/droid-adapter.ts) |
|
||||
| Codex implementation | [`src/targets/codex-adapter.ts`](../src/targets/codex-adapter.ts) |
|
||||
|
||||
All targets currently marked `persistedTarget` in target metadata are valid
|
||||
profile targets. Runtime aliases are also derived from that metadata. Link to
|
||||
the source instead of maintaining a second target list here.
|
||||
|
||||
At startup, [`src/ccs.ts`](../src/ccs.ts) registers adapters. The dispatcher
|
||||
resolves the profile and target, asks the adapter to prepare credentials and
|
||||
arguments, then executes the selected CLI. Target-specific behavior belongs in
|
||||
the adapter or its supporting target module, not in generic command routing.
|
||||
|
||||
## Configuration and Local State
|
||||
|
||||
[`src/utils/config-manager.ts`](../src/utils/config-manager.ts) owns CCS home
|
||||
resolution and honors `CCS_HOME`. Tests must point `CCS_HOME` at a temporary
|
||||
directory and must not touch a contributor's real `~/.ccs/` or `~/.claude/`.
|
||||
|
||||
Configuration schemas and loaders live under [`src/config/`](../src/config/).
|
||||
Provider- and CLIProxy-specific persistence stays under
|
||||
[`src/cliproxy/config/`](../src/cliproxy/config/). Values written to settings
|
||||
environment maps must remain strings.
|
||||
|
||||
Some integrations intentionally write state owned by the launched runtime, such
|
||||
as Claude channel configuration or Codex configuration. Verify those boundaries
|
||||
in the owning module and tests before changing paths, permissions, or cleanup.
|
||||
|
||||
## Dashboard Ownership
|
||||
|
||||
The dashboard is a separate TypeScript package under [`ui/`](../ui/):
|
||||
|
||||
| Area | Path | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| Pages | [`ui/src/pages/`](../ui/src/pages/) | Route-level orchestration and settings sections |
|
||||
| Components | [`ui/src/components/`](../ui/src/components/) | Domain UI and shared primitives |
|
||||
| Hooks | [`ui/src/hooks/`](../ui/src/hooks/) | Server-state access and reusable UI behavior |
|
||||
| Contexts/providers | [`ui/src/contexts/`](../ui/src/contexts/) and [`ui/src/providers/`](../ui/src/providers/) | Cross-page client state |
|
||||
| Libraries | [`ui/src/lib/`](../ui/src/lib/) | API client, localization, catalogs, formatting, and helpers |
|
||||
|
||||
The browser communicates with routes and services under
|
||||
[`src/web-server/`](../src/web-server/). When a configuration feature supports
|
||||
both surfaces, keep CLI and dashboard behavior aligned.
|
||||
|
||||
Localization codes, normalization, persistence, and fallback are owned by
|
||||
[`ui/src/lib/locales.ts`](../ui/src/lib/locales.ts). Translation resources and
|
||||
i18next wiring live in [`ui/src/lib/i18n.ts`](../ui/src/lib/i18n.ts).
|
||||
|
||||
## Logging and Operational Data
|
||||
|
||||
Structured CCS logging lives in
|
||||
[`src/services/logging/`](../src/services/logging/). Dashboard log routes and
|
||||
services live under [`src/web-server/`](../src/web-server/), with UI consumers
|
||||
under [`ui/src/components/logs/`](../ui/src/components/logs/) and the matching
|
||||
page and hooks.
|
||||
|
||||
Keep secrets and raw credentials out of logs. Treat legacy CLIProxy log files as
|
||||
a distinct source rather than folding them into CCS-owned structured logs.
|
||||
|
||||
## Tests
|
||||
|
||||
Root TypeScript tests run with Bun's test runner. Bucket selection is implemented
|
||||
by [`scripts/run-test-bucket.js`](../scripts/run-test-bucket.js). The dashboard
|
||||
uses Vitest as configured in [`ui/package.json`](../ui/package.json).
|
||||
|
||||
| Coverage area | Location |
|
||||
| --- | --- |
|
||||
| Focused module behavior | [`tests/unit/`](../tests/unit/) and colocated `src/**/__tests__/` |
|
||||
| Cross-module behavior | [`tests/integration/`](../tests/integration/) |
|
||||
| CLI end-to-end behavior | [`tests/e2e/`](../tests/e2e/) |
|
||||
| Package installation and exports | [`tests/npm/`](../tests/npm/) |
|
||||
| Shell and platform behavior | [`tests/native/`](../tests/native/) |
|
||||
| Docker public contracts | [`tests/docker/`](../tests/docker/) |
|
||||
| Documentation checks | [`tests/docs/`](../tests/docs/) |
|
||||
| Dashboard behavior | [`ui/tests/`](../ui/tests/) and colocated UI tests |
|
||||
|
||||
Commands and bucket behavior are documented in
|
||||
[`tests/README.md`](../tests/README.md) and defined in
|
||||
[`package.json`](../package.json). Avoid copying test counts or pass totals into
|
||||
evergreen documentation.
|
||||
|
||||
## Build and Release
|
||||
|
||||
| Output or process | Source of truth |
|
||||
| --- | --- |
|
||||
| CLI compilation into `dist/` | root scripts in [`package.json`](../package.json) |
|
||||
| Dashboard build into `dist/ui/` | UI and root build scripts |
|
||||
| Bundle verification | [`scripts/verify-bundle.js`](../scripts/verify-bundle.js) |
|
||||
| Local CI-equivalent gate | [`scripts/ci-parity-gate.sh`](../scripts/ci-parity-gate.sh) |
|
||||
| Commit policy | [`commitlint.config.cjs`](../commitlint.config.cjs) |
|
||||
| Branch-aware releases | [`.releaserc.cjs`](../.releaserc.cjs) |
|
||||
| GitHub automation | [`.github/workflows/`](../.github/workflows/) |
|
||||
|
||||
Semantic-release owns versions, changelog updates, tags, npm publication, and
|
||||
GitHub releases. Generated `dist/` contents and release artifacts are outputs,
|
||||
not architectural source.
|
||||
|
||||
## Documentation Map
|
||||
|
||||
- [Maintainer docs index](./README.md)
|
||||
- [Code standards](./code-standards.md)
|
||||
- [System architecture](./system-architecture/index.md)
|
||||
- [Project roadmap](./project-roadmap.md)
|
||||
- [Dashboard i18n](./i18n-dashboard.md)
|
||||
- [OpenAI-compatible provider routing](./openai-compatible-providers.md)
|
||||
- [Image analysis user guide](https://docs.ccs.kaitran.ca/features/ai/image-analysis)
|
||||
- [AI agent guide](../CLAUDE.md)
|
||||
- [Contributor guide](../CONTRIBUTING.md)
|
||||
|
||||
Update this summary only when domain ownership, stable entry points, build
|
||||
boundaries, or truth sources change.
|
||||
+58
-202
@@ -1,217 +1,73 @@
|
||||
# Codex Auth Profile Isolation (`ccsx auth`)
|
||||
# Codex Auth Developer Contract
|
||||
|
||||
Run two Codex accounts simultaneously — one per terminal — with full auth isolation.
|
||||
The canonical [Codex Adapter guide](https://docs.ccs.kaitran.ca/features/workflow/codex-adapter)
|
||||
owns user setup. This local contract documents active `ccsx auth` invariants
|
||||
that contributors and operators must preserve.
|
||||
|
||||
## Why
|
||||
## Command Surface
|
||||
|
||||
Codex stores its OAuth credentials in a single directory (`~/.codex/`). When you run two
|
||||
`codex` sessions in separate terminals, they both write to the same `auth.json`. A token
|
||||
refresh in one session overwrites the other's credentials.
|
||||
`ccsx auth` owns `create`, `login`, `switch`, `use`, `show`, `remove`, and
|
||||
`import-default`. Keep syntax and option changes sourced from
|
||||
[`src/codex-auth/codex-auth-help.ts`](../src/codex-auth/codex-auth-help.ts)
|
||||
rather than copying a long command reference here.
|
||||
|
||||
`ccsx auth` solves this by giving each account its own profile directory under
|
||||
`~/.ccs/codex-instances/<name>/`. Each profile holds its own `auth.json` and
|
||||
`history.jsonl`, plus its own session data. Shared `config.toml`, `agents/`, `skills/`,
|
||||
and plugin cache resources come from `~/.codex/` so configuration and installed plugin
|
||||
skills stay in sync across profiles.
|
||||
- `create <name>` is idempotent and starts native `codex login` for new
|
||||
profiles. `--force` repairs shared resources without replacing `auth.json`.
|
||||
- `switch <name>` changes the persistent registry default.
|
||||
- `use <name>` emits only shell-evaluable `CODEX_HOME` and
|
||||
`CCS_CODEX_PROFILE` assignments to stdout. It affects the current shell after
|
||||
`eval`/`source`; diagnostics stay on stderr.
|
||||
- `ccsx <name>` launches a named profile directly without changing the
|
||||
persistent default.
|
||||
|
||||
## Quick start (4 commands)
|
||||
## Import Safety
|
||||
|
||||
```bash
|
||||
# Create and authenticate two profiles
|
||||
ccsx auth create work # creates ~/.ccs/codex-instances/work/ and prompts for login
|
||||
ccsx auth create personal # same for personal account
|
||||
`import-default <name>` imports native `~/.codex/auth.json` without deleting the
|
||||
source. The implementation in
|
||||
[`import-default-command.ts`](../src/codex-auth/commands/import-default-command.ts)
|
||||
must continue to:
|
||||
|
||||
# Activate per terminal (ephemeral — only this shell)
|
||||
# Terminal A:
|
||||
eval "$(ccsx auth use work)"
|
||||
codex
|
||||
- refuse import while a current-user Codex process may be refreshing tokens,
|
||||
unless the operator explicitly accepts the race with
|
||||
`--force-while-running`;
|
||||
- retry and validate JSON/JWT shape, reject CLIProxy auth-file formats, and fail
|
||||
without registering a profile when a torn write persists;
|
||||
- write the destination atomically with private permissions;
|
||||
- omit history and sessions unless `--with-history` is requested;
|
||||
- refuse an existing profile unless `--force` is used, and preserve its current
|
||||
`auth.json` as `auth.json.bak-<timestamp>` before overwrite.
|
||||
|
||||
# Terminal B:
|
||||
eval "$(ccsx auth use personal)"
|
||||
codex
|
||||
## Storage And Cross-Platform Fallback
|
||||
|
||||
# Or launch a named profile directly through ccsx
|
||||
ccsx work
|
||||
```
|
||||
|
||||
## Two-terminal example
|
||||
|
||||
```bash
|
||||
# Terminal A — work account
|
||||
eval "$(ccsx auth use work)"
|
||||
codex # runs with CODEX_HOME=~/.ccs/codex-instances/work
|
||||
|
||||
# Terminal B — personal account (simultaneously)
|
||||
eval "$(ccsx auth use personal)"
|
||||
codex # runs with CODEX_HOME=~/.ccs/codex-instances/personal
|
||||
|
||||
# No token clobbering. Each session refreshes its own auth.json only.
|
||||
```
|
||||
|
||||
## Command reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `ccsx auth create <name>` | Create profile dir + auto-login |
|
||||
| `ccsx <name>` | Launch a named Codex auth profile |
|
||||
| `ccsx auth login <name>` | (Re-)authenticate an existing profile |
|
||||
| `ccsx auth switch <name>` | Set the persistent default profile for future `ccsx` launches |
|
||||
| `ccsx auth use <name>` | Emit shell exports for this shell only (use with `eval`) |
|
||||
| `ccsx auth show [name]` | List all profiles or show details for one |
|
||||
| `ccsx auth remove <name>` | Delete profile dir + registry entry |
|
||||
| `ccsx auth import-default <name>` | Migrate legacy `~/.codex/auth.json` into a new profile |
|
||||
|
||||
## Persistent vs ephemeral switching
|
||||
|
||||
| Method | Scope | How |
|
||||
|--------|-------|-----|
|
||||
| `ccsx <name>` | One launch | Resolves `<name>` from the Codex profile registry |
|
||||
| `ccsx auth switch <name>` | Future `ccsx` launches | Writes to `~/.ccs/codex-profiles.yaml` |
|
||||
| `eval "$(ccsx auth use <name>)"` | Current shell only | Sets `CODEX_HOME` + `CCS_CODEX_PROFILE` in your shell |
|
||||
|
||||
Native `codex` shells only see the persistent default when launched through the `ccsx`
|
||||
Codex runtime. For an already-open shell or a plain native `codex` binary, use `auth use`.
|
||||
|
||||
Do not use `ccs persist codex` for Claude Code or the Claude Code Extension. That path
|
||||
would persist Claude settings that send Claude traffic through the Codex translator. CCS
|
||||
blocks Codex CLIProxy profiles from Claude extension setup; use `ccsxp` or
|
||||
`ccs codex --target codex` for ChatGPT/Codex subscriptions. If old settings were already
|
||||
persisted, clear them with:
|
||||
|
||||
```bash
|
||||
ccs persist default --yes
|
||||
```
|
||||
|
||||
The command prints a config receipt after writing settings: cleared managed keys,
|
||||
written managed keys, whether and where any `/api/provider/codex` translator URL
|
||||
remains, and the native Codex targets to use next.
|
||||
|
||||
Shell syntax for `use`:
|
||||
|
||||
```bash
|
||||
# bash / zsh
|
||||
eval "$(ccsx auth use work)"
|
||||
|
||||
# fish
|
||||
ccsx auth use work | source
|
||||
|
||||
# PowerShell
|
||||
ccsx auth use work | Invoke-Expression
|
||||
```
|
||||
|
||||
## Migration from `~/.codex`
|
||||
|
||||
If you already have a logged-in session in `~/.codex/auth.json`, import it without
|
||||
disturbing the original:
|
||||
|
||||
```bash
|
||||
# Auth only (default — recommended)
|
||||
ccsx auth import-default legacy
|
||||
|
||||
# Auth + history + sessions (opt-in)
|
||||
ccsx auth import-default legacy --with-history
|
||||
|
||||
# Make it the default
|
||||
ccsx auth switch legacy
|
||||
```
|
||||
|
||||
The source `~/.codex/` directory is **never modified**. If `import-default` is not run,
|
||||
`codex` continues to work exactly as before.
|
||||
|
||||
### Torn-write safety
|
||||
|
||||
Codex writes `auth.json` with truncate+write (not atomic rename). Running
|
||||
`import-default` while a token refresh is in flight can produce a corrupt copy.
|
||||
The command detects a running `codex` process via `pgrep` and refuses unless you
|
||||
pass `--force-while-running`. The safest approach is to quit Codex before
|
||||
importing.
|
||||
|
||||
## Dashboard
|
||||
|
||||
The CCS dashboard shows active profile metadata at the **Auth Profiles** tab on the
|
||||
Codex page:
|
||||
|
||||
- Profile name and whether it is the current default
|
||||
- Decoded email address (from `id_token` — no signature verification; display only)
|
||||
- Plan tier (Plus, Pro, Free) when present in the token
|
||||
- Last-used timestamp
|
||||
|
||||
No OAuth tokens are ever returned by the API endpoint or shown in the UI.
|
||||
|
||||
## Profile disk layout
|
||||
|
||||
```
|
||||
```text
|
||||
~/.ccs/
|
||||
├── codex-profiles.yaml # Registry: version, default, profiles metadata
|
||||
└── codex-instances/
|
||||
└── <name>/
|
||||
├── auth.json # OAuth credentials (Codex writes here)
|
||||
├── history.jsonl # Per-profile prompt history (optional)
|
||||
├── sessions/ # Per-profile chat session dirs (optional)
|
||||
├── config.toml -> ~/.codex/config.toml (symlink — shared)
|
||||
├── agents/ -> ~/.codex/agents/ (symlink — shared)
|
||||
├── skills/ -> ~/.codex/skills/ (symlink — shared)
|
||||
└── plugins/ # Profile-local parent; may hold local metadata
|
||||
└── cache/ -> ~/.codex/plugins/cache/ (symlink — shared)
|
||||
|
||||
~/.codex/
|
||||
├── config.toml # Single shared model/provider config
|
||||
├── agents/ # Shared Codex agent role config files
|
||||
├── skills/ # Shared Codex skills
|
||||
└── plugins/
|
||||
└── cache/ # Shared installed plugin payloads
|
||||
├── codex-profiles.yaml
|
||||
└── codex-instances/<name>/
|
||||
├── auth.json, history.jsonl, sessions/ # profile-local
|
||||
├── config.toml -> ~/.codex/config.toml
|
||||
├── agents/ -> ~/.codex/agents/
|
||||
├── skills/ -> ~/.codex/skills/
|
||||
└── plugins/
|
||||
└── cache/ -> ~/.codex/plugins/cache/
|
||||
```
|
||||
|
||||
Only `plugins/cache/` is shared. The profile's parent `plugins/` directory remains a
|
||||
real local directory so Codex can keep profile-specific plugin metadata beside the
|
||||
shared cache.
|
||||
The `plugins/` parent stays profile-local. Shared config, resources, and plugin
|
||||
cache repair must preserve existing profile-local content. On Windows or other
|
||||
systems where symlinks are unavailable, CCS copies missing shared content into
|
||||
the profile and warns that later upstream edits will not propagate
|
||||
automatically. See
|
||||
[`codex-config-symlink.ts`](../src/codex-auth/codex-config-symlink.ts),
|
||||
[`codex-profile-resources.ts`](../src/codex-auth/codex-profile-resources.ts),
|
||||
and
|
||||
[`codex-profile-plugin-cache.ts`](../src/codex-auth/codex-profile-plugin-cache.ts).
|
||||
|
||||
`ccsx auth create <name>` and direct `ccsx <name>` launches repair these links
|
||||
idempotently before Codex starts. This keeps relative entries such as
|
||||
`agents/foo.toml` valid and prevents stale first-launch skill warnings after a plugin
|
||||
install or update changes the cache.
|
||||
## `ccsx` And `ccsxp` Isolation
|
||||
|
||||
## Caveats
|
||||
`ccsx auth` applies only to native Codex profiles. `ccsxp` ignores
|
||||
`CCS_CODEX_PROFILE`, uses native `~/.codex` history by default, and routes
|
||||
through its separate CLIProxy Codex pool. `CCSXP_CODEX_HOME` is its explicit
|
||||
home override. Never make a `ccsx auth switch` silently redirect `ccsxp`, merge
|
||||
their auth stores, or consume `ccsx` import backups as pool credentials.
|
||||
|
||||
### Windows symlinks
|
||||
|
||||
On Windows, creating symlinks requires Developer Mode or elevated privileges.
|
||||
If symlink creation fails, CCS falls back to copying `config.toml`, `agents/`,
|
||||
`skills/`, and the current `plugins/cache/` snapshot. Copies do not update live with
|
||||
`~/.codex/`; after a plugin update, another profile launch or
|
||||
`ccsx auth create <name> --force` repair copies newly missing cache entries. Existing
|
||||
profile-local cache files are preserved.
|
||||
|
||||
### Native Codex project-local config warnings
|
||||
|
||||
`ccsx` preserves your current working directory. If you launch from your home directory,
|
||||
native Codex can also see `~/.codex/config.toml` as `./.codex/config.toml`, a
|
||||
project-local config file. Codex rejects user-level-only keys such as `model_providers`
|
||||
and `notify` in project-local config. That warning comes from native Codex config
|
||||
layering, not from the `ccsx auth` profile resource links. Launch from a project
|
||||
directory or move project-local Codex config out of `$HOME/.codex/config.toml` if the
|
||||
warning is noisy.
|
||||
|
||||
### `ccsx` vs `ccsxp`
|
||||
|
||||
`ccsx auth` profiles apply only to the **native `codex`** CLI. They have no effect on
|
||||
`ccsxp` (the CLIProxy round-robin pool). `ccsxp` unconditionally sets its own
|
||||
`CODEX_HOME` on startup and ignores `CCS_CODEX_PROFILE`.
|
||||
|
||||
If you run `eval "$(ccsx auth use work)"` and then invoke `ccsxp`, a notice is emitted
|
||||
to stderr:
|
||||
|
||||
```
|
||||
[i] CCS_CODEX_PROFILE is ignored by ccsxp; profile applies to native 'codex' only
|
||||
```
|
||||
|
||||
### cmd.exe
|
||||
|
||||
`ccsx auth use` emits `set FOO=bar` syntax for cmd.exe. Native `eval` is not available
|
||||
in legacy cmd — use PowerShell (`Invoke-Expression`) instead.
|
||||
|
||||
### Backup files from `--force`
|
||||
|
||||
When re-importing with `--force`, the existing `auth.json` is backed up as
|
||||
`auth.json.bak-<timestamp>` in the profile directory. These accumulate over time; remove
|
||||
them manually when no longer needed.
|
||||
Behavior locks live under `tests/unit/codex-auth/` and
|
||||
`tests/integration/codex-auth/`.
|
||||
@@ -1,165 +0,0 @@
|
||||
# Cursor IDE Integration
|
||||
|
||||
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` -> `Deprecated` -> `Cursor IDE (Legacy)`.
|
||||
|
||||
## What This Runtime Actually Does
|
||||
|
||||
`ccs legacy cursor` does not launch Cursor IDE itself.
|
||||
|
||||
The current workflow is:
|
||||
1. import Cursor credentials from local SQLite or manual input
|
||||
2. run a local CCS daemon on `127.0.0.1:<port>`
|
||||
3. launch Claude Code against that daemon
|
||||
4. have CCS translate requests to Cursor upstream
|
||||
|
||||
Treat this as a CCS-managed Cursor bridge, not a generic CLIProxy-backed provider path.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Cursor IDE installed and logged in.
|
||||
- CCS installed and configured (`ccs config` works).
|
||||
- For auto-detect auth on macOS/Linux: `sqlite3` available in PATH.
|
||||
|
||||
## CLI Workflow
|
||||
|
||||
### 1) Enable integration
|
||||
|
||||
```bash
|
||||
ccs legacy cursor enable
|
||||
```
|
||||
|
||||
### 2) Import credentials
|
||||
|
||||
Auto-detect from Cursor local SQLite state:
|
||||
|
||||
```bash
|
||||
ccs legacy cursor auth
|
||||
```
|
||||
|
||||
Manual fallback:
|
||||
|
||||
```bash
|
||||
ccs legacy cursor auth --manual --token <token> --machine-id <machine-id>
|
||||
```
|
||||
|
||||
### 3) Start daemon
|
||||
|
||||
```bash
|
||||
ccs legacy cursor start
|
||||
```
|
||||
|
||||
### 4) Run a live probe
|
||||
|
||||
```bash
|
||||
ccs legacy cursor probe
|
||||
```
|
||||
|
||||
Use this to verify that the current build can complete one real authenticated request through the local daemon.
|
||||
|
||||
### 5) Run Cursor-backed Claude
|
||||
|
||||
```bash
|
||||
ccs legacy cursor "explain this repo"
|
||||
```
|
||||
|
||||
### 6) Verify status
|
||||
|
||||
```bash
|
||||
ccs legacy cursor status
|
||||
```
|
||||
|
||||
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 legacy cursor help
|
||||
```
|
||||
|
||||
### 7) Stop daemon
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
- Default port: `20129`
|
||||
- `ghost_mode`: enabled
|
||||
- `auto_start`: disabled
|
||||
- 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 legacy cursor probe` or `POST /api/cursor/probe`
|
||||
|
||||
These values are managed in unified config and can be updated from CLI or dashboard.
|
||||
|
||||
## Dashboard Usage
|
||||
|
||||
Open dashboard:
|
||||
|
||||
```bash
|
||||
ccs config
|
||||
```
|
||||
|
||||
Then navigate to `Cursor IDE (Legacy)` in the `Deprecated` section.
|
||||
|
||||
Available controls:
|
||||
|
||||
- Integration toggle (`enabled`)
|
||||
- Auth actions (auto-detect, manual import)
|
||||
- Daemon actions (start/stop)
|
||||
- Runtime config (port, auto-start, ghost mode)
|
||||
- Models list with searchable combobox filtering for large catalogs
|
||||
- Raw editor for `~/.ccs/cursor.settings.json`
|
||||
|
||||
## Raw Settings and Unified Config Sync
|
||||
|
||||
Raw settings are stored in:
|
||||
|
||||
`~/.ccs/cursor.settings.json`
|
||||
|
||||
When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchronizes that port back into unified config so CLI and dashboard remain consistent.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `Not authenticated` or `expired` in `ccs cursor status`
|
||||
|
||||
- Re-run `ccs legacy cursor auth` (or manual auth command).
|
||||
|
||||
### `ccs legacy cursor probe` fails even though status is green
|
||||
|
||||
- `status` proves local config/auth/daemon readiness only.
|
||||
- `probe` proves the live runtime path.
|
||||
- If `probe` fails with upstream protocol errors, inspect the current CCS build first rather than assuming the local daemon is healthy.
|
||||
|
||||
### Auto-detect fails
|
||||
|
||||
- Ensure Cursor is logged in.
|
||||
- Confirm `sqlite3` is installed or use manual import.
|
||||
- Use manual auth import if needed.
|
||||
|
||||
### Daemon fails to start
|
||||
|
||||
- Check if port `20129` is in use.
|
||||
- Change port in dashboard config tab, then retry `ccs legacy cursor start`.
|
||||
@@ -1,267 +0,0 @@
|
||||
# Dashboard Authentication CLI
|
||||
|
||||
Last Updated: 2026-05-05
|
||||
|
||||
CLI commands for managing CCS dashboard authentication.
|
||||
|
||||
## Overview
|
||||
|
||||
The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, such as when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`.
|
||||
|
||||
Authentication is **disabled by default** for backward compatibility, while `ccs config` binds to `localhost` by default. Use the CLI to configure and enable auth before exposing the dashboard to other devices.
|
||||
|
||||
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)
|
||||
|
||||
Dashboard auth and account context metadata are separate:
|
||||
|
||||
- `dashboard_auth`: protects dashboard access with username/password
|
||||
- `accounts.<name>.context_mode/context_group`: controls isolated vs shared account context
|
||||
- `accounts.<name>.shared_resource_mode`: controls plugins/commands/skills/agents/settings.json sharing
|
||||
|
||||
Account context is isolation-first. The recommended two-account route is:
|
||||
|
||||
```bash
|
||||
ccs auth create work
|
||||
ccs auth create personal
|
||||
ccs work
|
||||
ccs personal
|
||||
```
|
||||
|
||||
Only enable history sync when both accounts should share local continuity while tokens stay separate:
|
||||
|
||||
| Mode | Default | Requirement |
|
||||
|------|---------|-------------|
|
||||
| `isolated` | Yes | No `context_group` required |
|
||||
| `shared` | No (opt-in) | Valid non-empty `context_group` |
|
||||
|
||||
Shared continuity depth:
|
||||
|
||||
- `standard` (default): shares project workspace context only
|
||||
- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
|
||||
|
||||
`ccs auth show <profile>` reports credential isolation, shared resource mode, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane.
|
||||
|
||||
Non-bare account profiles share Claude-local resources with native Claude:
|
||||
|
||||
```text
|
||||
~/.ccs/instances/<profile>/settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json
|
||||
```
|
||||
|
||||
This keeps ordinary Claude settings, plugins, commands, skills, and agents in sync without copying account tokens. Existing accounts can opt out or back in:
|
||||
|
||||
```bash
|
||||
ccs auth resources work --mode profile-local
|
||||
ccs auth resources work --mode shared
|
||||
```
|
||||
|
||||
Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`.
|
||||
|
||||
`context_group` normalization and validation:
|
||||
|
||||
- trim + lowercase + collapse internal whitespace to `-`
|
||||
- allowed characters: lowercase letters, numbers, `_`, `-`
|
||||
- must start with a letter
|
||||
- max length: 64
|
||||
- shared mode requires non-empty value after normalization
|
||||
- `continuity_mode` is only valid when mode is `shared`
|
||||
|
||||
`PUT /api/config` behavior for account context:
|
||||
|
||||
- rejects invalid unified payloads
|
||||
- rejects explicit `context_mode: shared` with invalid/empty `context_group`
|
||||
- rejects invalid `continuity_mode` values
|
||||
- normalizes valid shared `context_group` before save
|
||||
- defaults missing shared `continuity_mode` to `standard`
|
||||
- rejects `context_group` when mode is not `shared`
|
||||
- rejects `continuity_mode` when mode is not `shared`
|
||||
|
||||
Dashboard accounts context editing:
|
||||
|
||||
- `PUT /api/accounts/:name/context` updates context mode/group/continuity for existing auth accounts
|
||||
- rejects CLIProxy OAuth account keys for this route
|
||||
- applies normalization/validation rules above
|
||||
|
||||
Shared resource editing:
|
||||
|
||||
- `PUT /api/accounts/:name/shared-resources` updates `shared_resource_mode` for existing auth accounts
|
||||
- accepts only `shared` or `profile-local`
|
||||
- rejects CLIProxy OAuth account keys for this route
|
||||
- reconciles the account instance after metadata is updated
|
||||
- Dashboard -> Accounts exposes this as a separate Resources action so it is not confused with History Sync.
|
||||
- Dashboard -> Shared Resources shows the shared hub inventory for commands, skills, agents, plugins, and `settings.json`.
|
||||
- The Plugins tab is registry-oriented: installed plugin entries come from `installed_plugins.json`, while internal cache/data/marketplace folders stay hidden unless a real plugin entry exists.
|
||||
- Shared `settings.json` is read-only in the Shared Resources page and still edited through the settings surfaces that own those values.
|
||||
|
||||
## Commands
|
||||
|
||||
### `ccs config auth setup`
|
||||
|
||||
Interactive wizard to configure dashboard login.
|
||||
|
||||
```bash
|
||||
$ ccs config auth setup
|
||||
|
||||
╭─────────────────────────────────╮
|
||||
│ Dashboard Auth Setup │
|
||||
╰─────────────────────────────────╯
|
||||
|
||||
[i] Configure username and password for dashboard access.
|
||||
Password will be hashed with bcrypt before storage.
|
||||
|
||||
Username
|
||||
Enter username: admin
|
||||
|
||||
Password
|
||||
Minimum 8 characters
|
||||
Enter password: ********
|
||||
Confirm password: ********
|
||||
|
||||
[i] Hashing password...
|
||||
|
||||
[OK] Dashboard authentication configured
|
||||
|
||||
[i] Settings saved to ~/.ccs/config.yaml
|
||||
[i] Username: admin
|
||||
[i] Session timeout: 24 hours
|
||||
|
||||
Start dashboard: ccs config
|
||||
Show status: ccs config auth show
|
||||
Disable auth: ccs config auth disable
|
||||
```
|
||||
|
||||
### `ccs config auth show`
|
||||
|
||||
Display current authentication status.
|
||||
|
||||
```bash
|
||||
$ ccs config auth show
|
||||
|
||||
╭─────────────────────────────────╮
|
||||
│ Dashboard Auth Status │
|
||||
╰─────────────────────────────────╯
|
||||
|
||||
Configuration
|
||||
[OK] Authentication: Enabled
|
||||
[OK] Username: admin
|
||||
[i] Session timeout: 24 hours
|
||||
|
||||
Commands
|
||||
ccs config auth setup Configure authentication
|
||||
ccs config auth disable Disable authentication
|
||||
ccs config Open dashboard
|
||||
```
|
||||
|
||||
### `ccs config auth disable`
|
||||
|
||||
Disable dashboard authentication with confirmation.
|
||||
|
||||
```bash
|
||||
$ ccs config auth disable
|
||||
|
||||
╭─────────────────────────────────╮
|
||||
│ Disable Dashboard Auth │
|
||||
╰─────────────────────────────────╯
|
||||
|
||||
[!] This will disable login protection for the dashboard.
|
||||
[i] Anyone with network access will be able to view the dashboard.
|
||||
|
||||
Disable authentication? [y/N]: y
|
||||
|
||||
[OK] Dashboard authentication disabled
|
||||
|
||||
[i] Credentials preserved - re-enable with: ccs config auth setup
|
||||
```
|
||||
|
||||
### `ccs config auth --help`
|
||||
|
||||
Display usage information.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Environment variables override `config.yaml` values:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CCS_DASHBOARD_AUTH_ENABLED` | Enable/disable auth (`true`/`false`) |
|
||||
| `CCS_DASHBOARD_USERNAME` | Username |
|
||||
| `CCS_DASHBOARD_PASSWORD_HASH` | Bcrypt password hash |
|
||||
|
||||
### Generating a Password Hash
|
||||
|
||||
Use bcrypt to generate a hash:
|
||||
|
||||
```bash
|
||||
# Using Node.js
|
||||
node -e "console.log(require('bcrypt').hashSync('your-password', 10))"
|
||||
|
||||
# Using npx
|
||||
npx bcrypt-cli hash "your-password"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are stored in `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
# Dashboard Auth: Optional login protection for CCS dashboard
|
||||
# Generate password hash: npx bcrypt-cli hash "your-password"
|
||||
# ENV override: CCS_DASHBOARD_AUTH_ENABLED, CCS_DASHBOARD_USERNAME, CCS_DASHBOARD_PASSWORD_HASH
|
||||
dashboard_auth:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password_hash: "$2b$10$..."
|
||||
session_timeout_hours: 24
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **Bcrypt hashing**: Passwords are hashed with bcrypt (10 rounds) before storage
|
||||
2. **Session cookies**: Sessions use HTTP-only cookies (not accessible via JavaScript)
|
||||
3. **Rate limiting**: Login attempts are rate-limited (5 per 15 minutes)
|
||||
4. **Fail-closed remote writes**: When auth is disabled, sensitive management routes allow localhost only
|
||||
5. **File permissions**: Config file is created with 0o600 permissions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Authentication not configured"
|
||||
|
||||
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.
|
||||
|
||||
### ENV override not working
|
||||
|
||||
Ensure the variable is exported:
|
||||
|
||||
```bash
|
||||
export CCS_DASHBOARD_AUTH_ENABLED=true
|
||||
export CCS_DASHBOARD_USERNAME=admin
|
||||
export CCS_DASHBOARD_PASSWORD_HASH='$2b$10$...'
|
||||
```
|
||||
|
||||
### Session expired immediately
|
||||
|
||||
Check `session_timeout_hours` in config. Default is 24 hours.
|
||||
|
||||
### "Invalid ... context_group ..."
|
||||
|
||||
This error comes from `PUT /api/config` when an account explicitly sets shared mode with an invalid group. Use a canonical group value (for example: `team-alpha`).
|
||||
|
||||
## See Also
|
||||
|
||||
- [Dashboard Auth Feature](https://ccs.kaitran.ca/features/dashboard-auth) - Full documentation
|
||||
- [Config Schema](https://ccs.kaitran.ca/reference/config-schema) - All config options
|
||||
@@ -1,112 +1,51 @@
|
||||
# Hardening Debt Burndown Tracker
|
||||
# Hardening Debt Burndown
|
||||
|
||||
Last Updated: 2026-06-18
|
||||
Owner: Stream D (`#542`); maintainability epic owner TBD (open Q5)
|
||||
This document defines the live measurement method. Completed phase narratives
|
||||
remain available in Git history and must not be copied forward as current state.
|
||||
|
||||
## Scope
|
||||
|
||||
Maintainability hardening groundwork with low-risk changes:
|
||||
The generated inventory measures:
|
||||
|
||||
- Inventory legacy shims/compatibility markers
|
||||
- Inventory sync filesystem usage, especially runtime hotpaths
|
||||
- Incrementally migrate hotpath sync I/O to async I/O with tests
|
||||
- synchronous filesystem calls across source and in runtime hotpaths;
|
||||
- legacy, shim, and compatibility markers;
|
||||
- typed-error adoption;
|
||||
- direct `console.error` and `console.warn` use outside CLI-UX exemptions;
|
||||
- structured logger coverage;
|
||||
- source files above maintainability size thresholds.
|
||||
|
||||
## How to Measure
|
||||
Runtime-hotpath metrics exclude test, fixture, and mock files. Repository-wide
|
||||
totals may include them when the metric explicitly says `all`.
|
||||
|
||||
Run:
|
||||
## Generate And Verify
|
||||
|
||||
```bash
|
||||
bun run report:hardening
|
||||
node scripts/hardening-inventory.js --check
|
||||
```
|
||||
|
||||
Generated artifacts:
|
||||
|
||||
- `docs/reports/hardening-inventory.json`
|
||||
- `docs/reports/hardening-inventory.md`
|
||||
- `docs/reports/hardening-inventory.json` for tooling;
|
||||
- `docs/reports/hardening-inventory.md` for review.
|
||||
|
||||
## Kickoff Baseline (Issue #542 Stream D)
|
||||
`--check` rebuilds the expected content in memory and compares both tracked
|
||||
artifacts byte-for-byte. Any source change that affects a metric requires
|
||||
regeneration in the same commit.
|
||||
|
||||
The current baseline is sourced from `docs/reports/hardening-inventory.json` after running `bun run report:hardening`.
|
||||
Baseline captured: `2026-02-12`.
|
||||
## Interpretation
|
||||
|
||||
| Metric | Baseline |
|
||||
|---|---:|
|
||||
| Sync fs occurrences (all) | 835 |
|
||||
| Sync fs files affected (all) | 100 |
|
||||
| Sync fs occurrences (runtime hotpaths) | 724 |
|
||||
| Sync fs files affected (runtime hotpaths) | 89 |
|
||||
| Legacy shim markers | 131 |
|
||||
| Legacy shim files affected | 56 |
|
||||
- Treat rankings as investigation queues, not automatic refactor mandates.
|
||||
- Characterize behavior before splitting large or compatibility-sensitive code.
|
||||
- Separate user-facing terminal output from operational diagnostics before
|
||||
migrating console calls.
|
||||
- Preserve intentional shims until their compatibility window and callers are
|
||||
verified.
|
||||
- Prefer trend comparisons from Git history when historical context is needed;
|
||||
do not turn this live method into a dated progress ledger.
|
||||
|
||||
## Initial Async I/O Migration Log
|
||||
## Review Contract
|
||||
|
||||
| Date | Area | Change | Safety Notes |
|
||||
|---|---|---|---|
|
||||
| 2026-02-12 | `src/web-server/jsonl-parser.ts` | Migrated `parseProjectDirectory()` directory listing from sync `readdirSync` to async `fs.promises.readdir` | Existing behavior kept (same filtering/fallback); covered by `tests/unit/jsonl-parser.test.ts` |
|
||||
|
||||
## Maintainability & Traceability Baseline (2026-06-18)
|
||||
|
||||
Baseline for the maintainability/traceability epic (`plans/260618-1346-maintainability-traceability-epic`). Sourced from `docs/reports/hardening-inventory.json` -> `maintainability` block after `bun run report:hardening`. Baseline captured: `2026-06-18`.
|
||||
|
||||
| Metric | Baseline | Epic target | Owner phase |
|
||||
|---|---:|---:|---|
|
||||
| typed-error adoption (typed / total throws) | 0.9% (4 / 431) | >40% in locked subdomains | P4 |
|
||||
| typed-error adoption (locked: cliproxy/quota, cliproxy/auth, web-server/routes, auth) | 0.0% (0 / 23) | >40% | P4 |
|
||||
| hotpath `console.error`/`warn` occurrences (non-exempt) | 931 (1091 total, 160 CLI-UX exempt) | < 10 | P3 |
|
||||
| hotpath `console.error`/`warn` files (non-exempt) | 134 | minimal | P3 |
|
||||
| files with `createLogger` | 35 / 685 (5.1%) | rise across all subdomains | P2/P3 |
|
||||
| subdomains with zero `createLogger` | 20 (incl. api, channels, config, delegation, dispatcher, docker, shared) | 0 in the named set | P2 |
|
||||
| files > 400 LOC | 95 | < 60 after P5+P6 | P5/P6 |
|
||||
| files > 600 LOC | 45 | drop | P5/P6 |
|
||||
| ESLint `no-new-throw-error` gate | not enforced | error + allowlist | P7 |
|
||||
| ESLint `max-lines` gate | not enforced | warn at 400 | P7 |
|
||||
| hardening report freshness | stale (2026-02-12) | < 30d gate in `validate:ci-parity` | P1 |
|
||||
|
||||
### Method
|
||||
|
||||
Metrics are grep-based and approximate (not a contract). Comments and string/template/regex literals are stripped before matching (`scripts/hardening-inventory.js#stripComments`). Subdomain granularity is 2-level under `src/cliproxy/` (`cliproxy/quota`, `cliproxy/auth`, ...) and 1-level elsewhere (`auth`, `config`, ...). The hotpath `console.error` count excludes CLI-UX print surfaces (`src/commands/`, `src/management/`, `src/utils/ui/`) which are legitimate user-facing terminal output. The typed-error denominator for the P4 target is LOCKED to the four named subdomains so the >40% goal cannot be gamed by narrowing scope. Re-baseline whenever the schema or method changes.
|
||||
|
||||
### Largest hotpath console.error offenders (2026-06-18)
|
||||
|
||||
| File | `console.error`/`warn` |
|
||||
|---|---:|
|
||||
| `src/utils/error-manager.ts` | 142 |
|
||||
| `src/cliproxy/accounts/account-safety.ts` | 56 |
|
||||
| `src/cliproxy/config/model-config.ts` | 32 |
|
||||
| `src/cliproxy/executor/arg-parser.ts` | 26 |
|
||||
| `src/dispatcher/flows/settings-flow.ts` | 26 |
|
||||
|
||||
## Progress Log
|
||||
|
||||
| Date | Phase | Change | Metric movement |
|
||||
|---|---|---|---|
|
||||
| 2026-06-18 | P2 | Express `withRequestContext` wrap; `CCS_REQUEST_ID` daemon forwarding + child re-anchor; logger toe-holds in delegation/docker. | zero-createLogger subdomains 20 -> 18 |
|
||||
| 2026-06-18 | P3 | Redaction gate (token-shape scrubbing in context + `Error.message` + message string). `tool-sanitization-proxy` private log subsystem deleted (13 sites -> existing `createLogger`). ~120 diagnostic `console.error` -> structured `createLogger` across proxy, web-server/routes, glmt, quota-fetchers, executors, delegation. User-facing `console.error` (CLI flows, arg-parser usage, installers, prompts, adapter launch errors, error display) migrated to `process.stderr.write` (preserves stderr output). `error-manager.ts` reclassified CLI-UX-exempt (user-facing display). | hotpath `console.error` 928 -> 267 (71%); createLogger files 35 -> 64 |
|
||||
| 2026-06-18 | P4 | `throw new Error` -> typed subclasses (ProfileError/AuthError/ConfigError/ProviderError/ValidationError) in cliproxy/auth, web-server/routes, auth. Error taxonomy made erasable (enum -> const, param-props -> fields) so the UI build accepts it. | typed adoption (locked subdomains) 0/23 -> 21/23 (91.3%); overall 0.9% -> 8.6% |
|
||||
| 2026-06-18 | P5 | Split 4 test-backed god-files into submodule dirs + barrels (shared-manager, cliproxy-stats-routes, persist-command, quota-subcommand). Public API preserved. | files > 400 LOC 95 -> 91 |
|
||||
| 2026-06-18 | P6 | Split 2 of 6 characterization-first god-files (quota-fetcher, quota-fetcher-gemini-cli; both had strong per-provider test coverage). 4 deferred. | files > 400 LOC 91 -> 89 |
|
||||
| 2026-06-18 | P7 | ESLint gates: `ccs/no-new-throw-error` (error, baseline-allowlisted) + `max-lines` (warn, 400). code-standards.md + logging-contract.md (error.code table). | gates enforced (0 lint errors on baseline) |
|
||||
|
||||
## Epic outcome (2026-06-18)
|
||||
|
||||
| Metric | Baseline | Final | Target | Status |
|
||||
|---|---:|---:|---:|---|
|
||||
| typed-error adoption (locked subdomains) | 0.0% (0/23) | 91.3% (21/23) | >40% | met |
|
||||
| typed-error adoption (overall) | 0.9% (5/431) | 8.6% (37/431) | rise | met |
|
||||
| hotpath `console.error`/`warn` | 928 | 267 | <10 | partial; high-risk diagnostics migrated + redaction gate closed, but metric target unmet (see P3 note) |
|
||||
| files with `createLogger` | 35 | 64 | rise | met |
|
||||
| subdomains with zero `createLogger` | 20 | 15 | 0 in named set | partial; logger toe-holds improved coverage but P2 zero-subdomain target unmet |
|
||||
| files > 400 LOC | 95 | 89 | <60 | partial (P6 deferred 4 targets) |
|
||||
| hardening report freshness | stale | <30d gate | gate | met (P1) |
|
||||
| ESLint `no-new-throw-error` | not enforced | error + allowlist | enforced | met (P7) |
|
||||
| ESLint `max-lines` | not enforced | warn at 400 | enforced | met (P7) |
|
||||
|
||||
### Deferred follow-ups
|
||||
|
||||
- P2 remaining zero-logger subdomains (`api`, `channels`, `config`, `dispatcher`, `shared`, and others): add lightweight logger toe-holds when those subdomains next receive behavior work. This epic improved coverage but did not satisfy the original `0` target.
|
||||
- P3 remaining 267 non-exempt `console.error`/`warn` call sites: split into true user-facing terminal output vs diagnostics, then either migrate diagnostics to structured logs or update the metric method to exempt confirmed CLI display helpers.
|
||||
- P6 remaining 4 targets (`oauth-handler.ts`, `cursor-executor.ts`, `tool-sanitization-proxy.ts`, +1): need dedicated characterization-test work before splitting (the plan's characterization-first hard gate). Each has a clean seam identified in the epic plan.
|
||||
|
||||
### P3 residual note (2026-06-18)
|
||||
|
||||
The remaining 267 non-exempt `console.error`/`warn` occurrences are not fully resolved by this epic. Many are user-facing terminal display paths (interactive flows, arg-parser usage errors, installers, prompts, adapter launch failures, error-display helpers), but the generated metric still counts them because its exemption list is intentionally conservative. Treat P3 as partial until the residual list is classified file-by-file and either migrated to structured logs or explicitly moved into the CLI-UX exemption method. The redaction gate now covers structured message, context, `Error.message`, and `StageOptions.error` metadata so further diagnostic migration is safe.
|
||||
A hardening change is complete only when focused tests pass, both inventory
|
||||
artifacts match the current source tree, and the diff explains any material
|
||||
metric movement.
|
||||
+22
-9
@@ -1,7 +1,5 @@
|
||||
# Dashboard i18n Guide
|
||||
|
||||
Last Updated: 2026-04-14
|
||||
|
||||
This document describes the internationalization (i18n) architecture used by the CCS Dashboard (`ui/`), how locale selection works, and how to add new languages safely.
|
||||
|
||||
---
|
||||
@@ -12,9 +10,11 @@ Dashboard i18n currently covers UI text rendered by React components.
|
||||
|
||||
- Supported locales today:
|
||||
- `en` (English)
|
||||
- `pt-BR` (Brazilian Portuguese)
|
||||
- `zh-CN` (Simplified Chinese)
|
||||
- `vi` (Vietnamese)
|
||||
- `ja` (Japanese)
|
||||
- `ko` (Korean)
|
||||
- Locale state is persisted in browser localStorage using `ccs-ui-locale`.
|
||||
- Fallback language is `en`.
|
||||
|
||||
@@ -37,9 +37,11 @@ Out of scope:
|
||||
- File: `ui/src/lib/i18n.ts`
|
||||
- Contains `resources` object with locale blocks:
|
||||
- `en.translation`
|
||||
- `pt-BR.translation`
|
||||
- `zh-CN.translation`
|
||||
- `vi.translation`
|
||||
- `ja.translation`
|
||||
- `ko.translation`
|
||||
- Uses `initReactI18next` for React integration.
|
||||
|
||||
### Locale utilities
|
||||
@@ -59,8 +61,11 @@ Out of scope:
|
||||
### Test bootstrap
|
||||
|
||||
- File: `ui/tests/setup/vitest-setup.ts`
|
||||
- Test setup must import `ui/src/lib/i18n.ts` so direct `useTranslation()` consumers resolve the same singleton instance as the app.
|
||||
- If a test mocks `useTranslation()`, keep the mocked key surface aligned with the component output or the assertions will drift.
|
||||
- Test setup must import `ui/src/lib/i18n.ts` so components using the
|
||||
react-i18next translation hook resolve the same singleton instance as the
|
||||
app.
|
||||
- If a test mocks the translation hook, keep the mocked key surface aligned
|
||||
with the component output or the assertions will drift.
|
||||
|
||||
---
|
||||
|
||||
@@ -104,11 +109,8 @@ When adding a locale such as Thai (`th`):
|
||||
5. Run UI validation and i18n tests:
|
||||
- `cd ui && bun run validate`
|
||||
- `cd ui && bun run test:run tests/unit/ui/i18n/language-switcher.test.tsx`
|
||||
6. Add or update key-parity tests to catch locale drift.
|
||||
|
||||
Current issue driving the Vietnamese rollout:
|
||||
|
||||
- https://github.com/kaitranntt/ccs/issues/659
|
||||
6. Keep the key-parity test passing. It compares every non-English resource
|
||||
against English and verifies interpolation placeholders match.
|
||||
|
||||
---
|
||||
|
||||
@@ -121,3 +123,14 @@ Before opening a PR that touches i18n:
|
||||
- [ ] No unsafe HTML injection path introduced for translated content.
|
||||
- [ ] `ui` validate/test commands pass.
|
||||
- [ ] This document is updated if architecture or conventions changed.
|
||||
|
||||
## References
|
||||
|
||||
- [`ui/src/lib/locales.ts`](../ui/src/lib/locales.ts) - supported locale ids,
|
||||
normalization, persistence, and formatting locale
|
||||
- [`ui/src/lib/i18n.ts`](../ui/src/lib/i18n.ts) - translation resources and
|
||||
i18next initialization
|
||||
- [`ui/src/components/layout/language-switcher.tsx`](../ui/src/components/layout/language-switcher.tsx)
|
||||
- locale selection UI
|
||||
- [`ui/tests/unit/ui/i18n/language-switcher.test.tsx`](../ui/tests/unit/ui/i18n/language-switcher.test.tsx)
|
||||
- selection, persistence, key-parity, and placeholder coverage
|
||||
+6
-117
@@ -1,119 +1,8 @@
|
||||
# Image Analysis Configuration Guide
|
||||
# Image Analysis
|
||||
|
||||
CCS provides first-class image and PDF analysis for third-party Claude launches that do not have reliable native vision support.
|
||||
User setup, routing behavior, and troubleshooting live in the canonical
|
||||
[Image Analysis guide](https://docs.ccs.kaitran.ca/features/ai/image-analysis).
|
||||
|
||||
## How Image Analysis Works
|
||||
|
||||
Native Claude accounts keep Anthropic's own vision flow.
|
||||
|
||||
Third-party profiles now use a CCS-managed local MCP tool named `ImageAnalysis` when the runtime is available. CCS also appends a short steering hint so Claude prefers that tool over `Read` for local image and PDF files.
|
||||
|
||||
Healthy Claude-target launches suppress the legacy CCS `Read` hook so MCP stays authoritative. If the managed runtime cannot be provisioned, CCS keeps the old `Read` hook available only as a compatibility fallback when that path is still viable. If runtime/auth/proxy readiness is degraded beyond that, CCS falls back to native `Read` instead of failing the whole launch.
|
||||
|
||||
## Routing Model
|
||||
|
||||
ImageAnalysis requests go straight to the CCS-managed provider route:
|
||||
|
||||
```text
|
||||
Claude -> ccs-image-analysis MCP -> CCS provider route -> /api/provider/<backend>/v1/messages
|
||||
```
|
||||
|
||||
Important:
|
||||
- CCS does not relay image analysis through Claude Code, another CLI, or a second model wrapper.
|
||||
- For bridge-backed settings profiles, CCS resolves the backend and provider path before launch.
|
||||
- CCS avoids leaking a profile's ordinary third-party `ANTHROPIC_BASE_URL` or token into image analysis unless that profile is explicitly using a CLIProxy bridge.
|
||||
|
||||
## Profile Behavior
|
||||
|
||||
| Profile Type | Image Method |
|
||||
|--------------|--------------|
|
||||
| Claude `default` / `account` | Native Claude vision / native `Read` |
|
||||
| Third-party settings / CLIProxy / Copilot | CCS local `ImageAnalysis` MCP tool when ready |
|
||||
| Third-party when MCP provisioning fails but provider-backed analysis is still viable | Legacy CCS `Read` hook fallback |
|
||||
| Third-party when runtime/auth/proxy is unavailable | Native `Read` fallback |
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure via dashboard (`Settings -> Image`) or `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
image_analysis:
|
||||
enabled: true
|
||||
timeout: 60
|
||||
fallback_backend: agy
|
||||
provider_models:
|
||||
agy: gemini-3-1-flash-preview
|
||||
codex: gpt-5.1-codex-mini
|
||||
ghcp: claude-haiku-4.5
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
ccs config image-analysis
|
||||
ccs config image-analysis --enable
|
||||
ccs config image-analysis --disable
|
||||
ccs config image-analysis --set-fallback agy
|
||||
ccs config image-analysis --set-profile-backend glm agy
|
||||
ccs config image-analysis --clear-profile-backend glm
|
||||
```
|
||||
|
||||
## Prompt Templates
|
||||
|
||||
CCS installs editable prompt templates at:
|
||||
|
||||
```text
|
||||
~/.ccs/prompts/image-analysis/
|
||||
```
|
||||
|
||||
Templates:
|
||||
- `default.txt`
|
||||
- `screenshot.txt`
|
||||
- `document.txt`
|
||||
|
||||
CCS automatically selects `screenshot` for screenshot-like filenames, `document` for PDFs, and `default` otherwise.
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
Key runtime env vars:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `CCS_IMAGE_ANALYSIS_SKIP` | Disable image analysis for the current launch |
|
||||
| `CCS_IMAGE_ANALYSIS_SKIP_HOOK` | Suppress only the legacy CCS `Read` hook while keeping MCP ImageAnalysis available |
|
||||
| `CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL` | Explicit CCS runtime base URL |
|
||||
| `CCS_IMAGE_ANALYSIS_RUNTIME_PATH` | Provider route such as `/api/provider/agy` |
|
||||
| `CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY` | Explicit CCS runtime auth key |
|
||||
| `CCS_IMAGE_ANALYSIS_MODEL` | Force a single image-analysis model |
|
||||
| `CCS_DEBUG` | Verbose runtime logging |
|
||||
|
||||
## Self-Heal
|
||||
|
||||
CCS now auto-heals stale managed image-analysis state in three places:
|
||||
|
||||
- Healthy Claude launches remove stale CCS-managed image `Read` hooks from the active profile settings before launch.
|
||||
- `Settings -> Image` save/provisioning repairs managed MCP runtime files, syncs managed MCP entries into isolated Claude config dirs, and cleans stale CCS-managed image hooks from `~/.ccs/*.settings.json`.
|
||||
- `ccs doctor --fix` repairs invalid image-analysis config, removes stale CCS-managed image hooks, and resyncs managed `ccs-image-analysis` MCP entries into isolated configs.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Claude still uses `Read`
|
||||
|
||||
- Confirm `ccs config image-analysis` shows `enabled: true`
|
||||
- Check the active profile resolves to a configured backend
|
||||
- Run `ccs doctor --fix` to repair stale managed hooks or missing managed MCP sync
|
||||
- Run with `CCS_DEBUG=1` to see runtime preparation details
|
||||
|
||||
### ImageAnalysis is not exposed
|
||||
|
||||
- Verify CLIProxy auth for the resolved backend
|
||||
- Verify the local or remote CLIProxy target is reachable
|
||||
- Check `~/.claude.json` and inherited account configs for `ccs-image-analysis`
|
||||
|
||||
### I need to prove requests are going directly to the provider route
|
||||
|
||||
Run with `CCS_DEBUG=1` and inspect the resolved runtime path. The request target should be provider-scoped, for example:
|
||||
|
||||
```text
|
||||
/api/provider/agy/v1/messages
|
||||
```
|
||||
Contributor implementation contracts are enforced by the source and focused
|
||||
tests under `src/utils/image-analysis/`, `src/utils/hooks/`, and
|
||||
`src/web-server/routes/`.
|
||||
+102
-141
@@ -1,178 +1,139 @@
|
||||
# Logging Contract
|
||||
|
||||
Single source of truth for structured backend logging in CCS CLI. Companion to GitHub issues #1138 (umbrella) and #1141 (backend instrumentation).
|
||||
CCS structured logs are a machine-readable JSONL channel for backend and
|
||||
runtime events. They are separate from terminal UX output and must never be
|
||||
used as a substitute for user-facing recovery messages.
|
||||
|
||||
## Overview
|
||||
The canonical schema is
|
||||
[`src/services/logging/log-types.ts`](../src/services/logging/log-types.ts).
|
||||
Defaults are defined in
|
||||
[`src/config/schemas/logging.ts`](../src/config/schemas/logging.ts).
|
||||
|
||||
CCS emits structured JSONL log entries for backend behavior (proxy daemons, OAuth flows, target spawn lifecycle, executor errors, etc.). This document defines the canonical schema, request-correlation pattern, lifecycle stages, and redaction policy.
|
||||
## Entry Schema
|
||||
|
||||
> CLI text output (`ok / info / warn / fail` from `src/utils/ui.ts`) is **NOT** affected by this contract. Logs are a separate channel — never printed to stdout/stderr.
|
||||
| Field | Type | Required | Contract |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `string` | yes | Unique entry id. |
|
||||
| `timestamp` | `string` | yes | ISO 8601 emission time. |
|
||||
| `level` | `error`, `warn`, `info`, or `debug` | yes | Severity. |
|
||||
| `source` | `string` | yes | Stable module-scoped producer id. |
|
||||
| `event` | `string` | yes | Stable machine-readable event name. |
|
||||
| `message` | `string` | yes | Short human-readable summary. |
|
||||
| `processId` | `number` | yes | Emitting process id. |
|
||||
| `runId` | `string` | yes | Stable for the current process. |
|
||||
| `context` | object | no | Event fields after configured redaction. |
|
||||
| `requestId` | `string` | no | Cross-stage correlation id. |
|
||||
| `stage` | `LogStage` | no | Canonical lifecycle stage. |
|
||||
| `latencyMs` | `number` | no | Elapsed milliseconds, normally at completion. |
|
||||
| `error` | `LogErrorInfo` | no | Structured error metadata. |
|
||||
|
||||
## Schema (`LogEntry`)
|
||||
|
||||
Defined in `src/services/logging/log-types.ts`.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `id` | `string` | yes | UUID per entry. |
|
||||
| `timestamp` | `string` | yes | ISO 8601. |
|
||||
| `level` | `'error'\|'warn'\|'info'\|'debug'` | yes | |
|
||||
| `source` | `string` | yes | Module-scoped identifier (e.g. `proxy:openai-compat:messages`). |
|
||||
| `event` | `string` | yes | Dotted machine-readable event name (e.g. `request.received`). |
|
||||
| `message` | `string` | yes | Human-readable summary. |
|
||||
| `processId` | `number` | yes | `process.pid`. |
|
||||
| `runId` | `string` | yes | Stable per-process id. |
|
||||
| `context` | `object` | no | Free-form structured fields (redacted). |
|
||||
| `requestId` | `string` | no | Correlates entries belonging to one inbound request across stages. |
|
||||
| `stage` | `LogStage` | no | Lifecycle stage tag. |
|
||||
| `latencyMs` | `number` | no | Elapsed ms (typically on `respond` / `cleanup`). |
|
||||
| `error` | `{name, message, code?, stack?}` | no | Structured error metadata; never raw token strings. |
|
||||
|
||||
Old free-form entries (no `requestId` / `stage`) are still valid; new fields are additive.
|
||||
|
||||
### Example
|
||||
|
||||
```jsonl
|
||||
{"id":"...","timestamp":"2026-04-30T12:34:56.000Z","level":"info","source":"proxy:openai-compat:messages","event":"request.received","message":"Proxy /v1/messages request received","processId":42,"runId":"r1","requestId":"a1b2...","stage":"intake","context":{"method":"POST"}}
|
||||
```
|
||||
Additive optional fields preserve compatibility with older readers. Consumers
|
||||
must not assume every event has a stage, request id, latency, or structured
|
||||
error.
|
||||
|
||||
## Lifecycle Stages
|
||||
|
||||
`LogStage` is one of:
|
||||
Use `logger.stage()` for events that map to the canonical lifecycle:
|
||||
|
||||
| Stage | When to emit |
|
||||
|-------|--------------|
|
||||
| `intake` | Inbound request received at an entry edge (HTTP handler, CLI dispatch). |
|
||||
| `route` | Destination/profile/target resolution. |
|
||||
| `auth` | Authentication / authorization (token exchange, profile auth). |
|
||||
| `dispatch` | Outbound request prepared / child process spawned. |
|
||||
| `upstream` | Upstream call in flight (provider HTTP / spawned child running). |
|
||||
| `transform` | Payload translation (request/response shape conversion). |
|
||||
| `respond` | Response written / dispatched (`latencyMs` typically populated). |
|
||||
| `cleanup` | Error path, abort, teardown. |
|
||||
| Stage | Meaning |
|
||||
| --- | --- |
|
||||
| `intake` | Request or command entered a CCS boundary. |
|
||||
| `route` | Profile, provider, target, or destination resolution. |
|
||||
| `auth` | Authentication or authorization work. |
|
||||
| `dispatch` | Outbound request or child launch prepared. |
|
||||
| `upstream` | Provider request or child operation in flight. |
|
||||
| `transform` | Request or response translation. |
|
||||
| `respond` | Result dispatched to the caller. |
|
||||
| `cleanup` | Failure, abort, or teardown path. |
|
||||
|
||||
Stages may be skipped or repeated. Streaming responses tag `upstream` only at start/end (NOT per chunk).
|
||||
Stages can be skipped or repeated. Use `logger.info()`, `warn()`, or `error()`
|
||||
for events that do not represent a lifecycle stage. High-volume details,
|
||||
including streaming chunk metrics, belong at `debug`.
|
||||
|
||||
## RequestId Propagation (AsyncLocalStorage)
|
||||
|
||||
`requestId` is propagated implicitly via Node `AsyncLocalStorage`. Entry edges wrap their handler in `withRequestContext`; every `createLogger`-emitted entry inside the context auto-merges `requestId` from the active store.
|
||||
Example:
|
||||
|
||||
```ts
|
||||
import { withRequestContext, createLogger } from './services/logging';
|
||||
|
||||
const logger = createLogger('proxy:my-edge');
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const requestId = req.headers['x-ccs-request-id'] ?? randomUUID();
|
||||
res.setHeader('x-ccs-request-id', requestId);
|
||||
withRequestContext({ requestId }, async () => {
|
||||
logger.stage('intake', 'request.received', 'inbound');
|
||||
// ... downstream work emits with the same requestId
|
||||
});
|
||||
logger.stage('respond', 'request.completed', 'Request completed', {
|
||||
statusCode: 200,
|
||||
}, {
|
||||
latencyMs: 42,
|
||||
});
|
||||
```
|
||||
|
||||
### Cross-daemon header
|
||||
See [`src/services/logging/logger.ts`](../src/services/logging/logger.ts) for the
|
||||
compiler-checked signature.
|
||||
|
||||
`x-ccs-request-id` round-trips across the proxy edge:
|
||||
- Inbound: if the header is present and matches the UUID-ish guard (`/^[A-Za-z0-9._-]{8,128}$/`), it is reused; otherwise a fresh UUID is minted.
|
||||
- Outbound (response): the resolved id is echoed back via `res.setHeader('x-ccs-request-id', ...)`.
|
||||
- When CCS calls another daemon (copilot, cursor, glmt), forward the active id in the same header so that daemon can correlate.
|
||||
## Request Correlation
|
||||
|
||||
### Ordering guarantee
|
||||
[`src/services/logging/log-context.ts`](../src/services/logging/log-context.ts)
|
||||
uses Node async-local storage within a process. Entry edges establish a
|
||||
context; loggers created downstream read its `requestId` automatically.
|
||||
|
||||
Emit-time ordering of entries within a single `requestId` is monotonic — the active context is single-threaded relative to the request, so `timestamp` ordering reflects emit order. The UI layer (#1142) consumes this guarantee.
|
||||
Async-local context does not cross child processes or worker threads. A child
|
||||
process can inherit the active id through `CCS_REQUEST_ID` and establish a new
|
||||
local context. HTTP edges may use `x-ccs-request-id`; each edge owns whether it
|
||||
accepts an incoming id or mints a new one.
|
||||
|
||||
### What NOT to put in the context
|
||||
Only the characters and length accepted by `REQUEST_ID_PATTERN` are valid for
|
||||
forwarded ids. Do not treat request ids as authentication or authorization
|
||||
material.
|
||||
|
||||
The ALS context object is mixed into every downstream entry. Never store:
|
||||
- Raw tokens, API keys, refresh tokens, OAuth codes
|
||||
- Raw request/response bodies
|
||||
- User-supplied secrets
|
||||
There is no global ordering guarantee across concurrent async work or processes.
|
||||
Use `timestamp`, stage, event, and process/run identifiers together when
|
||||
reconstructing a request.
|
||||
|
||||
Only benign correlation metadata: `requestId`, `method`, `path`, `command`, `profile`.
|
||||
## Redaction and Data Minimization
|
||||
|
||||
### Worker threads / spawned children
|
||||
[`src/services/logging/log-redaction.ts`](../src/services/logging/log-redaction.ts)
|
||||
is the implementation source of truth. With the default `logging.redact: true`,
|
||||
the logger:
|
||||
|
||||
ALS context is **not** inherited by worker threads or `child_process.spawn` stdio pipes. At those boundaries, mint a fresh `requestId` at the child entry and pass the parent id explicitly via env var or header for correlation.
|
||||
- replaces values under known credential-bearing keys;
|
||||
- masks common authorization schemes and credential token shapes in strings;
|
||||
- redacts sensitive CLI flag values, including inline assignments;
|
||||
- limits string length and nested object depth;
|
||||
- strips an `Error` object to safe structured fields.
|
||||
|
||||
## Redaction
|
||||
The matcher evolves as credential surfaces change. Link to the implementation
|
||||
instead of copying its complete key or token-pattern list into other docs.
|
||||
|
||||
`src/services/logging/log-redaction.ts` is the single source of truth.
|
||||
Redaction is defense in depth, not permission to log sensitive data. Never log:
|
||||
|
||||
### Sensitive key matcher
|
||||
- tokens, passwords, cookies, OAuth codes, or authorization values;
|
||||
- raw request or response bodies;
|
||||
- raw prompts or prompt-bearing CLI arguments;
|
||||
- personal identifiers when a non-identifying account or profile label works.
|
||||
|
||||
`SENSITIVE_KEY_PATTERN` matches (case-insensitive, with `_` / `-` / camelCase variants):
|
||||
`authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `password`, `password_hash`, `secret`, `client_secret`, `token`, `auth_token`, `access_token`, `refresh_token`, `id_token`, `bearer`, `assertion`, `api_key`, `x-api-key`, `x-goog-api-key`, `management_key`, `copilot_token`, `cursor_session_key`, `oauth_code`, `auth_code`.
|
||||
When a new secret-bearing field or flag is introduced, update the redactor and
|
||||
its tests under
|
||||
[`tests/unit/services/logging/`](../tests/unit/services/logging/).
|
||||
|
||||
String/object values for matching keys are replaced with `[redacted]`. Numeric/boolean values pass through (e.g., `expires_at` epoch numbers stay readable).
|
||||
## Error Codes and Process Exit Codes
|
||||
|
||||
### Auth-scheme value masking
|
||||
These are separate contracts:
|
||||
|
||||
Raw string values whose prefix matches `^(Bearer|Basic|Token)\s+\S+` are rewritten to `<scheme> [redacted]` even when nested under non-sensitive keys.
|
||||
- `LogEntry.error.code` is an optional **string** supplied as part of structured
|
||||
error metadata. It can carry a runtime, system, or provider error identifier.
|
||||
- `CCSError.code` is a numeric `ExitCode`. The centralized CLI error handler
|
||||
writes it as `context.exitCode` and passes it to `process.exit`.
|
||||
|
||||
### Argv redaction
|
||||
Do not put a numeric process exit code in `LogEntry.error.code`, and do not make
|
||||
log consumers derive process status from that string field.
|
||||
|
||||
`redactArgv(argv)` redacts the value following any sensitive flag (`--token`, `--api-key`, `--auth`, `--bearer`, `--secret`, `--client-secret`, `--access-token`, `--refresh-token`, `--id-token`, `--password`).
|
||||
The numeric mapping lives in
|
||||
[`src/errors/exit-codes.ts`](../src/errors/exit-codes.ts). Typed error
|
||||
assignments live in
|
||||
[`src/errors/error-types.ts`](../src/errors/error-types.ts), and propagation is
|
||||
implemented by
|
||||
[`src/errors/error-handler.ts`](../src/errors/error-handler.ts).
|
||||
|
||||
### Adding new sensitive keys
|
||||
## Configuration
|
||||
|
||||
1. Extend `SENSITIVE_KEY_PATTERN` in `src/services/logging/log-redaction.ts`.
|
||||
2. Add a unit test in `tests/unit/services/logging/log-redaction-extended.test.ts`.
|
||||
3. Verify regex stays O(1) per key (no catastrophic backtracking).
|
||||
CCS-owned logging uses the `logging` section in the unified config. Its defaults
|
||||
enable logging and redaction at `info` level with bounded rotation, retention,
|
||||
and live-buffer settings. This is distinct from `cliproxy.logging`, which
|
||||
controls CLIProxy runtime logging.
|
||||
|
||||
## Contributor Guide
|
||||
|
||||
### When to use `logger.stage()` vs `logger.info()`
|
||||
|
||||
Use `stage()` whenever the entry corresponds to one of the canonical lifecycle stages — this is what observability tooling and the dashboard rely on. Use `info()` / `warn()` / `error()` for one-off events that don't fit a stage.
|
||||
|
||||
### What NOT to log
|
||||
|
||||
- Token values (use metadata: `expires_at`, `scopes`, account display name).
|
||||
- Request/response bodies (sample lengths only).
|
||||
- Authorization headers (log header *names* present, not values).
|
||||
|
||||
### Level guidance
|
||||
|
||||
| Level | Use for |
|
||||
|-------|---------|
|
||||
| `error` | Failures requiring action (cleanup stage). |
|
||||
| `warn` | Recoverable issues (auth rejected, route fallback). |
|
||||
| `info` | Lifecycle stage entries by default. |
|
||||
| `debug` | High-volume detail (per-chunk stream metrics, lock acquire/release). |
|
||||
|
||||
### Level config
|
||||
|
||||
Default level is `info`. Configure via `logging.level` in `~/.ccs/config.yaml`. Streaming providers MUST gate per-chunk metrics behind `debug`.
|
||||
|
||||
## `error.code` values (exit codes)
|
||||
|
||||
Typed errors (`src/errors/error-types.ts`) carry an `ExitCode` that `handleError` propagates to `process.exit`. Log readers can branch on `error.code` for differentiated handling. The full mapping lives in `src/errors/exit-codes.ts`; the per-class assignment:
|
||||
|
||||
| Typed class | ExitCode | Value |
|
||||
|---|---|---:|
|
||||
| `ConfigError` | `CONFIG_ERROR` | 2 |
|
||||
| `NetworkError` | `NETWORK_ERROR` | 3 (recoverable) |
|
||||
| `AuthError` | `AUTH_ERROR` | 4 |
|
||||
| `BinaryError` | `BINARY_ERROR` | 5 |
|
||||
| `ProviderError` | `PROVIDER_ERROR` | 6 (recoverable) |
|
||||
| `ProfileError` | `PROFILE_ERROR` | 7 |
|
||||
| `ProxyError` | `PROXY_ERROR` | 8 |
|
||||
| `MigrationError` | `MIGRATION_ERROR` | 9 |
|
||||
| `UserAbortError` | `USER_ABORT` | 130 |
|
||||
| `ValidationError`, `RetryableError` | `GENERAL_ERROR` | 1 |
|
||||
|
||||
New throws must use a typed class (enforced by `ccs/no-new-throw-error`, see `docs/code-standards.md`). Redaction scrubs credential token shapes in both context values and message strings, so routing errors into the logger is safe — but keep messages clean prose and put sensitive data in context under a sensitive key (auto-redacted).
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- All new `LogEntry` fields (`requestId`, `stage`, `latencyMs`, `error`) are optional. Old readers ignore them.
|
||||
- Existing `console.*` UX prints in `src/commands/`, `src/utils/ui.ts`, and similar user-facing paths are intentionally **not** converted to logger.
|
||||
- `/api/logs` reader unchanged in this PR; UI surfacing of new fields tracked under #1142.
|
||||
|
||||
## Future Work
|
||||
|
||||
- UI surfacing of `requestId` / `stage` / `latencyMs` in the dashboard (#1142).
|
||||
- `ccs logs` CLI improvements (filter by `requestId` / `stage`).
|
||||
- Per-stage performance budgets (see #1071).
|
||||
When adding a logging setting, update the schema/defaults, configuration loader,
|
||||
dashboard surface if applicable, and focused logging tests. Do not document a
|
||||
default that is not present in `DEFAULT_LOGGING_CONFIG`.
|
||||
@@ -1,378 +1,44 @@
|
||||
# OpenAI-Compatible Provider Routing
|
||||
# OpenAI-Compatible Proxy Developer Contract
|
||||
|
||||
CCS can route Claude Code traffic through a local Anthropic-compatible proxy when
|
||||
your API profile points at an OpenAI-compatible chat completions endpoint.
|
||||
The canonical
|
||||
[OpenAI-Compatible Provider Routing guide](https://docs.ccs.kaitran.ca/features/proxy/openai-compatible-providers)
|
||||
owns user setup and workflows. This local contract retains runtime and
|
||||
configuration invariants used by source, tests, and operators.
|
||||
|
||||
This is useful for providers such as:
|
||||
## Profile-Scoped Insecure TLS
|
||||
|
||||
- Hugging Face Inference Providers
|
||||
- Tuning Engines
|
||||
- OpenRouter
|
||||
- Ollama
|
||||
- llama.cpp servers
|
||||
- OpenAI-compatible self-hosted gateways
|
||||
`CCS_OPENAI_PROXY_INSECURE` is read from an OpenAI-compatible profile env.
|
||||
Truthy values are `1`, `true`, `yes`, and `on` (case-insensitive). When enabled,
|
||||
the local proxy disables upstream certificate verification for that profile,
|
||||
including request-time routing to another insecure profile.
|
||||
|
||||
## Related Project: claude-code-router
|
||||
This flag weakens TLS verification. Keep it explicit and profile-scoped; never
|
||||
make it a global default or infer it from a failed certificate check. The live
|
||||
resolution and dispatcher contracts are in
|
||||
[`profile-router.ts`](../src/proxy/profile-router.ts) and
|
||||
[`proxy-server.ts`](../src/proxy/server/proxy-server.ts).
|
||||
|
||||
[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.
|
||||
## Request Timeout
|
||||
|
||||
When to use CCR:
|
||||
`CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS` controls the upstream request timeout in
|
||||
milliseconds:
|
||||
|
||||
- you want a standalone router without CCS profile integration
|
||||
- you do not need CCS account/runtime management around the request flow
|
||||
- default: `600000` (10 minutes);
|
||||
- accepted: values whose `Number.parseInt` result is positive;
|
||||
- missing, invalid, zero, or negative values: fall back to the default.
|
||||
|
||||
When to use CCS:
|
||||
Upstream Undici header/body timeouts must stay above the request timeout so they
|
||||
do not terminate slow self-hosted inference first. The current implementation
|
||||
adds a 30-second grace ceiling. Source of truth:
|
||||
[`messages-route.ts`](../src/proxy/server/messages-route.ts).
|
||||
|
||||
- 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
|
||||
## Compatibility Boundary
|
||||
|
||||
## What CCS Does
|
||||
These variables configure the local Anthropic-to-OpenAI proxy. They do not
|
||||
convert a non-compatible profile into a compatible one, bypass local proxy
|
||||
authentication, or authorize remote binding. Keep profile detection, adaptive
|
||||
port selection, passthrough mode, request-time routing, and scenario routing
|
||||
documented in the public guide.
|
||||
|
||||
When you launch a compatible settings profile with the Claude target, CCS now:
|
||||
|
||||
1. Starts a local proxy on `127.0.0.1` using the resolved local port for that profile
|
||||
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
|
||||
```
|
||||
|
||||
For Tuning Engines:
|
||||
|
||||
```bash
|
||||
ccs api create --preset te
|
||||
```
|
||||
|
||||
Then you can use the profile directly:
|
||||
|
||||
```bash
|
||||
ccs hf
|
||||
# or
|
||||
ccs te
|
||||
```
|
||||
|
||||
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 start hf --port 3460
|
||||
ccs proxy activate hf
|
||||
ccs proxy activate --fish
|
||||
ccs proxy status hf
|
||||
ccs proxy stop hf
|
||||
```
|
||||
|
||||
Port selection precedence is:
|
||||
|
||||
1. CLI `--port` for an exact one-off pin
|
||||
2. `proxy.profile_ports[profile]` for an exact per-profile pin
|
||||
3. `proxy.port` for a shared preferred starting port
|
||||
4. adaptive per-profile fallback when nothing is pinned
|
||||
|
||||
Legacy shared `proxy.port: 3456` values are treated as unset so older configs
|
||||
move onto the adaptive path instead of staying on the hot legacy default. If
|
||||
you need an exact `3456` binding now, pin it via `--port` or `proxy.profile_ports`.
|
||||
|
||||
`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`
|
||||
|
||||
## Multiple Active Proxy Profiles
|
||||
|
||||
CCS now stores OpenAI-compatible proxy state per profile instead of treating the
|
||||
runtime as a singleton.
|
||||
|
||||
- Different compatible profiles can run at the same time on separate local ports
|
||||
- `ccs proxy activate` without a profile stays convenient when only one proxy is
|
||||
running
|
||||
- When multiple proxies are running, pass the profile explicitly to
|
||||
`activate`, `status`, or `stop`
|
||||
- `status` and `activate` always reflect the actual running port instead of an
|
||||
assumed default
|
||||
|
||||
If you want to pin or guide ports explicitly, configure them in `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
port: 45000
|
||||
profile_ports:
|
||||
hf: 3460
|
||||
openai: 3461
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
### Need to pin or verify the local port
|
||||
|
||||
- Check the active binding with `ccs proxy status hf`
|
||||
- Pin a one-off port with `ccs proxy start hf --port 3460`
|
||||
- Reserve a stable profile port with `proxy.profile_ports`
|
||||
- Re-run `ccs proxy activate hf` 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`
|
||||
|
||||
### Slow upstreams: `socket connection was closed unexpectedly`
|
||||
|
||||
- Long-running upstreams (self-hosted LLMs with queue/prefill phases) can stay
|
||||
silent for minutes before the first or next token. The proxy already allows up
|
||||
to 10 minutes per request; set `CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS` in the
|
||||
profile settings to raise or lower that ceiling.
|
||||
- Restart the proxy after changing the setting.
|
||||
|
||||
### 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
|
||||
```
|
||||
Focused behavior locks live in `tests/unit/proxy/` and
|
||||
`tests/integration/proxy/`.
|
||||
+194
-314
@@ -1,374 +1,254 @@
|
||||
# CCS Product Development Requirements (PDR)
|
||||
|
||||
Last Updated: 2026-05-07
|
||||
# CCS Product Development Requirements
|
||||
|
||||
## Product Overview
|
||||
|
||||
**Product Name**: CCS (Claude Code Switch)
|
||||
**Product name:** CCS (Claude Codex Switch)
|
||||
|
||||
**Tagline**: The multi-provider profile and runtime manager for Claude Code and compatible CLIs
|
||||
**Purpose:** Provide one profile and runtime-management surface for Claude Code,
|
||||
Codex CLI, Factory Droid, CLIProxy-backed OAuth providers, and compatible API
|
||||
profiles.
|
||||
|
||||
**Description**: Multi-provider CLI/runtime manager enabling seamless switching between multiple Claude accounts, OAuth/API providers, and alternate targets such as Claude Code, Factory Droid, and Codex CLI. Includes a React-based dashboard for configuration management, plus support for local and remote CLIProxyAPI instances, hybrid quota management, and official Claude channel runtime setup for Telegram, Discord, and iMessage.
|
||||
CCS includes:
|
||||
|
||||
**Current Version**: v7.34.x+ (First-class ImageAnalysis MCP tooling, WebSearch MCP, performance improvements)
|
||||
- a TypeScript CLI and local server;
|
||||
- a React dashboard for configuration, account, health, and usage workflows;
|
||||
- isolated account contexts and per-profile settings;
|
||||
- local or remote CLIProxy routing;
|
||||
- optional managed tools such as WebSearch and image analysis; and
|
||||
- an integrated Docker image containing CCS, CLIProxy, and the dashboard.
|
||||
|
||||
---
|
||||
Release versions and completed-release inventories belong in
|
||||
[`CHANGELOG.md`](../CHANGELOG.md), not this evergreen requirements document.
|
||||
|
||||
## Problem Statement
|
||||
## Problem
|
||||
|
||||
Developers using Claude Code face these challenges:
|
||||
Developers need to switch between accounts, providers, and compatible CLIs
|
||||
without repeatedly editing credentials or allowing one session's configuration
|
||||
to leak into another. They also need a visible, reversible way to manage local
|
||||
proxy state and diagnose provider readiness.
|
||||
|
||||
1. **Single Account Limitation**: Cannot run multiple Claude subscriptions simultaneously
|
||||
2. **Provider Lock-in**: Stuck with Anthropic's API, cannot use alternatives
|
||||
3. **No Concurrent Sessions**: Cannot work on different projects with different accounts
|
||||
4. **Complex Configuration**: Manual env var and config file management
|
||||
5. **No Usage Analytics**: Lack visibility into token usage and costs across providers
|
||||
## Product Principles
|
||||
|
||||
---
|
||||
1. **CLI first:** Core configuration and launch behavior remains scriptable.
|
||||
2. **Explicit state:** Users can inspect which profile, provider, target, and
|
||||
proxy mode CCS selected.
|
||||
3. **Isolation:** Account sessions use separate configuration roots where the
|
||||
target supports them.
|
||||
4. **Compatibility:** CCS adapts provider credentials to a target without
|
||||
redefining the provider or target protocol.
|
||||
5. **Reversibility:** Persistent writes are explicit and recoverable.
|
||||
6. **Local ownership:** Credentials and profile state remain on infrastructure
|
||||
selected by the user.
|
||||
|
||||
## Solution
|
||||
## Users
|
||||
|
||||
CCS provides:
|
||||
|
||||
1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR`
|
||||
2. **OAuth Providers**: Zero-config Gemini, Codex, xAI/Grok, Antigravity, Kiro, and other active OAuth integrations, with deprecated Copilot compatibility for existing setups
|
||||
3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families
|
||||
4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API
|
||||
5. **Visual Dashboard**: React SPA for configuration management
|
||||
6. **Automatic WebSearch**: First-class local WebSearch tool with deterministic provider chain for third-party providers
|
||||
7. **Automatic Image Analysis**: First-class local ImageAnalysis tool with direct provider routing for third-party profiles
|
||||
8. **Usage Analytics**: Token tracking, cost analysis, model breakdown
|
||||
9. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage
|
||||
10. **Routing Strategy Guidance**: First-class `round-robin` vs `fill-first` controls in CLI and dashboard, with explicit opt-in changes and no account-based guessing
|
||||
|
||||
---
|
||||
|
||||
## Target Users
|
||||
|
||||
| User Type | Use Case | Primary Features |
|
||||
|-----------|----------|------------------|
|
||||
| Individual Developer | Work/personal separation | Multi-account Claude |
|
||||
| Agency/Contractor | Client account isolation | Profile switching |
|
||||
| Cost-conscious Dev | GLM for bulk operations | API profiles, analytics |
|
||||
| Enterprise | Custom LLM integration | OpenAI-compatible endpoints |
|
||||
| Power User | Multiple providers | OpenRouter 300+ models |
|
||||
|
||||
---
|
||||
| User | Primary need |
|
||||
| --- | --- |
|
||||
| Individual developer | Separate accounts, projects, and provider profiles |
|
||||
| Consultant or agency | Isolate client contexts |
|
||||
| API consumer | Reuse Anthropic-compatible and OpenAI-compatible providers |
|
||||
| Power user | Manage OAuth accounts, routing, health, and quota state |
|
||||
| Team operator | Run a shared remote CLIProxy or integrated Docker service |
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR-001: Profile Switching
|
||||
- Switch between profiles with `ccs <profile>` command
|
||||
- Support default profile when no argument provided
|
||||
- Pass through all Claude CLI arguments
|
||||
### FR-001: Profile resolution and launch
|
||||
|
||||
### FR-002: Multi-Account Claude
|
||||
- Create isolated Claude instances
|
||||
- Maintain separate sessions, todolists, logs per account
|
||||
- Share commands, skills, agents across accounts
|
||||
- Launch the default profile with `ccs`.
|
||||
- Launch named settings, account, and CLIProxy profiles.
|
||||
- Pass target-specific arguments without losing CCS-owned routing constraints.
|
||||
- Resolve provider and target aliases through canonical registries.
|
||||
|
||||
### FR-003: OAuth Provider Integration
|
||||
- Support Gemini, Codex, xAI/Grok, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows
|
||||
- Browser-based authentication with provider-specific Authorization Code, Device Code, or polling flows
|
||||
- Token caching and refresh
|
||||
### FR-002: Account isolation
|
||||
|
||||
### FR-004: API Profile Management
|
||||
- Configure custom API endpoints
|
||||
- Support Anthropic-compatible APIs
|
||||
- Model mapping and configuration
|
||||
- OpenRouter integration with 300+ models
|
||||
- Maintain an account registry.
|
||||
- Use isolated `CLAUDE_CONFIG_DIR` roots for Claude account profiles.
|
||||
- Keep shared resources and instance-owned state distinguishable.
|
||||
- Prevent one account's provider overrides from leaking into another launch.
|
||||
|
||||
### FR-004A: CLIProxy AI Provider Management
|
||||
- Configure CLIProxy-managed Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key entries
|
||||
- Keep provider authoring separate from CCS API Profile creation
|
||||
- Support local config editing and remote CLIProxy management parity where available
|
||||
### FR-003: Provider integration
|
||||
|
||||
### FR-005: Dashboard UI
|
||||
- Visual profile management
|
||||
- Real-time health monitoring
|
||||
- Usage analytics with cost tracking
|
||||
- Modular page architecture (settings, analytics, auth-monitor)
|
||||
- Support API-key profiles and OAuth-backed CLIProxy providers.
|
||||
- Support local and remote CLIProxy operation.
|
||||
- Keep original and Plus CLIProxy backends explicit; do not silently substitute
|
||||
one when a requested provider requires the other.
|
||||
- Treat provider capability metadata as code-owned, not prose-owned. See
|
||||
[`src/cliproxy/provider-capabilities.ts`](../src/cliproxy/provider-capabilities.ts)
|
||||
and
|
||||
[`src/cliproxy/types/provider-types.ts`](../src/cliproxy/types/provider-types.ts).
|
||||
|
||||
### FR-006: Health Diagnostics
|
||||
- Verify Claude CLI installation
|
||||
- Check config file integrity
|
||||
- Validate symlinks and permissions
|
||||
### FR-004: Target adapters
|
||||
|
||||
### FR-007: WebSearch Fallback
|
||||
- Expose a CCS-managed local WebSearch tool for third-party profiles that cannot reach Anthropic's native tool
|
||||
- Suppress native `WebSearch` on third-party launches and steer Claude toward the CCS-owned path when it is available
|
||||
- Support Exa, Tavily, Brave, and DuckDuckGo real search backends
|
||||
- Keep Gemini CLI, OpenCode, and Grok as optional legacy fallback
|
||||
- Graceful fallback chain
|
||||
- Support Claude Code, Factory Droid, and Codex CLI through target adapters.
|
||||
- Deliver credentials in the form owned by each target:
|
||||
environment variables for Claude launches, managed custom-model state for
|
||||
Droid, and transient configuration overrides for CCS-routed Codex launches.
|
||||
- Preserve user-owned target configuration outside the explicitly managed
|
||||
fields.
|
||||
|
||||
### FR-007A: First-Class Image Analysis
|
||||
- Expose a CCS-managed local `ImageAnalysis` MCP tool for third-party profiles that need provider-backed vision
|
||||
- Resolve the provider route before launch and send requests directly to `/api/provider/<backend>/v1/messages`
|
||||
- Use editable prompt templates for `default`, `screenshot`, and `document` analysis modes
|
||||
- Suppress the old CCS-managed `Read` hook during healthy MCP launches so it cannot compete with the primary path
|
||||
- Keep the old `Read` hook as compatibility fallback only when MCP provisioning fails but provider-backed analysis is still viable
|
||||
- Auto-heal stale CCS-managed image hooks and missing isolated MCP sync through launch-time cleanup, dashboard provisioning, and `ccs doctor --fix`
|
||||
- Fall back to native `Read` without failing the whole launch when managed runtime, auth, or proxy readiness is unavailable
|
||||
### FR-005: Configuration management
|
||||
|
||||
### FR-008: Remote CLIProxy Support
|
||||
- Connect to remote CLIProxyAPI instances
|
||||
- CLI flags for proxy configuration (--proxy-host, --proxy-port, etc.)
|
||||
- Environment variable configuration (CCS_PROXY_HOST, etc.)
|
||||
- Fallback to local proxy when remote unreachable
|
||||
- Protocol-based default ports (443 for HTTPS, 8317 for HTTP)
|
||||
- Dashboard UI for remote server configuration and testing
|
||||
- Store CCS configuration under the directory resolved by
|
||||
[`getCcsDir()`](../src/utils/config-manager.ts).
|
||||
- Store API profile launch settings in `<profile>.settings.json` files under
|
||||
that directory.
|
||||
- Require all environment values written to settings files to be strings.
|
||||
- Keep shared Claude settings unchanged during normal profile launches.
|
||||
- Allow the explicit `ccs persist` workflow to merge a profile into
|
||||
`~/.claude/settings.json`; back up the existing file and write atomically.
|
||||
- Reject unsafe settings-file targets such as symlinks.
|
||||
|
||||
### FR-009: Quota Management (v7.14)
|
||||
- Pause/resume individual accounts via `ccs cliproxy pause/resume <account>`
|
||||
- Check quota status via `ccs cliproxy status [account]`
|
||||
- Inspect the current proxy-wide routing strategy via `ccs cliproxy routing`
|
||||
- Explicitly switch `round-robin` vs `fill-first` from CLI or dashboard
|
||||
- Keep `round-robin` as the default until the user explicitly changes it
|
||||
- Never infer routing strategy from account count, tier mix, or paused/default account state
|
||||
- Auto-failover when account exhausted
|
||||
- Tier detection: free/pro/ultra/unknown
|
||||
- Distinguish entitlement failures from temporary capacity exhaustion
|
||||
- Pre-flight quota checks before session start
|
||||
- Dashboard UI with pause/resume toggles, tier badges, and quota-detail guidance
|
||||
### FR-006: Dashboard and diagnostics
|
||||
|
||||
### FR-010: Docker Deployment
|
||||
- Multi-stage Dockerfile with bun 1.2.21 and node:20-bookworm-slim
|
||||
- Docker Compose setup with resource limits and healthcheck
|
||||
- Persistent volumes for config, credentials, and CLI tools
|
||||
- Pre-installed CLIs: claude, gemini, grok, opencode, ccs
|
||||
- Ports: 3000 (Dashboard), 8317 (CLIProxy)
|
||||
- Entrypoint with privilege dropping and usage help
|
||||
- Environment variable configuration support
|
||||
- Provide local APIs and a React UI for supported configuration workflows.
|
||||
- Surface health, authentication, provider, routing, and usage state without
|
||||
exposing credentials.
|
||||
- Keep dashboard changes aligned with the same configuration contracts used by
|
||||
the CLI.
|
||||
|
||||
### FR-011: Third-Party Tool Integration
|
||||
- Export shell-evaluable env vars via `ccs env` command
|
||||
- Support OpenAI, Anthropic, raw output formats
|
||||
- Auto-detect shell (bash/zsh, fish, PowerShell) from $SHELL
|
||||
- Security: single-quoted output, key sanitization, shell-specific escaping
|
||||
- Cross-platform compatibility (macOS, Linux, Windows)
|
||||
### FR-007: Managed tools
|
||||
|
||||
### FR-012: Official Claude Channels
|
||||
- Support Telegram, Discord, and iMessage selection via `ccs config channels` and the dashboard
|
||||
- Auto-inject `--channels` only for native Claude `default` and `account` sessions
|
||||
- Store Telegram/Discord bot tokens in Claude's own `~/.claude/channels/<channel>/.env` state or the official `*_STATE_DIR` override path when one is configured
|
||||
- Treat iMessage as macOS-only, tokenless, and dependent on Claude-side install plus OS permissions
|
||||
- Require Bun, Claude Code v2.1.80+, and verified `claude.ai` auth before runtime auto-enable
|
||||
- Keep `--dangerously-skip-permissions` optional and never add it when the user already made an explicit permission choice
|
||||
- Surface platform/auth/version/setup blockers clearly in both CLI and dashboard flows
|
||||
- Preserve dashboard token drafts when save/refresh fails, and let already-selected unsupported iMessage entries be turned off without allowing re-enable on unsupported platforms
|
||||
- Provide WebSearch and image-analysis integration for profiles that need
|
||||
CCS-managed alternatives.
|
||||
- Prefer explicit provider routes.
|
||||
- Fail closed when enabled third-party WebSearch cannot prepare its constrained
|
||||
MCP replacement.
|
||||
- Allow image analysis to use compatible native behavior when its managed route
|
||||
is unavailable.
|
||||
|
||||
---
|
||||
### FR-008: Remote proxy
|
||||
|
||||
- Resolve remote proxy settings from supported CLI flags, environment
|
||||
variables, and CCS configuration.
|
||||
- Verify reachability before use.
|
||||
- Fall back to a local proxy only when fallback is enabled.
|
||||
- Fail instead of falling back when remote-only mode is selected.
|
||||
|
||||
### FR-009: Quota and account-pool management
|
||||
|
||||
- Display supported quota and account health data.
|
||||
- Let users pause and resume accounts.
|
||||
- Keep routing-strategy changes explicit.
|
||||
- Temporarily remove exhausted accounts from rotation only under the
|
||||
CCS-managed cooldown contract and restore only CCS-created pauses.
|
||||
|
||||
### FR-010: Docker deployment
|
||||
|
||||
- Publish an integrated multi-architecture image containing CCS, CLIProxy, and
|
||||
the dashboard.
|
||||
- Persist CCS state and service logs in declared volumes.
|
||||
- Expose dashboard and CLIProxy service ports.
|
||||
- Health-check both services.
|
||||
- Do not bundle target AI CLIs into the integrated image; consumers that need
|
||||
them run sibling containers or install them separately.
|
||||
|
||||
### FR-011: Shell and editor integration
|
||||
|
||||
- Export shell-safe environment values through `ccs env`.
|
||||
- Support the documented shell output formats.
|
||||
- Keep persistent shared settings behind `ccs persist`.
|
||||
- Keep editor-specific writes scoped to the selected editor and user layer.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
### NFR-001: Performance
|
||||
- CLI startup < 100ms
|
||||
- Dashboard load < 2s
|
||||
- Minimal memory footprint
|
||||
### NFR-001: Security
|
||||
|
||||
- Do not print or log credentials.
|
||||
- Bind local proxy services to loopback unless the user explicitly selects a
|
||||
deployment that exposes them.
|
||||
- Validate file types and use safe replacement semantics for managed sensitive
|
||||
configuration.
|
||||
- Keep remote transport and authentication choices explicit.
|
||||
|
||||
### NFR-002: Reliability
|
||||
- Idempotent operations
|
||||
- Graceful error handling
|
||||
- Automatic recovery where possible
|
||||
|
||||
### NFR-003: Security
|
||||
- Local-only proxy binding (127.0.0.1)
|
||||
- No credential exposure in logs
|
||||
- Secure token storage
|
||||
- Make setup and repair operations idempotent where practical.
|
||||
- Preserve existing user state when merging managed configuration.
|
||||
- Report recovery actions in actionable error messages.
|
||||
- Clean up child processes and temporary runtime state on exit.
|
||||
|
||||
### NFR-004: Cross-Platform
|
||||
- Support Linux, macOS, Windows
|
||||
- Bash 3.2+, PowerShell 5.1+, Node.js 14+
|
||||
- Identical behavior across platforms
|
||||
### NFR-003: Portability
|
||||
|
||||
### NFR-005: Maintainability
|
||||
- Files < 200 lines (with documented exceptions)
|
||||
- Domain-based organization
|
||||
- Barrel exports for clean imports
|
||||
- 90%+ test coverage
|
||||
- Support macOS, Linux, and Windows for the host CLI where target dependencies
|
||||
allow.
|
||||
- Support Node.js 18 or newer, as declared by
|
||||
[`package.json`](../package.json).
|
||||
- Support Bun 1.0 or newer for development and supported runtime workflows.
|
||||
- Keep terminal output ASCII-only and respect `NO_COLOR` and TTY detection.
|
||||
|
||||
---
|
||||
### NFR-004: Maintainability
|
||||
|
||||
## Technical Requirements
|
||||
- Keep provider metadata centralized.
|
||||
- Use target adapters instead of target checks scattered through dispatch code.
|
||||
- Validate CLI, server, and dashboard contracts with focused tests.
|
||||
- Keep generated or volatile inventories out of evergreen architecture prose.
|
||||
|
||||
### TR-001: Runtime Dependencies
|
||||
- Node.js 14+ or Bun 1.0+
|
||||
- Claude Code CLI installed
|
||||
- Internet access for OAuth/API calls
|
||||
## Runtime and Deployment Requirements
|
||||
|
||||
### TR-002: Optional Dependencies
|
||||
- CLIProxyAPI binary (auto-managed)
|
||||
- Exa/Tavily/Brave API keys for higher-quality WebSearch
|
||||
- Gemini CLI for legacy WebSearch fallback
|
||||
- Bun plus Claude Code v2.1.80+ with `claude.ai` auth for Official Channels auto-enable
|
||||
|
||||
### TR-003: Configuration
|
||||
- YAML-based config (`~/.ccs/config.yaml`)
|
||||
- JSON settings per profile
|
||||
- Environment variable overrides
|
||||
- Official channel bot tokens stored in Claude-managed `~/.claude/channels/<channel>/.env`
|
||||
|
||||
---
|
||||
| Surface | Requirement |
|
||||
| --- | --- |
|
||||
| Host npm install | Node.js 18+ |
|
||||
| Development and repository gates | Bun 1.0+ and Node.js 18+ |
|
||||
| Claude launches | Claude Code installed and authenticated as required by the selected profile |
|
||||
| Droid launches | Factory Droid installed |
|
||||
| Codex launches | Codex CLI installed |
|
||||
| Local OAuth proxy | CCS-managed CLIProxy binary |
|
||||
| Integrated Docker | Docker or compatible container runtime; target CLIs are not bundled |
|
||||
|
||||
## Architecture Constraints
|
||||
|
||||
### AC-001: CLI-First Design
|
||||
- All features accessible via CLI
|
||||
- Dashboard is convenience layer, not required
|
||||
- Scriptable and automatable
|
||||
### AC-001: Profile state and shared settings are separate
|
||||
|
||||
### AC-002: Non-Invasive
|
||||
- Never modify `~/.claude/settings.json`
|
||||
- Use environment variables for configuration
|
||||
- Reversible changes only
|
||||
Normal launches consume CCS-owned profile files and environment state.
|
||||
`~/.claude/settings.json` is written only through an explicit persistence or
|
||||
approved settings-management workflow.
|
||||
|
||||
### AC-003: Proxy Pattern
|
||||
- Use local proxy for provider routing
|
||||
- Claude CLI communicates with localhost
|
||||
- Proxy handles upstream API calls
|
||||
### AC-002: Provider and target are independent axes
|
||||
|
||||
---
|
||||
A provider supplies credentials and routing. A target adapter determines how a
|
||||
compatible CLI receives them. Unsupported combinations must fail clearly.
|
||||
|
||||
## Success Metrics
|
||||
### AC-003: Proxy trust boundary is visible
|
||||
|
||||
| Metric | Target | Current |
|
||||
|--------|--------|---------|
|
||||
| Startup time | < 100ms | Achieved |
|
||||
| Dashboard load | < 2s | Achieved |
|
||||
| Error rate | < 1% | Achieved |
|
||||
| Test coverage | > 90% | 90% (1440 tests, 6 skipped) |
|
||||
| File size compliance | 100% < 200 lines | 95% |
|
||||
Local CLIProxy, remote CLIProxy, and direct API profiles have different
|
||||
transport and credential boundaries. CCS must not present them as equivalent or
|
||||
silently cross those boundaries.
|
||||
|
||||
---
|
||||
### AC-004: Source owns volatile capability data
|
||||
|
||||
## Release Criteria
|
||||
Provider IDs, aliases, OAuth flow types, callback ports, refresh ownership,
|
||||
backend restrictions, and quota support must be read from the provider
|
||||
registries and tests. Documentation describes how to find them rather than
|
||||
copying a second mutable table.
|
||||
|
||||
### v1.0 Release (Complete)
|
||||
- [x] Multi-account Claude support
|
||||
- [x] OAuth provider integration (Gemini, Codex, AGY)
|
||||
- [x] API profile management
|
||||
- [x] Dashboard UI
|
||||
- [x] Health diagnostics
|
||||
- [x] WebSearch fallback
|
||||
- [x] Cross-platform support
|
||||
## Acceptance Criteria
|
||||
|
||||
### v7.0 Release (Complete)
|
||||
- [x] OpenRouter integration with 300+ models
|
||||
- [x] Interactive model picker
|
||||
- [x] Dynamic model discovery
|
||||
- [x] Tier mapping (opus/sonnet/haiku)
|
||||
- [x] Settings page modularization (20 files)
|
||||
- [x] Analytics page modularization (8 files)
|
||||
- [x] Auth monitor modularization (8 files)
|
||||
- [x] Comprehensive test infrastructure (539 CLI + 99 UI tests)
|
||||
|
||||
### v7.1 Release (Complete)
|
||||
- [x] Remote CLIProxy routing support
|
||||
- [x] CLI flags for remote proxy (--proxy-host, --proxy-port, etc.)
|
||||
- [x] Environment variables for proxy config (CCS_PROXY_*)
|
||||
- [x] Dashboard remote proxy configuration UI
|
||||
- [x] Connection testing with latency display
|
||||
- [x] Fallback to local when remote unreachable
|
||||
- [x] Protocol-based default ports (HTTPS:443, HTTP:8317)
|
||||
|
||||
### v7.2 Release (Complete)
|
||||
- [x] Kiro (AWS) OAuth provider support via CLIProxyAPIPlus
|
||||
- [x] GitHub Copilot (ghcp) OAuth provider via Device Code flow (deprecated compatibility)
|
||||
- [x] Authorization Code flow for Kiro (port 9876)
|
||||
- [x] Device Code flow for ghcp (no local port needed)
|
||||
|
||||
### v7.14 Release (Complete)
|
||||
- [x] Hybrid quota management with auto-failover
|
||||
- [x] `ccs cliproxy pause/resume/status` commands
|
||||
- [x] API tier detection (free/pro/ultra/unknown)
|
||||
- [x] Dashboard pause/resume toggles and tier badges
|
||||
- [x] Pre-flight quota checks before session start
|
||||
|
||||
### v7.23 Release (Complete)
|
||||
- [x] Docker deployment support (PR #345)
|
||||
- [x] Multi-stage Dockerfile with bun 1.2.21
|
||||
- [x] Docker Compose with resource limits and healthcheck
|
||||
- [x] Persistent volumes for config and credentials
|
||||
- [x] Pre-installed AI CLI tools (claude, gemini, grok, opencode)
|
||||
- [x] Entrypoint with privilege dropping
|
||||
|
||||
### v7.34 Release (Complete)
|
||||
- [x] First-class `ImageAnalysis` MCP tool for third-party launches
|
||||
- [x] Direct provider-scoped routing for image analysis requests
|
||||
- [x] Prompt template selection for default / screenshot / document flows
|
||||
- [x] Hook fallback retained only for compatibility
|
||||
- [x] Non-fatal native `Read` fallback when managed runtime is unavailable
|
||||
- [x] `ccs config image-analysis` CLI command
|
||||
- [x] Doctor integration for hook validation
|
||||
- [x] 791-line E2E test suite for image analysis
|
||||
- [x] Performance: Replace busy-wait with Atomics.wait in config lock
|
||||
- [x] Network error handling with noRetryPatterns
|
||||
- [x] Quota 429 rate limit handling improvements
|
||||
- [x] WebSocket maxPayload limit (DoS prevention)
|
||||
|
||||
### v7.39 Release (Complete)
|
||||
- [x] `ccs env` command for third-party tool integration (OpenCode, Cursor, Continue)
|
||||
- [x] Multi-format output: openai, anthropic, raw
|
||||
- [x] Multi-shell support: bash/zsh, fish, PowerShell (auto-detected)
|
||||
- [x] CLIProxy profile support (gemini, codex, agy, qwen)
|
||||
- [x] Settings profile support (glm, kimi, custom API)
|
||||
- [x] Security: single-quoted output, key sanitization, shell-specific escaping
|
||||
- [x] Shell completion updated (bash, zsh, fish, PowerShell)
|
||||
- [x] 34 unit tests for env command
|
||||
|
||||
### v8.0 Release (Planned - Q1 2026)
|
||||
- [ ] Multiple CLIProxyAPI instances (load balancing, failover)
|
||||
- [ ] Native git worktree support
|
||||
- [ ] Critical bug fixes (#158, #155, #124)
|
||||
|
||||
### v9.0 Release (Future - Q2 2026)
|
||||
- [ ] Team collaboration features
|
||||
- [ ] Cloud sync for profiles
|
||||
- [ ] Plugin system
|
||||
- [ ] CLI extension framework
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### External Services
|
||||
- Anthropic Claude API
|
||||
- Google Gemini API
|
||||
- GitHub Codex API
|
||||
- GitHub Copilot (ghcp - deprecated Device Code OAuth compatibility)
|
||||
- AWS Kiro (Authorization Code OAuth)
|
||||
- Z.AI GLM API
|
||||
- OpenRouter API
|
||||
- Moonshot Kimi API
|
||||
- DeepSeek API
|
||||
- Alibaba Qwen API
|
||||
- Minimax API
|
||||
- Azure Foundry API
|
||||
|
||||
### Third-Party Libraries
|
||||
- Express.js (web server)
|
||||
- React (dashboard)
|
||||
- Vite (build tool)
|
||||
- shadcn/ui (UI components)
|
||||
- CLIProxyAPI (proxy binary)
|
||||
- Vitest (testing)
|
||||
|
||||
---
|
||||
- A user can create or select a supported profile and launch it on a compatible
|
||||
target without manual credential-file editing.
|
||||
- Concurrent account profiles do not share target session state accidentally.
|
||||
- Local and remote proxy failures follow the configured fallback policy.
|
||||
- Persistent settings writes preserve unrelated keys and create a recovery
|
||||
path.
|
||||
- Dashboard operations produce configuration compatible with CLI operations.
|
||||
- Release automation publishes only from the documented branches and lanes.
|
||||
- Documentation links resolve and architecture claims are traceable to source.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|-------------|--------|------------|
|
||||
| Claude CLI API changes | Medium | High | Version pinning, compatibility layer |
|
||||
| Provider API deprecation | Low | High | Fallback chain, multiple providers |
|
||||
| OAuth token expiry | Medium | Medium | Auto-refresh, clear error messages |
|
||||
| Binary compatibility | Low | Medium | Multi-platform builds, fallback |
|
||||
|
||||
---
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| Provider auth contracts change | Central capability registry plus provider-specific tests |
|
||||
| Target CLI configuration changes | Adapter boundary and compatibility checks |
|
||||
| Credential leakage | Local storage, redaction, safe file handling, no secret logging |
|
||||
| Remote proxy outage | Reachability checks and explicit fallback policy |
|
||||
| Configuration corruption | Validation, backup, locking, and atomic replacement where supported |
|
||||
| Documentation drift | Link volatile details to code; keep this document version-neutral |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Codebase Summary](./codebase-summary.md) - Technical structure
|
||||
- [Code Standards](./code-standards.md) - Development conventions
|
||||
- [System Architecture](./system-architecture/index.md) - Architecture diagrams
|
||||
- [Project Roadmap](./project-roadmap.md) - Development phases and GitHub issues
|
||||
- [Codebase Summary](./codebase-summary.md)
|
||||
- [Code Standards](./code-standards.md)
|
||||
- [System Architecture](./system-architecture/index.md)
|
||||
- [Provider Flows](./system-architecture/provider-flows.md)
|
||||
- [Release Process](./release-process.md)
|
||||
- [Project Roadmap](./project-roadmap.md)
|
||||
+35
-268
@@ -1,284 +1,51 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-05-22
|
||||
This repository does not maintain a hand-copied backlog snapshot. Current work
|
||||
lives in GitHub so status, ownership, and dependencies stay live.
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
## Find Current Work
|
||||
|
||||
---
|
||||
- [Open issues](https://github.com/kaitranntt/ccs/issues)
|
||||
- [Project boards](https://github.com/users/kaitranntt/projects)
|
||||
- [Open pull requests](https://github.com/kaitranntt/ccs/pulls)
|
||||
- [Releases](https://github.com/kaitranntt/ccs/releases)
|
||||
|
||||
## Completed Modularization Summary
|
||||
Filter issues by area and type labels before selecting work. Verify the issue
|
||||
body, recent comments, and linked pull requests rather than relying on an old
|
||||
roadmap copy.
|
||||
|
||||
All major modularization work is complete. The codebase evolved from monolithic files to a well-structured modular architecture.
|
||||
## Planning Guidance
|
||||
|
||||
| Phase | Description | Key Result |
|
||||
|-------|-------------|------------|
|
||||
| 1 | Type System | `src/types/` with barrel exports |
|
||||
| 2 | CLI Commands | `src/commands/` (8 handlers extracted) |
|
||||
| 3 | CLIProxy | `src/cliproxy/` with auth/, binary/, services/ subdirs |
|
||||
| 4 | Utils/Errors | `src/utils/ui/`, `src/errors/`, `src/management/` |
|
||||
| 5 | UI Components | 5 monster files split into modular dirs (54+ modules) |
|
||||
| 6 | Settings Page | `pages/settings/` (1,781->20 files) |
|
||||
| 7 | Analytics Page | `pages/analytics/` (420->8 files) |
|
||||
| 8 | Auth Monitor | `monitoring/auth-monitor/` (465->8 files) |
|
||||
| 9 | Test Infrastructure | 1407 tests, 90% coverage |
|
||||
| 10 | Remote CLIProxy | `proxy-config-resolver.ts`, `remote-proxy-client.ts` |
|
||||
| 11 | Kiro + legacy ghcp Providers | OAuth support via CLIProxyAPIPlus (v7.2) |
|
||||
| 12 | Hybrid Quota Management | `quota-manager.ts`, `quota-fetcher.ts` (v7.14) |
|
||||
| 13 | Docker Support | `docker/` directory with Dockerfile, Compose, entrypoint |
|
||||
| 14 | Image Analysis Hook | Vision proxying via CLIProxy transformers (v7.34) |
|
||||
| 15 | Third-Party Tool Integration | `ccs env` command with multi-format export (v7.39) |
|
||||
1. Confirm the issue still reproduces against `origin/dev`.
|
||||
2. Read `CLAUDE.md` and the affected source, tests, help, and public docs.
|
||||
3. Keep the implementation and compatibility boundary explicit.
|
||||
4. Run focused validation, then the repository quality gate appropriate to the
|
||||
change.
|
||||
5. State public-doc impact before handoff.
|
||||
|
||||
**Metrics Achieved**:
|
||||
- Files >500 lines: 12 -> 5 (-58%)
|
||||
- UI files >200 lines: 28 -> 8 (-71%)
|
||||
- Barrel exports: 5 -> 39 (+680%)
|
||||
- Test coverage: 0% -> 90%
|
||||
- Total tests: 1440 (6 skipped)
|
||||
Plans are workspace-only and belong under ignored `plans/`; they are not product
|
||||
roadmap truth.
|
||||
|
||||
---
|
||||
## Maintainability Work
|
||||
|
||||
## Current Status
|
||||
Use [Hardening Debt Burndown](./hardening-debt-burndown.md) for measurement
|
||||
methodology. Its generated inventory is an exact source-tree snapshot, not a
|
||||
historical progress log.
|
||||
|
||||
### Recent Fixes
|
||||
## Stable Product Direction
|
||||
|
||||
- **2026-05-22**: **#1337** CLIProxy quota preflight now reconciles the entire
|
||||
active provider account pool before local proxy startup. Known-exhausted
|
||||
non-default accounts move out of live `auth/` rotation when another healthy
|
||||
account exists, preventing round-robin requests from stalling on already
|
||||
exhausted accounts while preserving manual pause/resume and cooldown
|
||||
auto-resume behavior.
|
||||
- **2026-05-22**: **#1332** `ccsx auth` profiles now link native Codex
|
||||
`agents/` and `skills/` resources alongside `config.toml`, and `ccsx <profile>`
|
||||
repairs missing resource links at launch so relative agent role `config_file`
|
||||
entries do not warn on existing profiles.
|
||||
- **2026-05-20**: **#1285** Codex model picker aliases now include `minimal` and `low` reasoning effort suffixes alongside `medium`, `high`, and `xhigh`, and the dashboard separates base recommendations, reasoning variants, and fast variants so low-effort selections such as `gpt-5.5-low` are visible.
|
||||
- **2026-05-19**: **#1252** Claude extension persistence now rejects Codex CLIProxy profiles instead of writing `ANTHROPIC_BASE_URL=/api/provider/codex` into Claude Code settings. Native Codex subscription use stays on `ccsxp` or `ccs codex --target codex`, and `ccs doctor` warns when stale Claude settings still point at the Codex translator.
|
||||
- **2026-05-18**: **#1287** `ccsx resume` now passes through to the native Codex `resume` subcommand before CCS profile detection, preserving Codex session continuation while keeping `ccsx auth` on the managed Codex profile namespace.
|
||||
- **2026-05-09**: **#1199** Existing Claude auth accounts now have dashboard-visible Shared Resources controls separate from History Sync. Accounts exposes a dedicated Resources action for `shared` vs `profile-local`, `/shared` now inventories commands, skills, agents, plugins, and `settings.json`, plugin directories without docs show factual directory contents, and shared settings content is inspectable read-only through the localhost-gated shared-content API.
|
||||
- **2026-05-07**: **#760** Codex GPT fast mode is now a first-class CLIProxy model tuning suffix. CCS accepts `gpt-5.4-fast`, `gpt-5.4-high-fast`, and equivalent canonicalized forms in raw env configs, CLI variant creation, and the dashboard model picker; runtime requests now send the base upstream model with `reasoning.effort` plus `service_tier: "priority"` instead of leaking the suffixed alias to CLIProxy upstream routing.
|
||||
- **2026-05-07**: **#1103** GitHub Copilot is now treated as a deprecated compatibility bridge. The dashboard moves Copilot out of the active Identity & Access section and into Deprecated, quick setup no longer offers `ghcp` for new onboarding, CLI/help/config copy marks Copilot as deprecated, and existing `ccs copilot` / `ghcp` compatibility paths remain available for current setups.
|
||||
- **2026-05-07**: **#1189** Headless settings-profile delegation now preserves native Claude passthrough args without a Claude flag allowlist. Explicit `--channels` values reach Claude Code, future native flags can carry multiple adjacent values, malformed CCS-owned flags no longer swallow the next native flag, and `--prompt=<text>` routes through headless delegation consistently with `--prompt <text>`.
|
||||
- **2026-05-03**: **#1172** Local CLIProxy config generation now keeps the CPAMC management dashboard aligned with backend selection. `backend: original` points to the upstream dashboard, `backend: plus` points to the CCS-maintained CPAMC fork, `cliproxy.management_panel_repository` lets advanced users override the panel repository, and stale generated configs are regenerated when the expected panel source changes.
|
||||
- **2026-04-30**: **#1153** Native Claude launches now accept session-scoped `--effort low|medium|high|xhigh|max` overrides through CCS without mutating global Claude settings. CCS validates invalid or missing effort values before spawning Claude, normalizes accepted values, keeps default headless `-p/--prompt` launches on native Claude instead of delegation parsing, and preserves CLIProxy/Codex/Droid effort aliases.
|
||||
- **2026-04-28**: **#1123** CLIProxy quota failover now uses the dashboard/manual pause mechanism for all quota-visible OAuth providers with CCS quota fetchers: Antigravity, Claude, Codex, Gemini CLI, and GitHub Copilot. When a healthy fallback exists, CCS moves the exhausted account token out of the live `auth/` folder into `auth-paused/`, marks the account paused for dashboard visibility, persists the cooldown for auto-resume, and still avoids self-pausing the last usable account.
|
||||
- **2026-04-28**: **#1115** CCS now exposes upstream CLIProxy session affinity as a first-class local managed setting. Users can inspect and toggle local `session-affinity` plus TTL from `ccs cliproxy routing affinity`, from the `/cliproxy` dashboard routing card, and through the local dashboard API. The generated local CLIProxy config now persists `routing.session-affinity` and `routing.session-affinity-ttl`, help/copy explains that CLIProxy prefers explicit session or thread identifiers before falling back to prompt-history hashing, and remote session-affinity management stays explicitly unsupported until upstream management APIs expose more than `routing.strategy`.
|
||||
- **2026-04-24**: **#1065** Local CLIProxy Plus is available again as an explicit opt-in backend through the community-maintained `kaitranntt/CLIProxyAPIPlus` fork. CCS keeps `original` as the default backend, no longer downgrades saved `backend: plus` configs to `original`, updates Plus release lookups to the maintained fork, and documents Plus as a targeted path for plus-only providers.
|
||||
- **2026-04-21**: CLIProxy quota failover now quarantines exhausted Claude and Antigravity accounts out of live rotation when a healthy fallback exists. CCS persists those quota-triggered pauses across launches, automatically resumes them after the configured cooldown window, and deliberately avoids auto-pausing the last available account so single-account setups still degrade gracefully instead of hard-locking themselves.
|
||||
- **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in.
|
||||
- **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser.<lane>.policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config.
|
||||
- **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default.
|
||||
- **2026-04-18**: **#1038** Legacy OpenAI-compatible provider writes no longer self-destruct on the next `ccs cliproxy restart`. CCS now preserves AI-provider-managed top-level sections such as `openai-compatibility` during CLIProxy config regeneration, and the legacy `openai-compat` manager now rewrites only its own YAML section instead of dumping the whole file and stripping the generated version header. Regression coverage now proves the legacy helper keeps the generated header intact and that OpenAI-compatible connectors survive regeneration.
|
||||
- **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.
|
||||
- **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings.
|
||||
- **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank.
|
||||
- **2026-04-08**: **#929** Image Analysis hardening now makes the managed `ccs-image-analysis` MCP path authoritative on healthy Claude-target launches, suppresses stale CCS-managed image `Read` hooks instead of letting them compete with MCP, keeps the legacy hook available only as compatibility fallback when MCP provisioning fails, and extends self-heal to dashboard provisioning plus `ccs doctor --fix` so stale hook files and missing isolated MCP sync are repaired automatically.
|
||||
- **2026-04-07**: CLIProxy routing strategy is now a first-class CCS surface. Users can inspect and explicitly change `round-robin` vs `fill-first` from `ccs cliproxy routing` and from a native `/cliproxy` dashboard card. Local mode now persists the chosen startup default into CCS-managed CLIProxy config generation, while untouched installs remain on `round-robin`. CCS deliberately does not infer strategy from account composition.
|
||||
- **2026-04-07**: **#926** CCS now has a first-class structured logging layer under `src/services/logging/`, a bounded top-level `logging` config section in `~/.ccs/config.yaml`, automatic rotation/retention for CCS-owned logs under `~/.ccs/logs/`, native `/api/logs` dashboard endpoints, request tracing for the dashboard backend, and a dedicated `System -> Logs` dashboard route for browsing recent entries and editing retention settings. Legacy CLIProxy error files remain available as a labeled legacy source instead of acting as the primary logging model.
|
||||
- **2026-04-06**: The dashboard login surface now distinguishes a real sign-in from a host-setup requirement. Remote/IP visitors no longer see a misleading blank credential form when dashboard auth is disabled or incomplete; they now get explicit guidance that CCS has no default credentials, should be enabled on the host with `ccs config auth setup`, or should be reopened via localhost when used on the same machine. The password field now includes a show/hide toggle, and the page exposes an explicit light/dark theme switch before sign-in.
|
||||
- **2026-04-04**: The GitHub README was reduced from a wall-of-text reference dump into a shorter conversion surface that keeps the hero, proof screenshots, and fast-start commands while delegating deeper installation, provider, feature, and CLI-reference content to `docs.ccs.kaitran.ca`. The docs site now includes a dedicated `Product Tour` page for the screenshot-led walkthrough.
|
||||
- **2026-04-05**: **#912 #913 #914** Kiro auth is now aligned with the current CLIProxyAPIPlus contract. CCS auto-selects the Builder ID path for the default `ccs kiro --auth` flow instead of stalling on the upstream Builder ID vs IDC chooser, callback-based Kiro auth methods can use `--paste-callback` by replaying the pasted redirect URL back into the local callback server, and the CLI now supports IDC auth via `--kiro-auth-method idc` plus `--kiro-idc-start-url`, `--kiro-idc-region`, and `--kiro-idc-flow`.
|
||||
- **2026-04-03**: CCS CLI help and completion UX was refreshed. Root help is now shorter and task-oriented, `ccs help <topic|command>` routes to topic-aware help, and shell completions now delegate to the hidden `ccs __complete` backend.
|
||||
- **2026-04-02**: Third-party image and PDF analysis now follows the same first-class local-tool model as WebSearch. CCS provisions `ccs-image-analysis` as a managed MCP tool, routes requests directly to provider-scoped CCS endpoints such as `/api/provider/agy/v1/messages`, keeps editable prompt templates under `~/.ccs/prompts/image-analysis/`, and demotes the old `Read` hook to a best-effort compatibility fallback. Launches now stay non-fatal and fall back to native `Read` when the managed runtime cannot be prepared.
|
||||
- **2026-04-01**: The `Compatible -> Codex CLI` dashboard now exposes manual long-context controls for `model_context_window` and `model_auto_compact_token_limit`. CCS reads and patches those upstream Codex config keys directly, adds official guidance that GPT-5.4 long context is experimental and opt-in, and keeps the behavior manual-only so the dashboard never auto-fills or auto-saves long-context values for the user.
|
||||
- **2026-03-30**: **#862** Third-party WebSearch now uses a first-class CCS-managed MCP tool path instead of relying on a denied native Anthropic `WebSearch` call as the normal UX. CCS provisions `ccs-websearch` into `~/.claude.json`, syncs it into isolated account configs when needed, suppresses native `WebSearch` on third-party launches, preserves the provider order `Exa -> Tavily -> Brave -> DuckDuckGo -> legacy CLI fallback`, and keeps the old hook runtime only as shared provider plumbing plus compatibility fallback. Uninstall cleanup now also removes the managed WebSearch MCP runtime.
|
||||
- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, refresh/discard recovery for stale raw drafts, dirty raw-editor guarding for structured controls, project-trust path validation, read-only handling for unreadable config files, preservation of unsupported upstream values such as granular `approval_policy`, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file.
|
||||
- **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow.
|
||||
- **2026-03-27**: **#812** CCS now includes a first-class `ccs docker` command suite for self-hosting the integrated Dashboard + CLIProxy stack. The CLI can stage bundled Docker assets locally or to a remote `--host` over SSH, report compose/supervisor status, stream CCS or CLIProxy logs, and run in-container update flows without relying on ad-hoc deployment scripts.
|
||||
- **2026-03-24**: Official Claude Channels now follow Anthropic's actual runtime contract. CCS blocks auto-enable unless Bun is available, Claude Code is verified at v2.1.80+, and `claude.ai` auth is verified; treats `--allow-dangerously-skip-permissions` as an explicit override; keeps Telegram/Discord bot tokens in Claude's shared `~/.claude/channels/` state (or official `*_STATE_DIR` overrides); and upgrades the dashboard/CLI status flow with Bun/version/auth/state-scope guidance, safer token draft retention on refresh failures, and a non-macOS iMessage toggle that can still be turned off when already selected.
|
||||
- **2026-03-23**: CLIProxy providers that do not expose an email no longer require a user-supplied nickname on first auth. CCS now derives a stable internal account identifier for Kiro/Copilot-style flows, preserves later rename support, hardens account discovery/registry sync around that identifier, and updates AI Provider CRUD to use stable entry IDs instead of dashboard list indexes.
|
||||
- **2026-03-23**: Sensitive dashboard management routes now fail closed to localhost-only access whenever dashboard auth is disabled. Remote access remains available after `ccs config auth setup`, but AI Provider management, CLIProxy auth/status helpers, and other write-capable settings endpoints no longer trust unauthenticated non-loopback requests.
|
||||
- **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles.
|
||||
- **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback.
|
||||
- **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint.
|
||||
- **#748**: API profile creation now keeps provider selection compact by collapsing advanced presets behind an explicit toggle, shrinking chooser cards so the form fields stay visually primary, and giving `llama.cpp` a dedicated provider logo.
|
||||
- **#744**: API profile creation now keeps featured providers in a horizontal rail with scroll fallback, moves Anthropic Direct API to the end, reuses the shared Claude logo, and separates the custom-endpoint entry point from advanced template discovery.
|
||||
- **#724**: Codex startup is now free-plan safe. CCS defaults new Codex sessions to a cross-plan model and uses runtime fallback handling for unsupported paid-only models without rewriting the saved dashboard settings.
|
||||
- **#737**: Dashboard model pickers in Cursor, Copilot, and CLIProxy now use a searchable combobox with autofocus and explicit no-results states for large model catalogs.
|
||||
- **#736**: `ccs config` now supports explicit dashboard bind hosts via `--host`, and surfaces remote-access warnings plus reachable URLs when the effective bind is non-loopback.
|
||||
- **#1121**: Usage analytics pricing now refreshes cached models.dev metadata before cost derivation, keeps CCS static pricing as the offline fallback, and carries provider identity through CLIProxy/native runtime breakdowns so subscription-backed providers do not inherit paid API pricing.
|
||||
CCS remains a CLI-first profile and runtime manager with dashboard parity for
|
||||
configuration. New work should preserve:
|
||||
|
||||
### Maintainability Hardening Kickoff
|
||||
|
||||
- Issue owner: Stream D for **#542**
|
||||
- Automated inventory command: `bun run report:hardening`
|
||||
- Generated report artifacts:
|
||||
- `docs/reports/hardening-inventory.json`
|
||||
- `docs/reports/hardening-inventory.md`
|
||||
- Debt burndown tracker: [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md)
|
||||
|
||||
### Remaining Large Files (Acceptable)
|
||||
|
||||
**CLI** (complex core logic):
|
||||
- `model-pricing.ts` (676 lines) - Data file
|
||||
- `glmt-proxy.ts` (675 lines) - Legacy internal compatibility proxy
|
||||
- `cliproxy-executor.ts` (666 lines) - Core execution
|
||||
- `ccs.ts` (596 lines) - Entry point
|
||||
|
||||
**UI** (external/shadcn):
|
||||
- `components/ui/sidebar.tsx` (674 lines) - shadcn component
|
||||
|
||||
---
|
||||
|
||||
## GitHub Issues Backlog
|
||||
|
||||
### Critical (Blocking Users)
|
||||
|
||||
| Issue | Title | Type |
|
||||
|-------|-------|------|
|
||||
| #158 | AGY not working - Missing API Key - Run /login | bug |
|
||||
| #155 | Invalid JSON payload error with Gemini/Antigravity | bug |
|
||||
| #124 | Incorrect model ID for Claude 3.5 Sonnet (Thinking) | bug |
|
||||
|
||||
### High Priority (Features)
|
||||
|
||||
| Issue | Title | Type | Status |
|
||||
|-------|-------|------|--------|
|
||||
| #142 | Configure with available CLIProxyAPI | enhancement | **COMPLETE** (v7.1) |
|
||||
| #157 | Support for Kiro auth from CLIProxyAPIPlus | enhancement | **COMPLETE** (v7.2) |
|
||||
| #123 | Add More Models | enhancement | Ongoing |
|
||||
| #114 | OpenCode Zen Free model + Auto Rotation API Key | enhancement | - |
|
||||
|
||||
### Medium Priority
|
||||
|
||||
| Issue | Title | Type |
|
||||
|-------|-------|------|
|
||||
| #137 | CCS Cannot Connect to IDE, but Native Claude Works | support |
|
||||
| #89 | Add Claude Code CLI flag passthrough for delegation | enhancement |
|
||||
| #659 | Comprehensive Vietnamese i18n for dashboard | enhancement |
|
||||
|
||||
### Low Priority / Questions
|
||||
|
||||
| Issue | Title | Type |
|
||||
|-------|-------|------|
|
||||
| #156 | Configure API for Zed IDE | docs |
|
||||
| #140 | Do we support ampcode? | question |
|
||||
| #111 | Factory droid CLI support | enhancement |
|
||||
| #103 | /context command returns incorrect context | invalid |
|
||||
|
||||
---
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
### Priority 1: Multiple CLIProxyAPI Instances
|
||||
|
||||
Support connecting to multiple CLIProxyAPI servers simultaneously.
|
||||
|
||||
**Use Cases**:
|
||||
- Load balancing across multiple proxy servers
|
||||
- Failover when primary server unavailable
|
||||
- Geographic distribution for latency optimization
|
||||
- Separate proxies for different provider groups
|
||||
|
||||
**Proposed Config**:
|
||||
```yaml
|
||||
cliproxy:
|
||||
instances:
|
||||
primary:
|
||||
url: http://localhost:8000
|
||||
providers: [gemini, codex]
|
||||
weight: 80
|
||||
secondary:
|
||||
url: http://192.168.1.100:8000
|
||||
providers: [agy]
|
||||
weight: 20
|
||||
failover:
|
||||
url: http://backup.example.com:8000
|
||||
priority: 2 # Only if primary/secondary fail
|
||||
strategy: weighted-round-robin
|
||||
```
|
||||
|
||||
### Priority 2: Native Git Worktree Support
|
||||
|
||||
Opt-in automatic git worktree management for features/issues.
|
||||
|
||||
**Use Cases**:
|
||||
- Automatic worktree creation when starting issue
|
||||
- Isolation of feature development
|
||||
- Easy cleanup after merge
|
||||
- Integration with GitHub issues
|
||||
|
||||
**Proposed Settings**:
|
||||
```yaml
|
||||
worktrees:
|
||||
enabled: true
|
||||
base_path: ~/.ccs/worktrees
|
||||
auto_create: true
|
||||
auto_cleanup: true
|
||||
naming: "{issue-number}-{short-title}"
|
||||
```
|
||||
|
||||
### Priority 3: Enhanced Model Support
|
||||
|
||||
- **#123**: Expand model catalog with new releases
|
||||
- **#124**: Fix Claude 3.5 Sonnet (Thinking) model ID
|
||||
- **#114**: OpenCode Zen free model + API key rotation
|
||||
|
||||
### Priority 4: IDE Integration
|
||||
|
||||
- **#137**: Debug CCS-to-IDE connection issues
|
||||
- **#156**: Zed IDE configuration documentation
|
||||
- **#140**: Investigate ampcode compatibility
|
||||
- **#111**: Factory droid CLI support assessment
|
||||
|
||||
### Priority 5: Authentication Enhancements
|
||||
|
||||
- **#158**: Fix AGY OAuth flow
|
||||
- **#157**: ~~Add Kiro auth support from CLIProxyAPIPlus~~ **COMPLETE** (v7.2)
|
||||
- GitHub Copilot (ghcp) Device Code flow **COMPLETE** (v7.2, now deprecated compatibility)
|
||||
- Hybrid quota management **COMPLETE** (v7.14)
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
| Milestone | Status | Target |
|
||||
|-----------|--------|--------|
|
||||
| Modularization (Phases 1-9) | COMPLETE | - |
|
||||
| Remote CLIProxy Support (#142) | COMPLETE | v7.1 |
|
||||
| Kiro + GitHub Copilot OAuth (#157) | COMPLETE, Copilot now deprecated compatibility | v7.2 |
|
||||
| Hybrid Quota Management | COMPLETE | v7.14 |
|
||||
| Docker Support (PR #345) | COMPLETE | v7.23 |
|
||||
| Image Analysis Hook | COMPLETE | v7.34 |
|
||||
| Third-Party Tool Integration | COMPLETE | v7.39 |
|
||||
| Critical Bug Fixes (#158, #155, #124) | PLANNED | Q1 2026 |
|
||||
| Multiple CLIProxyAPI Instances | PLANNED | Q1 2026 |
|
||||
| Git Worktree Support | PLANNED | Q2 2026 |
|
||||
| Enhanced Model Support | PLANNED | Q2 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
All criteria achieved:
|
||||
|
||||
- [x] Files under 200 lines (except documented exceptions)
|
||||
- [x] Every directory has barrel export
|
||||
- [x] No circular dependencies
|
||||
- [x] TypeScript strict mode passing
|
||||
- [x] 90%+ test coverage
|
||||
- [x] Clear domain boundaries
|
||||
- [x] Consistent naming conventions
|
||||
|
||||
## Historical Maintainability Gate (Retired)
|
||||
|
||||
This section is preserved as historical context from the original Issue `#539` work.
|
||||
|
||||
- The maintainability baseline gate is no longer part of the active CCS workflow.
|
||||
- Current contributor and CI gates are documented in `CLAUDE.md`, `CONTRIBUTING.md`, and the GitHub workflow files.
|
||||
- Do not assume `maintainability:baseline` or `maintainability:check` exist unless they are reintroduced in a future follow-up.
|
||||
|
||||
---
|
||||
- isolated account/profile state;
|
||||
- explicit provider and runtime selection;
|
||||
- actionable, ASCII-safe terminal errors;
|
||||
- compatible CLI, dashboard, and public-doc contracts;
|
||||
- safe local defaults for credentials, browser access, and proxy routing.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Codebase Summary](./codebase-summary.md) - Current structure
|
||||
- [Code Standards](./code-standards.md) - Patterns and conventions
|
||||
- [System Architecture](./system-architecture/index.md) - Architecture diagrams
|
||||
- [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md) - Legacy shim + sync-fs debt tracking
|
||||
- [CLAUDE.md](../CLAUDE.md) - AI development guidance
|
||||
- [Codebase Summary](./codebase-summary.md)
|
||||
- [Code Standards](./code-standards.md)
|
||||
- [System Architecture](./system-architecture/index.md)
|
||||
- [CLAUDE.md](../CLAUDE.md)
|
||||
+90
-79
@@ -1,106 +1,117 @@
|
||||
# CCS Release Process
|
||||
|
||||
CCS uses a decoupled release model: every merge to `main` immediately publishes
|
||||
a stable npm `@latest` release and an immutable Docker `:<ver>` tag. Docker
|
||||
mutable tags (`:latest`, `:<MAJOR>`, `:<MINOR>`) require a separate manual
|
||||
promote step after an operator-verified soak window. This decouples the npm
|
||||
ecosystem from the Docker stability gate.
|
||||
CCS has separate development, stable npm, and Docker promotion lanes. A branch
|
||||
push starts the relevant workflow. Eligible `dev` pushes publish the next custom
|
||||
development prerelease after their gates pass; `main` publishes only when
|
||||
semantic-release finds release-worthy commits.
|
||||
|
||||
## Phase 1 — Automatic stable release (on every merge to `main`)
|
||||
## Release lanes
|
||||
|
||||
1. A PR is merged into `main` with a conventional commit (`feat:`, `fix:`, etc.).
|
||||
2. `release.yml` triggers semantic-release, which reads `.releaserc.cjs`.
|
||||
3. Because `main` is a stable channel, semantic-release cuts a GitHub release
|
||||
tagged `vX.Y.Z` and publishes the npm package to the `@latest` dist-tag
|
||||
immediately. No rc channel, no soak delay on npm.
|
||||
4. `docker-release.yml` triggers on the `release: published` event and:
|
||||
- Validates the tag as stable semver (`vX.Y.Z`).
|
||||
- Builds the integrated image for `linux/amd64` and `linux/arm64`.
|
||||
- Pushes **only the immutable** `ghcr.io/kaitranntt/ccs:X.Y.Z` tag.
|
||||
- Signs the image with cosign (keyless OIDC).
|
||||
- Runs smoke tests (`smoke-test` job).
|
||||
- Mutable tags (`:latest`, `:<MAJOR>`, `:<MINOR>`) are **not** added at
|
||||
this stage — `promote-mutable-tags` only runs on explicit
|
||||
`workflow_dispatch` with `promote_to_latest=true`.
|
||||
| Source | Workflow | Result |
|
||||
| --- | --- | --- |
|
||||
| Push to `dev` | [`dev-release.yml`](../.github/workflows/dev-release.yml) | Custom development prerelease and npm `@dev` publication |
|
||||
| Push to `main` | [`release.yml`](../.github/workflows/release.yml) | Semantic-release stable version, npm `@latest`, tag, and GitHub release when commits require a release |
|
||||
| Published stable or `rc` GitHub release | [`docker-release.yml`](../.github/workflows/docker-release.yml) | Immutable integrated Docker version tag, signature, and smoke test |
|
||||
| Manual stable promotion | [`promote-release.yml`](../.github/workflows/promote-release.yml) | Docker `:latest`, major, and minor aliases |
|
||||
| Stable GitHub release | [`sync-dev-after-release.yml`](../.github/workflows/sync-dev-after-release.yml) | Merge released `main` state back into `dev` |
|
||||
|
||||
## Phase 2 — Manual promotion to Docker mutable tags (rc.1 soak window)
|
||||
## Development prereleases
|
||||
|
||||
After the immutable `:<ver>` Docker image has soaked (typically 24 h with no
|
||||
reported issues), the operator promotes mutable tags:
|
||||
`Dev Release` runs on pushes to `dev` and can also be dispatched manually.
|
||||
After build and validation gates, it calls
|
||||
[`scripts/dev-release.sh`](../scripts/dev-release.sh). That script owns the
|
||||
`<stable>-dev.<n>` version sequence and npm `@dev` publication. It is
|
||||
intentionally separate from the production semantic-release configuration.
|
||||
|
||||
1. Verify the immutable image is healthy:
|
||||
Generated `chore(release): ...` pushes are skipped by the workflow guard to
|
||||
prevent release recursion.
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/kaitranntt/ccs:X.Y.Z
|
||||
docker run --rm -p 3000:3000 -p 8317:8317 ghcr.io/kaitranntt/ccs:X.Y.Z
|
||||
# check http://localhost:3000 and http://localhost:8317
|
||||
```
|
||||
## Stable npm and GitHub releases
|
||||
|
||||
2. Optionally verify the cosign signature:
|
||||
`Release` runs on `main`. It builds the CLI and dashboard, runs the fast, slow,
|
||||
and end-to-end gates, then invokes semantic-release with
|
||||
[`.releaserc.cjs`](../.releaserc.cjs).
|
||||
|
||||
```bash
|
||||
cosign verify \
|
||||
--certificate-identity-regexp "https://github.com/kaitranntt/ccs/.github/workflows/docker-release.yml" \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
ghcr.io/kaitranntt/ccs:X.Y.Z
|
||||
```
|
||||
Semantic-release analyzes commits since the previous stable release:
|
||||
|
||||
3. Run the `promote-release` workflow via GitHub Actions UI or CLI:
|
||||
- `feat` produces at least a minor release;
|
||||
- `fix`, `hotfix`, `refactor`, and `style` produce patch releases under the
|
||||
repository rules;
|
||||
- breaking-change notation produces the appropriate major release; and
|
||||
- commits without a matching release rule may produce no release.
|
||||
|
||||
```bash
|
||||
gh workflow run promote-release.yml \
|
||||
--field tag=vX.Y.Z
|
||||
```
|
||||
When a release is required, the lane updates `CHANGELOG.md` and `package.json`,
|
||||
publishes npm `@latest`, creates the stable Git tag and GitHub release, and
|
||||
pushes the generated release commit to `main`. Do not bump versions or create
|
||||
release tags manually.
|
||||
|
||||
This dispatches `docker-release.yml` with `promote_to_latest=true`, which
|
||||
triggers the `promote-mutable-tags` job to add `:latest`, `:<MAJOR>`, and
|
||||
`:<MINOR>` via `docker buildx imagetools create`.
|
||||
## Docker publication and promotion
|
||||
|
||||
Alternatively, dispatch `docker-release.yml` directly:
|
||||
The supported integrated image is `ghcr.io/kaitranntt/ccs`.
|
||||
|
||||
```bash
|
||||
gh workflow run "Publish Docker Image" \
|
||||
--field tag=vX.Y.Z \
|
||||
--field promote_to_latest=true
|
||||
```
|
||||
On a published stable `vX.Y.Z` or release-candidate `vX.Y.Z-rc.N` GitHub
|
||||
release, `Publish Docker Image`:
|
||||
|
||||
## Why npm and Docker have different soak windows
|
||||
1. validates the release tag;
|
||||
2. checks out that tag;
|
||||
3. builds the integrated image for `linux/amd64` and `linux/arm64`;
|
||||
4. publishes only the matching immutable version tag;
|
||||
5. signs the image digest with keyless cosign; and
|
||||
6. smoke-tests the published image.
|
||||
|
||||
- **npm `@latest`**: Published immediately on every `main` merge. npm users who
|
||||
pin a version are unaffected; users who run `npm install -g @kaitranntt/ccs`
|
||||
get the latest immediately. Rollback is `npm install -g @kaitranntt/ccs@X.Y.Z`.
|
||||
- **Docker `:latest`**: Promoted only after operator confirmation. Users who
|
||||
pull `:latest` or run `docker pull` without a pinned tag are shielded from
|
||||
a bad image. The immutable `:<ver>` tag is always available for pinned usage
|
||||
from the moment of release.
|
||||
|
||||
## Verifying the promotion
|
||||
Mutable aliases are a separate operator decision. After verifying the immutable
|
||||
image and allowing the desired soak period, dispatch `promote-release.yml`:
|
||||
|
||||
```bash
|
||||
# Confirm :latest points to the promoted digest
|
||||
docker buildx imagetools inspect ghcr.io/kaitranntt/ccs:latest
|
||||
gh workflow run promote-release.yml --field tag=vX.Y.Z
|
||||
```
|
||||
|
||||
# Confirm npm @latest updated (happens automatically at Phase 1)
|
||||
The promotion workflow verifies that the stable GitHub release and immutable
|
||||
image exist, then dispatches `docker-release.yml` with
|
||||
`promote_to_latest=true`. The promotion job creates `:latest`, `:X`, and
|
||||
`:X.Y` aliases from the immutable image digest.
|
||||
|
||||
The deprecated `ccs-dashboard` image has its own sunset compatibility job.
|
||||
Do not use its tag behavior as the contract for the supported integrated image.
|
||||
|
||||
## Post-release development sync
|
||||
|
||||
A published, non-prerelease `vX.Y.Z` release targeting `main` triggers
|
||||
`Sync Dev After Main Release`. The workflow merges `main` into `dev`, resolves
|
||||
known generated version-file conflicts in favor of the released `main` state,
|
||||
and pushes `dev`. That push intentionally triggers the normal `Push CI` and
|
||||
development-release lanes.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# npm channels
|
||||
npm view @kaitranntt/ccs dist-tags
|
||||
|
||||
# immutable integrated image
|
||||
docker buildx imagetools inspect ghcr.io/kaitranntt/ccs:X.Y.Z
|
||||
|
||||
# mutable alias after promotion
|
||||
docker buildx imagetools inspect ghcr.io/kaitranntt/ccs:latest
|
||||
```
|
||||
|
||||
## Rollback
|
||||
Verify the GitHub Actions run and tag point to the expected commit before
|
||||
announcing a release.
|
||||
|
||||
If a promoted release is found to be bad:
|
||||
## Recovery
|
||||
|
||||
```bash
|
||||
# Repoint :latest to the previous known-good immutable tag
|
||||
docker buildx imagetools create \
|
||||
--tag ghcr.io/kaitranntt/ccs:latest \
|
||||
ghcr.io/kaitranntt/ccs:PREVIOUS.VERSION
|
||||
- **Bad npm release:** publish a corrected patch. Do not unpublish a version
|
||||
used by downstream consumers.
|
||||
- **Bad immutable Docker image:** leave the immutable tag unchanged and publish
|
||||
a corrected version.
|
||||
- **Bad mutable Docker promotion:** promote a known-good immutable digest back
|
||||
to the mutable aliases through the controlled workflow.
|
||||
- **Failed `dev` sync:** repair the merge against current `main` and `dev`;
|
||||
never overwrite branch history.
|
||||
|
||||
# For npm, publish a fix as a new patch release (do not unpublish)
|
||||
# Unpublishing npm packages causes downstream breakage for pinned consumers.
|
||||
```
|
||||
## Branch and tag summary
|
||||
|
||||
## Branch / tag taxonomy
|
||||
|
||||
| Branch | Semantic-release channel | npm dist-tag | Docker tag (on release event) | Docker mutable (on promote) |
|
||||
|--------|--------------------------|--------------|-------------------------------|------------------------------|
|
||||
| `main` | stable | `@latest` | `:<ver>` (immutable, immediate) | `:latest`, `:<MAJOR>`, `:<MINOR>` (after soak) |
|
||||
| `dev` | `dev` prerelease | `@dev` | not published | not published |
|
||||
| Branch | Package channel | npm dist-tag | Integrated Docker |
|
||||
| --- | --- | --- | --- |
|
||||
| `dev` | Development prerelease | `@dev` | None |
|
||||
| `main` | Stable semantic release | `@latest` | Immutable tag on GitHub release; mutable aliases after manual promotion |
|
||||
@@ -1,113 +1,25 @@
|
||||
{
|
||||
"scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
|
||||
"syncFs": {
|
||||
"totalOccurrences": 2301,
|
||||
"filesAffected": 247,
|
||||
"hotpathOccurrences": 1946,
|
||||
"hotpathFilesAffected": 197,
|
||||
"totalOccurrences": 2471,
|
||||
"filesAffected": 261,
|
||||
"hotpathOccurrences": 1186,
|
||||
"hotpathFilesAffected": 155,
|
||||
"topHotpathFiles": [
|
||||
{
|
||||
"file": "src/cliproxy/__tests__/pool-routing-phase3.test.ts",
|
||||
"count": 96,
|
||||
"file": "src/management/shared-manager/diverged-file-adopter.ts",
|
||||
"count": 39,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"mkdtempSync",
|
||||
"closeSync",
|
||||
"fsyncSync",
|
||||
"linkSync",
|
||||
"lstatSync",
|
||||
"openSync",
|
||||
"readdirSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/config/__tests__/config-generator.test.js",
|
||||
"count": 88,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"mkdtempSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/config/__tests__/claude-model-neutral.test.ts",
|
||||
"count": 63,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"mkdtempSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"readlinkSync",
|
||||
"renameSync",
|
||||
"statSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts",
|
||||
"count": 48,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"mkdtempSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts",
|
||||
"count": 45,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"mkdtempSync",
|
||||
"readdirSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js",
|
||||
"count": 36,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readdirSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"unlinkSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/executor/__tests__/composite-variant-service.test.ts",
|
||||
"count": 33,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"mkdtempSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/executor/__tests__/variant-port-edge-cases.test.js",
|
||||
"count": 33,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readdirSync",
|
||||
"rmSync",
|
||||
"unlinkSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
@@ -130,18 +42,122 @@
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/__tests__/session-tracker-port.test.js",
|
||||
"count": 31,
|
||||
"file": "src/utils/image-analysis/mcp-installer.ts",
|
||||
"count": 30,
|
||||
"calls": [
|
||||
"chmodSync",
|
||||
"copyFileSync",
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readFileSync",
|
||||
"renameSync",
|
||||
"statSync",
|
||||
"unlinkSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/utils/claude-symlink-manager.ts",
|
||||
"count": 27,
|
||||
"calls": [
|
||||
"copyFileSync",
|
||||
"existsSync",
|
||||
"lstatSync",
|
||||
"mkdirSync",
|
||||
"readdirSync",
|
||||
"readlinkSync",
|
||||
"renameSync",
|
||||
"rmSync",
|
||||
"statSync",
|
||||
"symlinkSync",
|
||||
"unlinkSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/config/env-builder.ts",
|
||||
"count": 25,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readdirSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/management/shared-manager/migrations.ts",
|
||||
"count": 25,
|
||||
"calls": [
|
||||
"copyFileSync",
|
||||
"cpSync",
|
||||
"existsSync",
|
||||
"lstatSync",
|
||||
"mkdirSync",
|
||||
"readdirSync",
|
||||
"symlinkSync",
|
||||
"unlinkSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/utils/websearch/mcp-installer.ts",
|
||||
"count": 25,
|
||||
"calls": [
|
||||
"chmodSync",
|
||||
"copyFileSync",
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readFileSync",
|
||||
"renameSync",
|
||||
"statSync",
|
||||
"unlinkSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/services/variant-settings.ts",
|
||||
"count": 23,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readFileSync",
|
||||
"renameSync",
|
||||
"unlinkSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/management/recovery-manager.ts",
|
||||
"count": 23,
|
||||
"calls": [
|
||||
"copyFileSync",
|
||||
"existsSync",
|
||||
"lstatSync",
|
||||
"mkdirSync",
|
||||
"renameSync",
|
||||
"statSync",
|
||||
"unlinkSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/utils/shell-completion.ts",
|
||||
"count": 23,
|
||||
"calls": [
|
||||
"appendFileSync",
|
||||
"copyFileSync",
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readFileSync",
|
||||
"statSync"
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
],
|
||||
"topFilesOverall": [
|
||||
@@ -212,6 +228,25 @@
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/management/shared-manager/diverged-file-adopter.ts",
|
||||
"count": 39,
|
||||
"calls": [
|
||||
"closeSync",
|
||||
"fsyncSync",
|
||||
"linkSync",
|
||||
"lstatSync",
|
||||
"openSync",
|
||||
"readdirSync",
|
||||
"readFileSync",
|
||||
"readlinkSync",
|
||||
"renameSync",
|
||||
"statSync",
|
||||
"unlinkSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js",
|
||||
"count": 36,
|
||||
@@ -267,26 +302,12 @@
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/__tests__/session-tracker-port.test.js",
|
||||
"count": 31,
|
||||
"calls": [
|
||||
"existsSync",
|
||||
"mkdirSync",
|
||||
"readdirSync",
|
||||
"readFileSync",
|
||||
"rmSync",
|
||||
"unlinkSync",
|
||||
"writeFileSync"
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"legacyShim": {
|
||||
"totalMarkers": 427,
|
||||
"filesAffected": 164,
|
||||
"totalMarkers": 465,
|
||||
"filesAffected": 176,
|
||||
"topFiles": [
|
||||
{
|
||||
"file": "src/auth/profile-detector.ts",
|
||||
@@ -312,6 +333,28 @@
|
||||
"// Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/web-server/usage/native-quota-collector.ts",
|
||||
"count": 15,
|
||||
"calls": [],
|
||||
"markers": [
|
||||
"* are used for backward compatibility.",
|
||||
"* Kept for backward compatibility with existing tests.",
|
||||
"* Legacy Codex collector — uses the old getDefaultCodexAccountId dep.",
|
||||
"* List all Claude profiles from the profile registry (merged legacy + unified).",
|
||||
"* profile name. Kept for backward compatibility with existing tests that stub",
|
||||
"* When no enumeration deps are injected (legacy mode / old tests that stub only",
|
||||
"/** Legacy Codex network row builder. */",
|
||||
"/** Legacy Codex row builder (local quota). */",
|
||||
"/** Legacy row builder — no surface/profile/is_subscription fields. */",
|
||||
"// everything except the legacy harness takes the multi-profile path below.",
|
||||
"// For the network fallback: the legacy getDefaultCodexAccountId is the",
|
||||
"// Legacy path: backward-compatible with old tests that only inject",
|
||||
"// Legacy single-profile collectors (unchanged; used by old tests + back-compat)",
|
||||
"// Multi-profile enumeration is the DEFAULT (production) behavior. The legacy",
|
||||
"// Use the legacy single-state approach via profile key '__legacy__' to avoid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/utils/config-manager.ts",
|
||||
"count": 13,
|
||||
@@ -373,14 +416,14 @@
|
||||
"count": 10,
|
||||
"calls": [],
|
||||
"markers": [
|
||||
"* - Legacy CLI fallbacks: Gemini, Grok, OpenCode",
|
||||
"* Legacy AI CLI fallbacks remain available for compatibility only.",
|
||||
"* Uses deterministic search backends first, with optional legacy CLI fallback.",
|
||||
"/** Enable Gemini CLI legacy fallback (default: false) */",
|
||||
"/** Enable Grok CLI legacy fallback (default: false - requires GROK_API_KEY) */",
|
||||
"/** Enable OpenCode CLI legacy fallback (default: false) */",
|
||||
"/** Gemini CLI - optional legacy LLM fallback */",
|
||||
"/** Gemini CLI - deprecated legacy LLM fallback (retired upstream) */",
|
||||
"/** Grok CLI - optional legacy LLM fallback */",
|
||||
"/** Model to use (default: gemini-2.5-flash; accepts legacy gemini ids) */",
|
||||
"/** OpenCode - optional legacy LLM fallback */",
|
||||
"// Legacy fields (deprecated, kept for backwards compatibility)"
|
||||
]
|
||||
@@ -440,20 +483,6 @@
|
||||
"it('legacy variant does not block port allocation', function () {",
|
||||
"legacy: {"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/config/__tests__/config-generator.test.js",
|
||||
"count": 7,
|
||||
"calls": [],
|
||||
"markers": [
|
||||
"'Should preserve lone manual high-version aliases during legacy cleanup'",
|
||||
"'Should preserve manual aliases after legacy migration has already run'",
|
||||
"'Should preserve the manual 3.2 compatibility cluster during legacy cleanup'",
|
||||
"'Should preserve the pruned legacy alias in the migration backup'",
|
||||
"assert(fs.existsSync(backupPath), 'Should keep a backup of the legacy config before pruning');",
|
||||
"it('prunes partially retained broad guessed ranges during legacy cleanup', () => {",
|
||||
"it('writes a one-time backup before pruning legacy Gemini aliases during migration', () => {"
|
||||
]
|
||||
}
|
||||
],
|
||||
"explicitShimFiles": [
|
||||
@@ -468,11 +497,11 @@
|
||||
},
|
||||
"maintainability": {
|
||||
"typedErrors": {
|
||||
"totalThrows": 431,
|
||||
"typedThrows": 37,
|
||||
"plainThrows": 336,
|
||||
"otherThrows": 58,
|
||||
"adoptionRatio": 0.0858,
|
||||
"totalThrows": 454,
|
||||
"typedThrows": 83,
|
||||
"plainThrows": 311,
|
||||
"otherThrows": 60,
|
||||
"adoptionRatio": 0.1828,
|
||||
"topSubdomainsByThrows": [
|
||||
{
|
||||
"subdomain": "web-server",
|
||||
@@ -480,6 +509,12 @@
|
||||
"typed": 16,
|
||||
"plain": 45
|
||||
},
|
||||
{
|
||||
"subdomain": "commands",
|
||||
"count": 38,
|
||||
"typed": 6,
|
||||
"plain": 30
|
||||
},
|
||||
{
|
||||
"subdomain": "proxy",
|
||||
"count": 38,
|
||||
@@ -487,10 +522,10 @@
|
||||
"plain": 34
|
||||
},
|
||||
{
|
||||
"subdomain": "commands",
|
||||
"count": 33,
|
||||
"typed": 0,
|
||||
"plain": 32
|
||||
"subdomain": "codex-auth",
|
||||
"count": 35,
|
||||
"typed": 4,
|
||||
"plain": 27
|
||||
},
|
||||
{
|
||||
"subdomain": "utils",
|
||||
@@ -498,23 +533,23 @@
|
||||
"typed": 0,
|
||||
"plain": 33
|
||||
},
|
||||
{
|
||||
"subdomain": "codex-auth",
|
||||
"count": 31,
|
||||
"typed": 0,
|
||||
"plain": 27
|
||||
},
|
||||
{
|
||||
"subdomain": "targets",
|
||||
"count": 27,
|
||||
"typed": 0,
|
||||
"plain": 25
|
||||
"typed": 9,
|
||||
"plain": 16
|
||||
},
|
||||
{
|
||||
"subdomain": "cursor",
|
||||
"count": 23,
|
||||
"typed": 0,
|
||||
"plain": 21
|
||||
"typed": 4,
|
||||
"plain": 17
|
||||
},
|
||||
{
|
||||
"subdomain": "cliproxy/auth",
|
||||
"count": 16,
|
||||
"typed": 16,
|
||||
"plain": 0
|
||||
},
|
||||
{
|
||||
"subdomain": "cliproxy/accounts",
|
||||
@@ -527,12 +562,6 @@
|
||||
"count": 12,
|
||||
"typed": 12,
|
||||
"plain": 0
|
||||
},
|
||||
{
|
||||
"subdomain": "cliproxy/ai-providers",
|
||||
"count": 12,
|
||||
"typed": 0,
|
||||
"plain": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -543,15 +572,15 @@
|
||||
"web-server/routes",
|
||||
"auth"
|
||||
],
|
||||
"numerator": 21,
|
||||
"denominator": 23,
|
||||
"ratio": 0.913,
|
||||
"numerator": 28,
|
||||
"denominator": 30,
|
||||
"ratio": 0.9333,
|
||||
"targetRatio": 0.4
|
||||
},
|
||||
"loggerCoverage": {
|
||||
"filesWithCreateLogger": 64,
|
||||
"totalSourceFiles": 745,
|
||||
"coverageRatio": 0.0859,
|
||||
"filesWithCreateLogger": 65,
|
||||
"totalSourceFiles": 764,
|
||||
"coverageRatio": 0.0851,
|
||||
"subdomainsWithZeroCreateLogger": [
|
||||
"api",
|
||||
"bin",
|
||||
@@ -572,21 +601,26 @@
|
||||
"topSubdomainsByFiles": [
|
||||
{
|
||||
"subdomain": "web-server",
|
||||
"count": 111,
|
||||
"count": 112,
|
||||
"withLogger": 10
|
||||
},
|
||||
{
|
||||
"subdomain": "commands",
|
||||
"count": 107,
|
||||
"count": 110,
|
||||
"withLogger": 2
|
||||
},
|
||||
{
|
||||
"subdomain": "utils",
|
||||
"count": 87,
|
||||
"count": 90,
|
||||
"withLogger": 1
|
||||
},
|
||||
{
|
||||
"subdomain": "management",
|
||||
"count": 35,
|
||||
"withLogger": 1
|
||||
},
|
||||
{
|
||||
"subdomain": "cliproxy/auth",
|
||||
"count": 32,
|
||||
"withLogger": 1
|
||||
},
|
||||
@@ -595,26 +629,21 @@
|
||||
"count": 31,
|
||||
"withLogger": 5
|
||||
},
|
||||
{
|
||||
"subdomain": "cliproxy/auth",
|
||||
"count": 30,
|
||||
"withLogger": 1
|
||||
},
|
||||
{
|
||||
"subdomain": "config",
|
||||
"count": 27,
|
||||
"withLogger": 0
|
||||
},
|
||||
{
|
||||
"subdomain": "codex-auth",
|
||||
"count": 24,
|
||||
"withLogger": 9
|
||||
},
|
||||
{
|
||||
"subdomain": "cursor",
|
||||
"count": 24,
|
||||
"withLogger": 2
|
||||
},
|
||||
{
|
||||
"subdomain": "codex-auth",
|
||||
"count": 23,
|
||||
"withLogger": 8
|
||||
},
|
||||
{
|
||||
"subdomain": "auth",
|
||||
"count": 20,
|
||||
@@ -623,10 +652,10 @@
|
||||
]
|
||||
},
|
||||
"hotpathConsoleErrors": {
|
||||
"totalOccurrences": 569,
|
||||
"exemptOccurrences": 302,
|
||||
"hotpathOccurrences": 267,
|
||||
"filesAffected": 82,
|
||||
"totalOccurrences": 592,
|
||||
"exemptOccurrences": 326,
|
||||
"hotpathOccurrences": 266,
|
||||
"filesAffected": 81,
|
||||
"topFiles": [
|
||||
{
|
||||
"file": "src/errors/error-handler.ts",
|
||||
@@ -691,16 +720,20 @@
|
||||
]
|
||||
},
|
||||
"largeFiles": {
|
||||
"countOver400": 89,
|
||||
"countOver600": 39,
|
||||
"countOver400": 91,
|
||||
"countOver600": 42,
|
||||
"topOver400": [
|
||||
{
|
||||
"file": "src/web-server/usage/native-quota-collector.ts",
|
||||
"loc": 1758
|
||||
},
|
||||
{
|
||||
"file": "src/web-server/routes/cliproxy-auth-routes.ts",
|
||||
"loc": 1515
|
||||
"loc": 1531
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/auth/oauth-handler.ts",
|
||||
"loc": 1455
|
||||
"loc": 1510
|
||||
},
|
||||
{
|
||||
"file": "src/cursor/cursor-executor.ts",
|
||||
@@ -708,31 +741,31 @@
|
||||
},
|
||||
{
|
||||
"file": "src/web-server/model-pricing.ts",
|
||||
"loc": 1070
|
||||
"loc": 1127
|
||||
},
|
||||
{
|
||||
"file": "src/web-server/routes/settings-routes.ts",
|
||||
"loc": 1041
|
||||
"file": "src/cliproxy/config/generator.ts",
|
||||
"loc": 1109
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/auth/oauth-process.ts",
|
||||
"loc": 1048
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/config/env-builder.ts",
|
||||
"loc": 1037
|
||||
"loc": 1045
|
||||
},
|
||||
{
|
||||
"file": "src/web-server/routes/settings-routes.ts",
|
||||
"loc": 1042
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/proxy/tool-sanitization-proxy.ts",
|
||||
"loc": 1020
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/auth/oauth-process.ts",
|
||||
"loc": 1018
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/config/generator.ts",
|
||||
"loc": 1012
|
||||
},
|
||||
{
|
||||
"file": "src/commands/cliproxy/variant-subcommand.ts",
|
||||
"loc": 978
|
||||
"loc": 997
|
||||
},
|
||||
{
|
||||
"file": "src/cliproxy/quota/quota-manager.ts",
|
||||
@@ -749,10 +782,6 @@
|
||||
{
|
||||
"file": "src/cliproxy/accounts/registry.ts",
|
||||
"loc": 871
|
||||
},
|
||||
{
|
||||
"file": "src/channels/official-channels-runtime.ts",
|
||||
"loc": 867
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,33 +6,34 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| Sync fs occurrences (all) | 2301 |
|
||||
| Sync fs files affected (all) | 247 |
|
||||
| Sync fs occurrences (runtime hotpaths) | 1946 |
|
||||
| Sync fs files affected (runtime hotpaths) | 197 |
|
||||
| Legacy shim markers | 427 |
|
||||
| Legacy shim files affected | 164 |
|
||||
| Sync fs occurrences (all) | 2471 |
|
||||
| Sync fs files affected (all) | 261 |
|
||||
| Sync fs occurrences (runtime hotpaths) | 1186 |
|
||||
| Sync fs files affected (runtime hotpaths) | 155 |
|
||||
| Legacy shim markers | 465 |
|
||||
| Legacy shim files affected | 176 |
|
||||
|
||||
## Top Runtime Hotpath Sync fs Files
|
||||
|
||||
| File | Sync Calls | API Names |
|
||||
|---|---:|---|
|
||||
| `src/cliproxy/__tests__/pool-routing-phase3.test.ts` | 96 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
|
||||
| `src/cliproxy/config/__tests__/config-generator.test.js` | 88 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
|
||||
| `src/cliproxy/config/__tests__/claude-model-neutral.test.ts` | 63 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync |
|
||||
| `src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts` | 48 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
|
||||
| `src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts` | 45 | existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync |
|
||||
| `src/cliproxy/executor/__tests__/variant-port-integration.test.js` | 36 | existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync |
|
||||
| `src/cliproxy/executor/__tests__/composite-variant-service.test.ts` | 33 | existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync |
|
||||
| `src/cliproxy/executor/__tests__/variant-port-edge-cases.test.js` | 33 | existsSync, mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync |
|
||||
| `src/management/shared-manager/diverged-file-adopter.ts` | 39 | closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, unlinkSync, writeFileSync |
|
||||
| `src/utils/browser/mcp-installer.ts` | 32 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync |
|
||||
| `src/cliproxy/__tests__/session-tracker-port.test.js` | 31 | existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync |
|
||||
| `src/utils/image-analysis/mcp-installer.ts` | 30 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync |
|
||||
| `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync |
|
||||
| `src/cliproxy/config/env-builder.ts` | 25 | existsSync, mkdirSync, readFileSync, writeFileSync |
|
||||
| `src/management/shared-manager/migrations.ts` | 25 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync, unlinkSync, writeFileSync |
|
||||
| `src/utils/websearch/mcp-installer.ts` | 25 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync |
|
||||
| `src/cliproxy/services/variant-settings.ts` | 23 | existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync |
|
||||
| `src/management/recovery-manager.ts` | 23 | copyFileSync, existsSync, lstatSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync |
|
||||
| `src/utils/shell-completion.ts` | 23 | appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync |
|
||||
|
||||
## Top Legacy Shim Marker Files
|
||||
|
||||
| File | Marker Count |
|
||||
|---|---:|
|
||||
| `src/auth/profile-detector.ts` | 18 |
|
||||
| `src/web-server/usage/native-quota-collector.ts` | 15 |
|
||||
| `src/utils/config-manager.ts` | 13 |
|
||||
| `src/cliproxy/__tests__/pool-onboarding-phase5.test.ts` | 12 |
|
||||
| `src/cliproxy/executor/__tests__/variant-port-allocation.test.js` | 12 |
|
||||
@@ -41,7 +42,6 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
||||
| `src/config/migration-manager.ts` | 9 |
|
||||
| `src/cliproxy/config/__tests__/env-builder-provider-url.test.ts` | 8 |
|
||||
| `src/cliproxy/executor/__tests__/variant-port-edge-cases.test.js` | 8 |
|
||||
| `src/cliproxy/config/__tests__/config-generator.test.js` | 7 |
|
||||
|
||||
## Explicit Shim/Re-export Files
|
||||
|
||||
@@ -56,14 +56,14 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| typed-error adoption (typed/total throws) | 8.6% (37/431) |
|
||||
| typed-error adoption (P4 locked subdomains) | 91.3% (21/23), target 40% |
|
||||
| hotpath console.error/warn occurrences | 267 (569 total, 302 CLI-UX exempt) |
|
||||
| hotpath console.error/warn files | 82 |
|
||||
| files with createLogger | 64/745 |
|
||||
| typed-error adoption (typed/total throws) | 18.3% (83/454) |
|
||||
| typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% |
|
||||
| hotpath console.error/warn occurrences | 266 (592 total, 326 CLI-UX exempt) |
|
||||
| hotpath console.error/warn files | 81 |
|
||||
| files with createLogger | 65/764 |
|
||||
| subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) |
|
||||
| files > 400 LOC | 89 |
|
||||
| files > 600 LOC | 39 |
|
||||
| files > 400 LOC | 91 |
|
||||
| files > 600 LOC | 42 |
|
||||
|
||||
### Top Hotpath console.error/warn Files
|
||||
|
||||
@@ -89,19 +89,19 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
||||
|
||||
| File | LOC |
|
||||
|---|---:|
|
||||
| `src/web-server/routes/cliproxy-auth-routes.ts` | 1515 |
|
||||
| `src/cliproxy/auth/oauth-handler.ts` | 1455 |
|
||||
| `src/web-server/usage/native-quota-collector.ts` | 1758 |
|
||||
| `src/web-server/routes/cliproxy-auth-routes.ts` | 1531 |
|
||||
| `src/cliproxy/auth/oauth-handler.ts` | 1510 |
|
||||
| `src/cursor/cursor-executor.ts` | 1234 |
|
||||
| `src/web-server/model-pricing.ts` | 1070 |
|
||||
| `src/web-server/routes/settings-routes.ts` | 1041 |
|
||||
| `src/cliproxy/config/env-builder.ts` | 1037 |
|
||||
| `src/web-server/model-pricing.ts` | 1127 |
|
||||
| `src/cliproxy/config/generator.ts` | 1109 |
|
||||
| `src/cliproxy/auth/oauth-process.ts` | 1048 |
|
||||
| `src/cliproxy/config/env-builder.ts` | 1045 |
|
||||
| `src/web-server/routes/settings-routes.ts` | 1042 |
|
||||
| `src/cliproxy/proxy/tool-sanitization-proxy.ts` | 1020 |
|
||||
| `src/cliproxy/auth/oauth-process.ts` | 1018 |
|
||||
| `src/cliproxy/config/generator.ts` | 1012 |
|
||||
| `src/commands/cliproxy/variant-subcommand.ts` | 978 |
|
||||
| `src/commands/cliproxy/variant-subcommand.ts` | 997 |
|
||||
| `src/cliproxy/quota/quota-manager.ts` | 954 |
|
||||
| `src/web-server/services/codex-dashboard-service.ts` | 940 |
|
||||
| `src/glmt/glmt-proxy.ts` | 939 |
|
||||
| `src/cliproxy/accounts/registry.ts` | 871 |
|
||||
| `src/channels/official-channels-runtime.ts` | 867 |
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
# Typed-Error Exit-Code Compat Audit (P4)
|
||||
# Typed-Error Exit-Code Compatibility Audit
|
||||
|
||||
Date: 2026-06-18. Phase 4 of the maintainability/traceability epic.
|
||||
Status: resolved contract.
|
||||
|
||||
## Question (open Q1)
|
||||
## Question
|
||||
|
||||
Are CCS CLI exit codes a documented public contract that users or CI scripts depend on? This determines whether migrating `throw new Error(...)` to typed errors (which changes the exit code) is safe.
|
||||
|
||||
## Finding
|
||||
|
||||
Typed exit codes are **already wired end-to-end**. `handleError` -> `getExitCode` extracts `error.code` from any `CCSError` and passes it to `process.exit` (`src/errors/error-handler.ts`, `src/ccs.ts:145`). The `ExitCode` enum and the class-to-code mapping in `src/errors/error-types.ts` are complete and pre-date this epic.
|
||||
Typed exit codes are wired end-to-end. `handleError` calls the local
|
||||
`getExitCode` helper and passes the resulting code to `process.exit` in
|
||||
`src/errors/error-handler.ts`. The CLI entry point delegates uncaught errors to
|
||||
`handleError` in `src/ccs.ts`. `src/errors/exit-codes.ts` and
|
||||
`src/errors/error-types.ts` define the stable mapping.
|
||||
|
||||
**Only documented public contract:** `ccs doctor` documents exit codes 0 (healthy) / 1 (unhealthy) in `src/commands/doctor-command.ts:55-58`. `ccs doctor` is OUTSIDE the P4 priority subdomains and is NOT touched by P4. Its 0/1 contract is preserved.
|
||||
`ccs doctor` separately documents exit codes 0 (healthy) and 1 (unhealthy) in
|
||||
`src/commands/doctor-command.ts`. Preserve that command contract.
|
||||
|
||||
**No CI/scripts assert on ccs exit codes.** `scripts/ci-parity-gate.sh` uses `set -euo pipefail` but performs no `ccs` exit-code branching. `.github/workflows/*` perform no ccs exit-code assertions. Prior exit-code changes in CHANGELOG are treated as bugfixes; no documented breaking changes.
|
||||
The typed taxonomy is enforced by
|
||||
`src/errors/__tests__/typed-error-migration-exit-codes.test.ts`; do not treat
|
||||
the mapping as an untested migration note.
|
||||
|
||||
## Decision
|
||||
|
||||
**Migrate freely** in the P4 priority subdomains (`cliproxy/quota`, `cliproxy/auth`, `web-server/routes`, `auth`). Preserve `GENERAL_ERROR(1)` only where no clear subclass applies. Behavior-lock tests assert the new typed codes.
|
||||
Use the most specific typed error in `cliproxy/quota`, `cliproxy/auth`,
|
||||
`web-server/routes`, and `auth`. Preserve `GENERAL_ERROR(1)` only where no clear
|
||||
subclass applies. Update the behavior-lock test with any intentional taxonomy
|
||||
change.
|
||||
|
||||
## Exit-code mapping (the contract this audit locks)
|
||||
|
||||
| Typed class | ExitCode | Value | Used for (P4 sites) |
|
||||
| Typed class | ExitCode | Value | Typical use |
|
||||
|---|---|---:|---|
|
||||
| `ProfileError` | `PROFILE_ERROR` | 7 | profile/account/variant not found, already exists |
|
||||
| `AuthError` | `AUTH_ERROR` | 4 | OAuth/token/Kiro/GitLab auth flow failures, refresh ownership |
|
||||
@@ -31,15 +41,5 @@ Typed exit codes are **already wired end-to-end**. `handleError` -> `getExitCode
|
||||
| `ProxyError` | `PROXY_ERROR` | 8 | |
|
||||
| `MigrationError` | `MIGRATION_ERROR` | 9 | |
|
||||
|
||||
## Per-site decisions
|
||||
|
||||
See the P4 commit for the full site list. Summary by subclass chosen:
|
||||
- `ProfileError`: profile/account not-found + already-exists (`src/auth/profile-registry.ts`, `src/cliproxy/auth/auth-token-manager.ts`, `src/cliproxy/auth/auth-types.ts`).
|
||||
- `AuthError`: OAuth start failed, paste-callback unavailable, Kiro auth method unsupported, token refresh ownership (`src/cliproxy/auth/oauth-handler.ts`, `provider-refreshers/index.ts`).
|
||||
- `ConfigError`: invalid settings path, settings not found, CLIProxy config not initialized, GitLab URL format, failed read/write profiles, copilot sync failure (`src/web-server/routes/*`, `src/cliproxy/auth/oauth-handler.ts`, `src/auth/profile-registry.ts`).
|
||||
- `ValidationError`: invalid profile name, invalid target, invalid host, Kiro IDC start-url (`src/web-server/routes/*`, `src/cliproxy/auth/auth-types.ts`).
|
||||
- `ProviderError`: unsupported provider backend (`src/web-server/routes/image-analysis-routes.ts`).
|
||||
|
||||
## Outcome
|
||||
|
||||
Typed-error adoption in the locked subdomains: 0/23 -> 21/23 (91.3%), well above the 40% target. Exit-code changes are intentional and documented here; release notes for the epic PR should mention the differentiated exit codes.
|
||||
Current adoption counts belong in the generated hardening inventory. Git history
|
||||
contains the completed migration narrative.
|
||||
@@ -1,216 +0,0 @@
|
||||
# Session Sharing Technical Analysis
|
||||
|
||||
Last Updated: 2026-05-05
|
||||
|
||||
## Summary
|
||||
|
||||
CCS supports practical cross-account continuity by sharing workspace context files between selected accounts, while keeping credentials isolated per account.
|
||||
|
||||
This is implemented as a context policy per account:
|
||||
|
||||
- `isolated` (default): account keeps its own workspace context
|
||||
- `shared` + `standard` (default): account workspace context is linked to a shared context group
|
||||
- `shared` + `deeper` (advanced opt-in): account also shares continuity artifacts
|
||||
|
||||
## Recommended Two-Account Route
|
||||
|
||||
Use `ccs auth` account profiles when you want two real Claude accounts and want to choose which one runs each session:
|
||||
|
||||
```bash
|
||||
ccs auth create work
|
||||
ccs auth create personal
|
||||
|
||||
ccs work
|
||||
ccs personal
|
||||
```
|
||||
|
||||
This keeps usage and credentials isolated. Each account owns its own Claude config directory, login state, and `.anthropic` credentials.
|
||||
|
||||
Shared Resources are separate from History Sync. By default, non-bare account profiles inherit Claude-local resources from native Claude:
|
||||
|
||||
```text
|
||||
~/.ccs/instances/<account>/settings.json
|
||||
-> ~/.ccs/shared/settings.json
|
||||
-> ~/.claude/settings.json
|
||||
```
|
||||
|
||||
This covers ordinary Claude Code `settings.json`, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show <account>` reports `Resources`, `Settings`, `History`, and `Plain ccs` lanes so users can see whether shared resources and resume history are aligned.
|
||||
|
||||
For existing accounts, change Shared Resources from the CLI:
|
||||
|
||||
```bash
|
||||
ccs auth resources work --mode profile-local
|
||||
ccs auth resources work --mode shared
|
||||
```
|
||||
|
||||
- `shared`: link plugins, commands, skills, agents, and `settings.json` from the shared Claude resource layout.
|
||||
- `profile-local`: detach those shared resources for the account. This is the existing `--bare` behavior exposed as an existing-account setting.
|
||||
|
||||
Only opt in to shared history when both accounts should see the same local continuity:
|
||||
|
||||
```bash
|
||||
ccs auth create work2 --share-context --context-group daily --deeper-continuity
|
||||
```
|
||||
|
||||
For existing History Sync, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context. History Sync does not control plugins or `settings.json`; use `ccs auth resources` for that.
|
||||
|
||||
## Why This Is Safe Enough
|
||||
|
||||
CCS only shares workspace context paths (project/session context files). It does **not** merge or copy authentication credentials between accounts.
|
||||
|
||||
Credential storage remains per account instance.
|
||||
|
||||
## Implementation Model
|
||||
|
||||
Account metadata is stored in `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
accounts:
|
||||
work:
|
||||
created: "2026-02-24T00:00:00.000Z"
|
||||
last_used: null
|
||||
shared_resource_mode: "shared"
|
||||
context_mode: "shared"
|
||||
context_group: "team-alpha"
|
||||
continuity_mode: "deeper"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `shared_resource_mode` controls commands, skills, agents, plugins, and `settings.json` (`shared` or `profile-local`)
|
||||
- `context_mode` must be `isolated` or `shared`
|
||||
- `context_group` is required when `context_mode=shared`
|
||||
- `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`)
|
||||
- group normalization: trim, lowercase, internal spaces -> `-`
|
||||
- group must start with a letter and only include `[a-zA-Z0-9_-]`
|
||||
- max length: `64`
|
||||
|
||||
Deeper continuity links these directories per context group:
|
||||
|
||||
- `session-env`
|
||||
- `file-history`
|
||||
- `shell-snapshots`
|
||||
- `todos`
|
||||
|
||||
`.anthropic` and account credentials remain isolated.
|
||||
|
||||
## Cross-Profile Inheritance (API / CLIProxy / Copilot)
|
||||
|
||||
You can explicitly map non-account profiles (including `default`) to reuse continuity artifacts from an account profile:
|
||||
|
||||
```yaml
|
||||
continuity:
|
||||
inherit_from_account:
|
||||
glm: pro
|
||||
gemini: pro
|
||||
copilot: pro
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Applies only when running Claude target (`ccs <profile>` or `--target claude`)
|
||||
- Does not change provider credentials or API routing
|
||||
- Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution
|
||||
- Invalid/missing mapped accounts are skipped safely
|
||||
|
||||
### Resume Lane Note
|
||||
|
||||
Resume follows the active `CLAUDE_CONFIG_DIR`, not just the continuity group:
|
||||
|
||||
- plain `ccs -r` resumes the lane plain `ccs` is using right now
|
||||
- `ccs <account> -r` resumes only that account lane
|
||||
- those two commands can point at different continuity inventories
|
||||
|
||||
That means `shared + deeper` on an account does **not** automatically make old plain-`ccs` resume history appear inside `ccs <account> -r`.
|
||||
|
||||
If you want future plain `ccs` sessions to use an account lane, either:
|
||||
|
||||
```bash
|
||||
ccs auth default work
|
||||
```
|
||||
|
||||
or map the default profile explicitly:
|
||||
|
||||
```yaml
|
||||
continuity:
|
||||
inherit_from_account:
|
||||
default: work
|
||||
```
|
||||
|
||||
Example with an existing `ck` account:
|
||||
|
||||
```bash
|
||||
ccs auth show ck
|
||||
ccs auth backup default
|
||||
ccs auth default ck
|
||||
```
|
||||
|
||||
`ccs auth default ck` makes future plain `ccs` sessions use the `ck` account lane, so future `ccs` and `ccs ck` resume from the same local inventory. It does not automatically import old native `~/.claude/projects` history into `ck`; keep using `ccs -r` for the old native lane until you intentionally migrate that local history.
|
||||
|
||||
## User Workflows
|
||||
|
||||
### New account with shared context
|
||||
|
||||
```bash
|
||||
ccs auth create work2 --share-context
|
||||
ccs auth create backup --share-context --context-group sprint-a
|
||||
ccs auth create backup2 --share-context --context-group sprint-a --deeper-continuity
|
||||
```
|
||||
|
||||
### Existing account
|
||||
|
||||
History Sync:
|
||||
|
||||
- Open `ccs config`
|
||||
- Go to `Accounts`
|
||||
- Click the pencil icon (`Edit History Sync`)
|
||||
- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity
|
||||
|
||||
Shared Resources:
|
||||
|
||||
```bash
|
||||
ccs auth resources work --mode profile-local
|
||||
ccs auth resources work --mode shared
|
||||
```
|
||||
|
||||
Dashboard:
|
||||
|
||||
- Open `ccs config`
|
||||
- Go to `Accounts`
|
||||
- Use `Resources` to switch an existing account between `shared` and `profile-local`
|
||||
- Go to `Shared Resources` to inspect the shared commands, skills, agents, plugins, and `settings.json` hub
|
||||
|
||||
No account recreation required for this workflow.
|
||||
|
||||
### Backup Before Changing Sync
|
||||
|
||||
CCS can back up local continuity artifacts before you change settings:
|
||||
|
||||
```bash
|
||||
ccs auth backup work
|
||||
ccs auth backup default
|
||||
```
|
||||
|
||||
- `ccs auth backup work` backs up the selected account lane
|
||||
- `ccs auth backup default` backs up the lane plain `ccs` would use right now
|
||||
- this is a local continuity backup, not a guaranteed export of all upstream Claude-hosted resume state
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- Shared context is local filesystem sharing. It does not bypass remote provider permission models.
|
||||
- Session continuity still depends on what the upstream tool/provider stores and allows.
|
||||
- Context sharing should only be enabled for accounts you intentionally trust to share workspace history.
|
||||
- Shared Resources inspection is read-only in the dashboard. Editing individual files still belongs to the owning command, skill, plugin, or settings surface.
|
||||
|
||||
## Alternative: CLIProxy Claude Pool
|
||||
|
||||
For users who prefer lower manual account switching, use CLIProxy Claude pool instead:
|
||||
|
||||
- Authenticate pool accounts via `ccs cliproxy auth claude`
|
||||
- Manage account pool behavior in `ccs config` -> `CLIProxy Plus`
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- Confirm account row shows `shared (<group>)` in Dashboard Accounts table
|
||||
- Switch between accounts in the same group and verify workspace continuity
|
||||
- Run `ccs doctor` if symlink/context health looks inconsistent
|
||||
+211
-430
@@ -1,465 +1,246 @@
|
||||
# CCS System Architecture
|
||||
|
||||
Last Updated: 2026-04-14
|
||||
CCS separates profile resolution, provider routing, and target execution. This
|
||||
document describes stable boundaries; source registries and tests own mutable
|
||||
provider and command inventories.
|
||||
|
||||
High-level architecture overview for the CCS (Claude Code Switch) system.
|
||||
## System context
|
||||
|
||||
---
|
||||
|
||||
## System Overview
|
||||
|
||||
CCS is a multi-provider profile and runtime manager that enables seamless switching between multiple Claude accounts, alternative AI providers, and multiple CLI targets (Claude Code, Factory Droid, Codex CLI) for credential delivery.
|
||||
|
||||
The system consists of two main components:
|
||||
|
||||
1. **CLI Application** (`src/`) - Node.js TypeScript CLI
|
||||
2. **Dashboard UI** (`ui/`) - React web application served by Express
|
||||
|
||||
Dashboard localization (i18n) architecture and contributor workflow are documented in [Dashboard i18n Guide](../i18n-dashboard.md).
|
||||
|
||||
CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy with automatic injection for all profile types.
|
||||
CCS v7.67 adds a native structured logging lane for CCS-owned runtime events, backed by `src/services/logging/`, bounded JSONL files under `~/.ccs/logs/`, and a dedicated dashboard `/logs` route.
|
||||
CCS PR review now uses PR-Agent in GitHub Actions, with reviews running on the self-hosted `cliproxy` runner, existing `AI_REVIEW_*` workflow variables and secrets preserved as the runtime contract, and repo-level guidance stored in `.pr_agent.toml`.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CCS System |
|
||||
+===========================================================================+
|
||||
| |
|
||||
| +------------------+ +-----------------+ +----------------+ |
|
||||
| | User Terminal | ---> | CCS CLI | ---> | Target CLI | |
|
||||
| | (ccs command) | | (src/ccs.ts) | | (claude/droid/codex) | |
|
||||
| +------------------+ +-----------------+ +----------------+ |
|
||||
| | | |
|
||||
| v v |
|
||||
| +------------------+ +-----------------+ +----------------+ |
|
||||
| | Dashboard UI | <--> | Express | ---> | Provider APIs | |
|
||||
| | (React SPA) | | Web Server | | (Claude/GLM/ | |
|
||||
| +------------------+ +-----------------+ | Gemini/etc) | |
|
||||
| | +----------------+ |
|
||||
| v |
|
||||
| +---------------------+ |
|
||||
| | CLIProxyAPI | |
|
||||
| | (Local or Remote) | |
|
||||
| +---------------------+ |
|
||||
| |
|
||||
+===========================================================================+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### Multi-Target Adapter System
|
||||
|
||||
CCS v7.45 introduces the Target Adapter pattern, enabling seamless integration with different CLI implementations.
|
||||
|
||||
**Key architecture:**
|
||||
|
||||
```
|
||||
Profile Resolution (CLIProxy, Settings/API, Account-based)
|
||||
```text
|
||||
User or automation
|
||||
|
|
||||
v
|
||||
Target Resolution (--target flag > runtime entrypoint / argv[0] > config > default)
|
||||
|
|
||||
v
|
||||
Get Target Adapter (Claude, Droid, or Codex)
|
||||
|
|
||||
+---> detectBinary() (find CLI on system)
|
||||
|
|
||||
+---> prepareCredentials() (write config or set env)
|
||||
|
|
||||
+---> buildArgs() (construct CLI arguments)
|
||||
|
|
||||
+---> buildEnv() (prepare environment variables)
|
||||
|
|
||||
v
|
||||
Spawn Target Process
|
||||
CCS CLI ------------------------> Target CLI
|
||||
| |
|
||||
| profile/provider state | provider protocol
|
||||
v v
|
||||
CCS local server <-----------> CLIProxy or direct API
|
||||
|
|
||||
v
|
||||
React dashboard
|
||||
```
|
||||
|
||||
**Each target adapter implements different credential delivery:**
|
||||
The main implementation surfaces are:
|
||||
|
||||
- **Claude Adapter**: Env var delivery (existing behavior)
|
||||
- `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`
|
||||
- No config files needed
|
||||
| Surface | Ownership |
|
||||
| --- | --- |
|
||||
| `src/` | CLI, dispatch, server, provider integration, target adapters |
|
||||
| `ui/src/` | Dashboard application |
|
||||
| `dist/` and `dist/ui/` | Build outputs |
|
||||
| `docker/` | Integrated and legacy container definitions |
|
||||
| `tests/` | Unit, integration, end-to-end, native, and Docker contracts |
|
||||
|
||||
- **Droid Adapter**: Config file delivery to `~/.factory/settings.json`
|
||||
- Writes custom model entry: `custom:ccs-<profile>`
|
||||
- Spawns: `droid -m custom:ccs-<profile> <args>`
|
||||
- Model config includes baseUrl, apiKey, provider
|
||||
Dashboard localization is documented in the
|
||||
[Dashboard i18n Guide](../i18n-dashboard.md).
|
||||
|
||||
- **Codex Adapter**: Transient runtime overrides plus user-layer dashboard inspection
|
||||
- Uses `codex -c key=value` only for CCS-routed launches
|
||||
- Preserves native `~/.codex/config.toml` ownership
|
||||
- Dashboard page reads/writes only the user config layer with explicit runtime-vs-provider warnings
|
||||
## Execution pipeline
|
||||
|
||||
**Runtime entrypoints (built-in bins) and argv[0]-style aliases:**
|
||||
|
||||
```
|
||||
ccs → Target: claude (default)
|
||||
ccs-droid → Target: droid (explicit alias)
|
||||
ccsd → Target: droid (legacy shortcut)
|
||||
ccs-codex → Target: codex (explicit alias)
|
||||
ccsx → Target: codex (short alias)
|
||||
ccsxp → Target: codex (native cliproxy shortcut; prepends `--config model_provider="cliproxy"`)
|
||||
```
|
||||
|
||||
For details on the adapter architecture, see [Target Adapters](./target-adapters.md).
|
||||
|
||||
### CLI Layer
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CLI Architecture |
|
||||
+===========================================================================+
|
||||
|
||||
User Input (ccs [--target <cli>] <profile> [args])
|
||||
|
|
||||
v
|
||||
+-------------+
|
||||
| ccs.ts | Entry point, command routing
|
||||
+-------------+
|
||||
|
|
||||
+---> [Version/Help/Doctor/etc.] ---> Exit
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Target Resolution | Determine which CLI to use
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+-------------+
|
||||
| Profile | Determines execution path
|
||||
| Detection |
|
||||
+-------------+
|
||||
|
|
||||
+---> [Native Claude Account] ---> execClaude()
|
||||
| |
|
||||
+---> [CLIProxy Provider] ---> execClaudeWithCLIProxy()
|
||||
| |
|
||||
+---> [Settings/API Profile] ---> normalize legacy glmt if needed
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Target Adapter | Get appropriate adapter
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Prepare Creds | Deliver credentials
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Target CLI | Claude Code or Droid
|
||||
+------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Architecture
|
||||
|
||||
### CLI Execution Flow
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CLI Execution Flow |
|
||||
+===========================================================================+
|
||||
|
||||
1. Parse Arguments
|
||||
|
|
||||
v
|
||||
2. Resolve Target Type
|
||||
|
|
||||
v
|
||||
3. Detect Profile Type
|
||||
|
|
||||
+---> Native Claude ---> 3a. Load Account Settings
|
||||
| |
|
||||
| v
|
||||
| 4a. Set CLAUDE_CONFIG_DIR
|
||||
| |
|
||||
| v
|
||||
| 5a. Get Claude Target Adapter
|
||||
|
|
||||
+---> CLIProxy -------> 3b. Ensure Binary Installed
|
||||
| |
|
||||
| v
|
||||
| 4b. Generate Config
|
||||
| |
|
||||
| v
|
||||
| 5b. Resolve Target Adapter
|
||||
| |
|
||||
| v
|
||||
| 6b. Prepare Credentials
|
||||
| |
|
||||
| v
|
||||
| 7b. Spawn via Adapter
|
||||
|
|
||||
+---> Settings/API ---> 3c. Load settings env
|
||||
|
|
||||
v
|
||||
4c. Normalize legacy glmt if needed
|
||||
|
|
||||
v
|
||||
5c. Resolve Target Adapter
|
||||
|
|
||||
v
|
||||
6c. Spawn via Adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider Integration Architecture
|
||||
|
||||
For detailed provider flows (CLIProxyAPI, legacy GLMT compatibility, quota management), see [Provider Flows](./provider-flows.md).
|
||||
|
||||
---
|
||||
|
||||
## Configuration Architecture
|
||||
|
||||
### CCS Logging Architecture
|
||||
|
||||
- Shared logging contract lives in `src/services/logging/` and is used for CCS-owned runtime diagnostics, request tracing, and bounded recent-entry reads.
|
||||
- Config lives at top-level `logging.*` in `~/.ccs/config.yaml`; `cliproxy.logging.*` still controls upstream CLIProxy runtime files only.
|
||||
- CCS-owned runtime logs write to `~/.ccs/logs/current.jsonl` and rotate into `~/.ccs/logs/archive/` based on policy.
|
||||
- Dashboard exposure uses native `/api/logs/config`, `/api/logs/sources`, and `/api/logs/entries` endpoints plus the `System -> Logs` React page.
|
||||
- Request logging explicitly skips `/api/logs` reads so the log viewer does not recursively log itself.
|
||||
|
||||
### Config File Hierarchy
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Configuration Hierarchy |
|
||||
+===========================================================================+
|
||||
|
||||
~/.ccs/
|
||||
```text
|
||||
Parse command
|
||||
|
|
||||
+---> config.yaml # Main CCS config (unified)
|
||||
Resolve command vs launch
|
||||
|
|
||||
+---> profiles.json # Claude account registry
|
||||
Resolve target
|
||||
|
|
||||
+---> <profile>.settings.json # Per-profile settings
|
||||
Resolve profile type
|
||||
+-- account profile ------> isolated target config root
|
||||
+-- settings profile -----> profile environment
|
||||
+-- CLIProxy provider ----> local or remote proxy route
|
||||
|
|
||||
+---> cliproxy/
|
||||
| |
|
||||
| +---> config.yaml # CLIProxy configuration
|
||||
| +---> auth/ # OAuth tokens
|
||||
| +---> bin/ # CLIProxy binary
|
||||
Prepare target credentials
|
||||
|
|
||||
+---> shared/ # Symlinked resources
|
||||
|
|
||||
+---> commands/ # Claude Code commands
|
||||
+---> skills/ # Custom skills
|
||||
+---> agents/ # Agent configurations
|
||||
+---> plugins/
|
||||
|
|
||||
+---> cache/ # Shared plugin payload/cache data
|
||||
+---> marketplaces/ # Shared marketplace payload directories
|
||||
+---> installed_plugins.json
|
||||
|
||||
~/.ccs/instances/<profile>/
|
||||
|
|
||||
+---> plugins/
|
||||
|
|
||||
+---> known_marketplaces.json # Instance-local registry for active CLAUDE_CONFIG_DIR validation
|
||||
|
||||
~/.factory/ (Droid CLI)
|
||||
|
|
||||
+---> settings.json # Droid config (custom models)
|
||||
Spawn target and forward lifecycle signals
|
||||
```
|
||||
|
||||
Plugin ownership note:
|
||||
- `commands/`, `skills/`, `agents/`, and `settings.json` remain shared through the existing symlink/copy flow.
|
||||
- Marketplace payload directories stay shared, but `known_marketplaces.json` is reconciled per instance so Claude Code can validate `installLocation` against that instance's `CLAUDE_CONFIG_DIR/plugins/marketplaces`.
|
||||
Command routing stops before profile execution for management commands such as
|
||||
configuration, diagnostics, proxy management, and environment export.
|
||||
|
||||
### Config Loading Order
|
||||
### Profile resolution
|
||||
|
||||
```
|
||||
1. Environment Variables (highest priority)
|
||||
|
|
||||
v
|
||||
2. CLI Arguments (including --target)
|
||||
|
|
||||
v
|
||||
3. Profile-specific settings (~/.ccs/<profile>.settings.json)
|
||||
|
|
||||
v
|
||||
4. Main config (~/.ccs/config.yaml)
|
||||
|
|
||||
v
|
||||
5. Default values (lowest priority)
|
||||
Profile resolution distinguishes:
|
||||
|
||||
1. built-in CLIProxy provider shortcuts;
|
||||
2. user-defined CLIProxy profiles;
|
||||
3. settings/API profiles; and
|
||||
4. registered account profiles.
|
||||
|
||||
Canonical provider IDs and aliases come from
|
||||
[`src/cliproxy/provider-capabilities.ts`](../../src/cliproxy/provider-capabilities.ts).
|
||||
The detector and dispatcher consume those registries; documentation must not
|
||||
maintain a second provider list.
|
||||
|
||||
### Target resolution
|
||||
|
||||
Provider and target are independent axes. The selected target is resolved from
|
||||
explicit flags and runtime entry points before falling back to configuration
|
||||
and the default target.
|
||||
|
||||
Each adapter owns credential delivery:
|
||||
|
||||
- **Claude Code:** launch environment and optional isolated
|
||||
`CLAUDE_CONFIG_DIR`;
|
||||
- **Factory Droid:** CCS-managed custom-model entries in
|
||||
`~/.factory/settings.json`; and
|
||||
- **Codex CLI:** transient `-c` overrides for CCS-routed launches while native
|
||||
user configuration remains separately owned.
|
||||
|
||||
See [Target Adapters](./target-adapters.md) for the detailed compatibility
|
||||
contract.
|
||||
|
||||
## Provider routing
|
||||
|
||||
CCS supports three routing boundaries:
|
||||
|
||||
| Route | Credential and transport owner |
|
||||
| --- | --- |
|
||||
| Direct settings/API profile | Target receives the selected provider's environment |
|
||||
| Local CLIProxy | CCS manages a local proxy binary, config, and auth directory |
|
||||
| Remote CLIProxy | CCS connects to the configured remote service and applies the selected fallback policy |
|
||||
|
||||
Local backend choice is explicit. `original` is the default. `plus` is an
|
||||
opt-in backend for provider capabilities unavailable in the original backend.
|
||||
Compatibility restrictions are defined in
|
||||
[`src/cliproxy/types/provider-types.ts`](../../src/cliproxy/types/provider-types.ts)
|
||||
and enforced before local execution.
|
||||
|
||||
See [Provider Flows](./provider-flows.md).
|
||||
|
||||
## Configuration ownership
|
||||
|
||||
The effective CCS directory is resolved by
|
||||
[`src/utils/config-manager.ts`](../../src/utils/config-manager.ts). The normal
|
||||
default is `~/.ccs`; tests and scoped workflows can override it.
|
||||
|
||||
```text
|
||||
CCS directory
|
||||
├── config.yaml
|
||||
├── profiles.json
|
||||
├── <profile>.settings.json
|
||||
├── instances/
|
||||
├── logs/
|
||||
└── cliproxy/
|
||||
├── config.yaml
|
||||
├── auth/
|
||||
└── bin/
|
||||
```
|
||||
|
||||
---
|
||||
### Settings-write contract
|
||||
|
||||
## WebSocket Architecture
|
||||
Normal launches do not rewrite shared Claude settings. API profiles store
|
||||
string-valued launch environment in CCS-owned per-profile settings.
|
||||
|
||||
### Real-time Communication
|
||||
Persistent shared configuration is explicit:
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| WebSocket Communication |
|
||||
+===========================================================================+
|
||||
- `ccs persist` reads and validates `~/.claude/settings.json`;
|
||||
- it refuses unsafe symlink targets;
|
||||
- it preserves unrelated settings while updating the requested managed fields;
|
||||
- it creates a backup when an existing file is present; and
|
||||
- it writes the replacement atomically under a settings-directory lock.
|
||||
|
||||
Dashboard (React) Server (Express)
|
||||
| |
|
||||
|<------ Connection Established ------>|
|
||||
| |
|
||||
|<------ health:update ----------------| Health status
|
||||
| |
|
||||
|<------ auth:status ------------------| Auth changes
|
||||
| |
|
||||
|<------ usage:update -----------------| Usage stats
|
||||
| |
|
||||
|------- action:refresh -------------->| User requests
|
||||
| |
|
||||
Target-owned writers follow their own boundary. For example, the Droid adapter
|
||||
manages CCS custom-model entries in `~/.factory/settings.json`, not arbitrary
|
||||
user settings.
|
||||
|
||||
## Local server and dashboard
|
||||
|
||||
The Express server exposes APIs used by the React dashboard for supported
|
||||
configuration, auth, usage, health, and logging workflows. The dashboard is a
|
||||
management surface over shared services; it must not implement a competing
|
||||
configuration model.
|
||||
|
||||
Real-time updates use server-owned WebSocket messages. Event names and payloads
|
||||
are code contracts and should be read from the server and UI implementations
|
||||
rather than copied into this overview.
|
||||
|
||||
## Logging
|
||||
|
||||
CCS-owned structured runtime logging lives under `src/services/logging/`.
|
||||
Top-level `logging.*` configuration controls CCS JSONL logs under the CCS
|
||||
directory. `cliproxy.logging.*` controls upstream CLIProxy files and is a
|
||||
separate contract.
|
||||
|
||||
The dashboard log reader excludes its own log-read requests from request
|
||||
logging to prevent recursive noise. See [Logging Contract](../logging-contract.md).
|
||||
|
||||
## Managed tool preparation
|
||||
|
||||
WebSearch and image analysis are prepared before the target launch when the
|
||||
selected profile needs CCS-managed tooling. They use provider-aware routes but
|
||||
have different failure contracts: enabled third-party WebSearch fails closed if
|
||||
its managed MCP replacement cannot be prepared, while image analysis can use a
|
||||
compatible native path when available.
|
||||
|
||||
- [WebSearch](../websearch.md)
|
||||
- [Provider Flows](./provider-flows.md)
|
||||
|
||||
## Security and trust boundaries
|
||||
|
||||
### Local host
|
||||
|
||||
CCS reads and writes user-authorized configuration and starts target processes.
|
||||
That local filesystem access is more privileged than a provider API request.
|
||||
Sensitive values must not enter logs or dashboard responses.
|
||||
|
||||
### Local proxy
|
||||
|
||||
The host CLI uses loopback for locally managed CLIProxy traffic. Local auth
|
||||
files and the management API remain sensitive even when the transport never
|
||||
leaves the machine.
|
||||
|
||||
### Remote proxy
|
||||
|
||||
A remote CLIProxy crosses a network and administrative boundary. TLS,
|
||||
authentication, certificate policy, reachability, and local fallback are
|
||||
explicit configuration choices. Remote-only mode must not silently start a
|
||||
local proxy.
|
||||
|
||||
### Container deployment
|
||||
|
||||
The integrated container exposes dashboard and proxy ports through the
|
||||
operator's port mappings. The image therefore does not inherit the host
|
||||
installation's loopback-only assumption. Network exposure and access control
|
||||
belong to the deployment operator.
|
||||
|
||||
## Build and distribution
|
||||
|
||||
```text
|
||||
src/ -------- TypeScript --------> dist/
|
||||
ui/src/ ----- Vite --------------> dist/ui/
|
||||
|
|
||||
v
|
||||
npm package
|
||||
|
|
||||
v
|
||||
integrated Docker image
|
||||
```
|
||||
|
||||
---
|
||||
The package requires Node.js 18 or newer. Repository development supports Bun
|
||||
1.0 or newer. CI can pin newer tool versions independently; those workflow pins
|
||||
are not the minimum consumer runtime contract.
|
||||
|
||||
## Security Architecture
|
||||
The integrated Docker image is built from
|
||||
[`docker/Dockerfile.integrated`](../../docker/Dockerfile.integrated). It layers
|
||||
CCS onto a digest-pinned CLIProxy base, runs CLIProxy and the dashboard under
|
||||
supervision, and health-checks both services. It does not bundle Claude Code,
|
||||
Gemini CLI, Codex CLI, Droid, or other target CLIs.
|
||||
|
||||
### Authentication Flow
|
||||
Release lane details are in [Release Process](../release-process.md).
|
||||
|
||||
See [Provider Flows](./provider-flows.md) → Authentication Flow section.
|
||||
## Architecture invariants
|
||||
|
||||
### Security Boundaries
|
||||
- Provider identity comes from the provider registry.
|
||||
- Target compatibility is enforced at the adapter boundary.
|
||||
- Environment values persisted in settings are strings.
|
||||
- Shared target configuration changes require an explicit workflow.
|
||||
- Local and remote proxy modes do not silently cross trust boundaries.
|
||||
- Dashboard and CLI use the same domain services and configuration schema.
|
||||
- Volatile capability details remain source-owned.
|
||||
|
||||
```
|
||||
+------------------+
|
||||
| User Terminal |
|
||||
+------------------+
|
||||
|
|
||||
| Local only (no network exposure)
|
||||
v
|
||||
+------------------+
|
||||
| CCS CLI |
|
||||
+------------------+
|
||||
|
|
||||
| Localhost only (127.0.0.1)
|
||||
v
|
||||
+------------------+
|
||||
| CLIProxy/Legacy | Binds to localhost only
|
||||
+------------------+
|
||||
|
|
||||
| TLS encrypted
|
||||
v
|
||||
+------------------+
|
||||
| Target CLI | Spawned locally (claude/droid)
|
||||
+------------------+
|
||||
|
|
||||
| TLS encrypted
|
||||
v
|
||||
+------------------+
|
||||
| Provider APIs | External endpoints
|
||||
+------------------+
|
||||
```
|
||||
## Related documentation
|
||||
|
||||
---
|
||||
|
||||
## Build and Distribution
|
||||
|
||||
### Build Pipeline
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Build Pipeline |
|
||||
+===========================================================================+
|
||||
|
||||
src/ (TypeScript) ui/src/ (React TSX)
|
||||
| |
|
||||
v v
|
||||
TypeScript Compiler Vite Build
|
||||
| |
|
||||
v v
|
||||
dist/ (JavaScript) dist/ui/ (Static assets)
|
||||
| |
|
||||
+---------------+---------------------+
|
||||
|
|
||||
v
|
||||
npm package (@kaitranntt/ccs)
|
||||
|
|
||||
v
|
||||
npm registry / GitHub releases
|
||||
```
|
||||
|
||||
### Package Contents
|
||||
|
||||
```
|
||||
@kaitranntt/ccs
|
||||
|
|
||||
+---> dist/ # Compiled CLI
|
||||
+---> dist/ui/ # Built dashboard
|
||||
+---> lib/ # Native scripts
|
||||
| +---> ccs # Bash bootstrap
|
||||
| +---> ccs.ps1 # PowerShell bootstrap
|
||||
+---> package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Local Installation
|
||||
|
||||
```
|
||||
npm install -g @kaitranntt/ccs
|
||||
|
|
||||
v
|
||||
Global node_modules
|
||||
|
|
||||
+---> Creates symlink: ccs --> dist/ccs.js
|
||||
|
|
||||
+---> Runtime aliases: ccs-droid / ccsd → ccs (auto-select droid target)
|
||||
|
|
||||
+---> First run creates: ~/.ccs/
|
||||
```
|
||||
|
||||
### PR Review Lane
|
||||
|
||||
Automated pull request review stays in `.github/workflows/ai-review.yml`, but the workflow now runs PR-Agent instead of the old Claude action. Reviews run on the existing self-hosted `cliproxy` runner, while the workflow preserves the existing `AI_REVIEW_BASE_URL`, `AI_REVIEW_MODEL`, and `AI_REVIEW_API_KEY` contract by mapping those values into PR-Agent env keys such as `OPENAI.*`, `config.*`, and `github_action_config.*`. Repo-specific reviewer guidance lives in `.pr_agent.toml`.
|
||||
|
||||
```
|
||||
GitHub Actions `ai-review.yml`
|
||||
|
|
||||
v
|
||||
Self-hosted `cliproxy` runner
|
||||
|
|
||||
v
|
||||
PR-Agent action
|
||||
|
|
||||
v
|
||||
CLIProxy
|
||||
|
|
||||
v
|
||||
Configured model from `.pr_agent.toml`
|
||||
```
|
||||
|
||||
- `ai-review.yml` owns automation wiring such as runner selection, PR-Agent action usage, and runtime values mapped from `AI_REVIEW_*` into `OPENAI.*`, `config.*`, and `github_action_config.*`.
|
||||
- `.pr_agent.toml` in the repo root owns review instructions for this repository.
|
||||
- Contributors should treat PR-Agent comments and trusted `/review` reruns as the primary AI review path for PRs targeting CCS.
|
||||
|
||||
### Runtime Dependencies
|
||||
|
||||
```
|
||||
+------------------+ +------------------+
|
||||
| Node.js 14+ | | Claude CLI |
|
||||
| (required) | | (required) |
|
||||
+------------------+ +------------------+
|
||||
|
||||
+------------------+ +------------------+
|
||||
| CLIProxyAPI | | Droid CLI |
|
||||
| (auto-managed) | | (optional) |
|
||||
+------------------+ +------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Codebase Summary](../codebase-summary.md) - Detailed directory structure
|
||||
- [Code Standards](../code-standards.md) - Coding conventions & patterns
|
||||
- [Target Adapters](./target-adapters.md) - Multi-CLI adapter architecture
|
||||
- [Provider Flows](./provider-flows.md) - CLIProxy, legacy GLMT compatibility, authentication flows
|
||||
- [Project Roadmap](../project-roadmap.md) - Development phases
|
||||
- [Codebase Summary](../codebase-summary.md)
|
||||
- [Code Standards](../code-standards.md)
|
||||
- [Target Adapters](./target-adapters.md)
|
||||
- [Provider Flows](./provider-flows.md)
|
||||
- [Release Process](../release-process.md)
|
||||
- [Project Roadmap](../project-roadmap.md)
|
||||
@@ -1,641 +1,247 @@
|
||||
# Provider Integration Flows
|
||||
|
||||
Last Updated: 2026-07-16
|
||||
CCS routes profiles through direct target configuration or CLIProxy. This
|
||||
document describes the durable flow and trust boundaries. Mutable provider
|
||||
capabilities are linked to their source registries.
|
||||
|
||||
Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication.
|
||||
## Sources of truth
|
||||
|
||||
---
|
||||
| Detail | Authoritative source |
|
||||
| --- | --- |
|
||||
| Canonical provider IDs, aliases, auth-flow type, callback ports, refresh ownership | [`src/cliproxy/provider-capabilities.ts`](../../src/cliproxy/provider-capabilities.ts) |
|
||||
| Original vs Plus backend restrictions | [`src/cliproxy/types/provider-types.ts`](../../src/cliproxy/types/provider-types.ts) |
|
||||
| Provider/backend enforcement | [`src/cliproxy/services/variant-service.ts`](../../src/cliproxy/services/variant-service.ts) |
|
||||
| Remote proxy precedence and fallback | [`src/cliproxy/proxy/proxy-config-resolver.ts`](../../src/cliproxy/proxy/proxy-config-resolver.ts) |
|
||||
| Provider capability regression tests | [`src/cliproxy/__tests__/provider-capabilities.test.ts`](../../src/cliproxy/__tests__/provider-capabilities.test.ts) |
|
||||
|
||||
## CLIProxyAPI Flow
|
||||
Do not copy provider inventories, callback ports, or quota-supported lists into
|
||||
this document. They change independently of the architecture.
|
||||
|
||||
### Overview
|
||||
## Route selection
|
||||
|
||||
CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with multiple AI providers. CCS manages the binary and configuration automatically.
|
||||
|
||||
### Local Backend Choice
|
||||
|
||||
CCS defaults to the original `router-for-me/CLIProxyAPI` backend because it is the stable MIT upstream. The `plus` backend is an explicit opt-in path that downloads the community-maintained `kaitranntt/CLIProxyAPIPlus` fork for providers that still require Plus-only support, such as Kiro, Cursor, GitLab, CodeBuddy, Kilo, and deprecated GitHub Copilot compatibility. CCS does not silently downgrade `backend: plus` to `original`; users choose that backend deliberately when they need those providers.
|
||||
|
||||
Generated local CLIProxy configs also keep the management dashboard aligned with the selected backend. `backend: original` uses upstream CPAMC (`router-for-me/Cli-Proxy-API-Management-Center`), while `backend: plus` uses the CCS-maintained dashboard fork (`kaitranntt/Cli-Proxy-API-Management-Center`). Advanced users can override the generated `remote-management.panel-github-repository` value by setting `cliproxy.management_panel_repository` in `~/.ccs/config.yaml`; CCS will regenerate stale local CLIProxy configs when the expected dashboard repository changes.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CLIProxyAPI Integration |
|
||||
+===========================================================================+
|
||||
|
||||
Claude CLI
|
||||
|
|
||||
| ANTHROPIC_BASE_URL = localhost:XXXX
|
||||
v
|
||||
+------------------+
|
||||
| CLIProxyAPI | Local proxy binary (Plus fork opt-in for plus-only providers)
|
||||
| (binary) |
|
||||
+------------------+
|
||||
|
|
||||
+---> OAuth Authentication
|
||||
| |
|
||||
| +---> Authorization Code Flow (port-based)
|
||||
| | - Gemini, Codex, Antigravity, iFlow, Claude, GitLab
|
||||
| | - Opens browser for user auth
|
||||
| | - Callback to localhost:PORT
|
||||
| |
|
||||
| +---> Device Code Flow (no port needed)
|
||||
| - xAI/Grok, Kimi, Kiro, Copilot, CodeBuddy, Kilo, and Qoder
|
||||
| - User enters a code on the provider verification page
|
||||
| - Polls for token completion
|
||||
| |
|
||||
| +---> Browser URL Polling (no callback port)
|
||||
| - Cursor
|
||||
| - Opens provider login URL returned by CLIProxyAPIPlus
|
||||
| - Polls auth state until token is saved
|
||||
| |
|
||||
| v
|
||||
| +------------------+
|
||||
| | OAuth Server | Browser-based auth
|
||||
| +------------------+
|
||||
|
|
||||
+---> Request Transformation
|
||||
| |
|
||||
| v
|
||||
| Anthropic Format --> Provider Format
|
||||
|
|
||||
+---> Image Analysis Hook (v7.34)
|
||||
| |
|
||||
| v
|
||||
| Vision Model Proxying (gemini, codex, agy, clipproxy)
|
||||
| - Auto-injected via claude-hooks
|
||||
| - Skip for Claude Sub accounts (native vision)
|
||||
| - Fallback with deprecated block-image-read
|
||||
|
|
||||
+---> Provider APIs
|
||||
```text
|
||||
Resolved profile
|
||||
|
|
||||
+-- account profile
|
||||
| └── target-native authenticated context
|
||||
|
|
||||
+-- settings/API profile
|
||||
| └── direct or compatible upstream URL and credential environment
|
||||
|
|
||||
└-- CLIProxy provider
|
||||
|
|
||||
+-- reachable configured remote proxy
|
||||
|
|
||||
└-- local proxy, when selected or fallback is allowed
|
||||
|
|
||||
+---> Google (Gemini)
|
||||
+---> OpenAI (Codex)
|
||||
+---> xAI (Grok)
|
||||
+---> Antigravity (AGY)
|
||||
+---> AWS Kiro (Claude-powered)
|
||||
+---> GitHub Copilot (ghcp, deprecated compatibility)
|
||||
+---> OpenAI-compatible endpoints
|
||||
└── original or Plus backend
|
||||
```
|
||||
|
||||
### Supported Built-In Providers
|
||||
The provider route is resolved before the target adapter prepares credentials.
|
||||
This keeps provider selection independent from whether Claude Code, Droid, or
|
||||
Codex receives the route.
|
||||
|
||||
| Provider | ID | Auth Method | Callback Port | Backend |
|
||||
|----------|----|----|------|--------|
|
||||
| Gemini | `gemini` | Authorization Code | 8085 | Original |
|
||||
| Codex | `codex` | Authorization Code | 1455 | Original |
|
||||
| xAI (Grok) | `xai` (`grok` CLI alias) | Device Code | none | Original |
|
||||
| Antigravity | `agy` | Authorization Code | 51121 | Original |
|
||||
| Qwen | `qwen` | Account linking unavailable in bundled runtime | none | Original |
|
||||
| iFlow | `iflow` | Authorization Code | 11451 | Original |
|
||||
| Claude | `claude` | Authorization Code | 54545 | Original |
|
||||
| Kimi | `kimi` | Device Code | none | Original |
|
||||
| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | none by default | Plus |
|
||||
| GitHub Copilot (deprecated) | `ghcp` | Device Code | none | Plus |
|
||||
| Cursor | `cursor` | Browser URL polling | none | Plus |
|
||||
| GitLab Duo | `gitlab` | Authorization Code or PAT | 17171 | Plus |
|
||||
| CodeBuddy | `codebuddy` | Device-style polling | none | Plus |
|
||||
| Kilo AI | `kilo` | Device Code | none | Plus |
|
||||
| Qoder | `qoder` | Device Code | none | Plus |
|
||||
## Local CLIProxy
|
||||
|
||||
xAI uses CLIProxyAPI's `--xai-login` device flow. CCS stores and discovers the resulting
|
||||
`xai-*.json` credentials under the canonical `xai` provider. `ccs grok` is a command alias only,
|
||||
so it shares the same accounts, settings, live model catalog, and routing as `ccs xai`.
|
||||
CCS manages the local binary, generated configuration, auth files, lifecycle,
|
||||
and provider-specific launch environment.
|
||||
|
||||
### Codex Duplicate-Email Account Identity
|
||||
The local backend contract is:
|
||||
|
||||
Codex can legitimately produce multiple auth files for the same email when the user has both a team/business login and a personal/free login. CCS now treats those as separate accounts instead of collapsing them by email.
|
||||
- `original` is the default upstream CLIProxy backend;
|
||||
- `plus` is an explicit opt-in community-maintained distribution;
|
||||
- providers declared Plus-only fail clearly on the original backend; and
|
||||
- CCS does not silently downgrade a configured Plus backend.
|
||||
|
||||
- Internal account IDs stay duplicate-aware for Codex only: `email#variant`
|
||||
- Variant keys are derived from the auth filename, for example `kaidu.kd@gmail.com#04a0f049-team` and `kaidu.kd@gmail.com#free`
|
||||
- Dashboard surfaces continue to show the canonical email, with a compact variant badge such as `Team` or `Free`
|
||||
- Quota fetch resolves the exact registry `tokenFile` for the selected account instead of scanning by email and taking the first match
|
||||
- Live usage/account monitor stats key by `provider + account identity`, so duplicate Codex emails no longer merge into one runtime bucket
|
||||
The management-panel repository is generated to match the selected backend
|
||||
unless the user supplies the supported override.
|
||||
|
||||
This preserves the user-visible distinction between business and personal Codex sessions while keeping other providers on their existing email-backed identity model.
|
||||
Local target traffic uses a provider-compatible endpoint and an internal
|
||||
credential. CLIProxy owns upstream OAuth token use and request translation
|
||||
according to the selected provider.
|
||||
|
||||
### Built-In Provider Detection
|
||||
## Remote CLIProxy
|
||||
|
||||
CCS derives canonical providers from `provider-capabilities.ts`. `profile-detector.ts` resolves only
|
||||
explicit CLI aliases before routing through the CLIProxy execution flow.
|
||||
Remote mode delegates proxy lifecycle and stored authentication to another
|
||||
CLIProxy installation.
|
||||
|
||||
```typescript
|
||||
const provider = resolveCLIProxyProviderShortcut(profileName);
|
||||
|
||||
if (provider) {
|
||||
return execClaudeWithCLIProxy(claudeCli, provider, args);
|
||||
}
|
||||
```text
|
||||
CLI flags and environment
|
||||
|
|
||||
v
|
||||
CCS proxy configuration
|
||||
|
|
||||
v
|
||||
Reachability check
|
||||
+-----+-----+
|
||||
| |
|
||||
reachable unreachable
|
||||
| |
|
||||
remote remote-only? ---- yes ---> fail
|
||||
|
|
||||
no
|
||||
|
|
||||
fallback enabled? -- no ----> fail
|
||||
|
|
||||
yes
|
||||
|
|
||||
local proxy
|
||||
```
|
||||
|
||||
---
|
||||
Configuration precedence and accepted environment names are implemented in
|
||||
[`proxy-config-resolver.ts`](../../src/cliproxy/proxy/proxy-config-resolver.ts).
|
||||
The stable behavioral contract is:
|
||||
|
||||
## Legacy GLMT Compatibility Flow
|
||||
- CLI flags override other sources where supported;
|
||||
- environment can select a remote host;
|
||||
- configuration supplies persistent remote settings;
|
||||
- HTTPS and HTTP defaults remain protocol-aware;
|
||||
- `--remote-only` disables local fallback; and
|
||||
- an unreachable remote proxy falls back only when fallback is enabled.
|
||||
|
||||
### Overview
|
||||
A remote proxy is a separate trust boundary. Its operator can receive provider
|
||||
traffic and owns its stored OAuth state. Use TLS and authentication appropriate
|
||||
to that boundary.
|
||||
|
||||
GLMT is no longer a marketed runtime surface in CCS. Existing `glmt` profiles are kept as a compatibility path and normalized at launch to the direct GLM endpoint. The `src/glmt/` module remains because Cursor response translation still imports its transformer pipeline.
|
||||
## Authentication
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Legacy GLMT Compatibility + Internal Transforms |
|
||||
+===========================================================================+
|
||||
CCS asks the selected CLIProxy backend to start the provider's supported auth
|
||||
flow. The provider registry identifies whether that is an authorization-code
|
||||
flow, device-code flow, browser polling flow, or currently unsupported account
|
||||
linking.
|
||||
|
||||
Claude CLI
|
||||
```text
|
||||
Select canonical provider
|
||||
|
|
||||
| legacy glmt settings detected
|
||||
v
|
||||
+------------------+
|
||||
| Compatibility | normalizeDeprecatedGlmtEnv()
|
||||
| Layer | (src/utils/glmt-deprecation.ts)
|
||||
+------------------+
|
||||
Validate backend and auth-start support
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Direct GLM API | https://api.z.ai/api/anthropic
|
||||
+------------------+
|
||||
Start backend-owned auth flow
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| src/glmt/* | retained for Cursor translation
|
||||
+------------------+
|
||||
User completes provider interaction
|
||||
|
|
||||
Backend writes provider auth file
|
||||
|
|
||||
CCS reconciles account identity and readiness
|
||||
|
|
||||
Launch through provider route
|
||||
```
|
||||
|
||||
### Supported Migration Targets
|
||||
Provider tokens live under the configured CLIProxy auth directory. Documentation
|
||||
and examples must use neutral account labels; raw email addresses, token
|
||||
filenames, access tokens, and refresh tokens are not architecture data.
|
||||
|
||||
| Provider | Config Key | Endpoint | Auth |
|
||||
|----------|------------|----------|------|
|
||||
| Z.AI (GLM) | `glm` | https://api.z.ai/api/anthropic | API key |
|
||||
| Kimi API | `km` | https://api.kimi.com/coding/ | API key |
|
||||
| Legacy compatibility | `glmt` | normalized to direct GLM at runtime | existing profile only |
|
||||
Some providers can produce more than one account with the same display email.
|
||||
CCS keeps runtime identity tied to the exact registered auth file rather than
|
||||
collapsing accounts by display text. The dashboard can show a safe variant
|
||||
label without exposing the internal identifier.
|
||||
|
||||
Use `ccs glm` for Z.AI profiles and `ccs km` for reasoning-first Kimi API profiles. Keep `glmt` only when migrating an existing settings file.
|
||||
## API-key profiles
|
||||
|
||||
### Runtime Handling
|
||||
API-key profiles store string-valued environment under the CCS profile
|
||||
settings file.
|
||||
|
||||
CCS detects the deprecated `glmt` profile name and normalizes legacy proxy-only settings before dispatching through the normal settings-profile flow:
|
||||
|
||||
```typescript
|
||||
if (isDeprecatedGlmtProfileName(profileName)) {
|
||||
const normalized = normalizeDeprecatedGlmtEnv(settingsEnv);
|
||||
// warn user, validate against direct GLM endpoint, continue through settings flow
|
||||
}
|
||||
```text
|
||||
Create or edit API profile
|
||||
|
|
||||
Write CCS-owned <profile>.settings.json
|
||||
|
|
||||
Resolve direct/native vs compatible proxy-style auth
|
||||
|
|
||||
Target adapter prepares credentials
|
||||
|
|
||||
Spawn target
|
||||
```
|
||||
|
||||
---
|
||||
Native Anthropic profiles use `ANTHROPIC_API_KEY` without forcing a proxy base
|
||||
URL. Compatible providers typically use `ANTHROPIC_BASE_URL` and
|
||||
`ANTHROPIC_AUTH_TOKEN`. The profile writer owns that distinction; callers
|
||||
should not infer it from documentation examples.
|
||||
|
||||
## Remote CLIProxy Flow (v7.1)
|
||||
Normal launches do not write `~/.claude/settings.json`. Users who want shared
|
||||
Claude settings must select the explicit `ccs persist` workflow.
|
||||
|
||||
### Overview
|
||||
## Legacy GLMT compatibility
|
||||
|
||||
Remote CLIProxy enables CCS to delegate authentication to a central proxy server instead of spawning a local binary.
|
||||
`glmt` is a compatibility input, not a current provider architecture. Existing
|
||||
legacy settings are normalized to the direct GLM path before normal
|
||||
settings-profile dispatch. Internal GLMT transformer modules can remain in use
|
||||
by other compatibility surfaces without making GLMT a supported standalone
|
||||
runtime.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Remote CLIProxy Architecture (v7.1) |
|
||||
+===========================================================================+
|
||||
New configuration should use current API profile commands and provider names.
|
||||
|
||||
Config Resolution (proxy-config-resolver.ts)
|
||||
## Quota and account-pool flow
|
||||
|
||||
Quota support is provider-specific and intentionally sourced from
|
||||
[`provider-capabilities.ts`](../../src/cliproxy/provider-capabilities.ts).
|
||||
|
||||
The stable pool contract is:
|
||||
|
||||
1. Reconcile registered accounts with live auth files.
|
||||
2. Exclude manually paused accounts from rotation.
|
||||
3. Fetch quota only for providers with an implemented quota fetcher.
|
||||
4. Distinguish an exhausted account from a provider-wide or transient failure.
|
||||
5. When a healthy fallback exists, CCS can create a temporary quota pause for
|
||||
an exhausted account.
|
||||
6. Persist the cooldown so later launches observe the same state.
|
||||
7. Auto-resume only pauses created by CCS quota management; never override a
|
||||
user's manual pause.
|
||||
|
||||
Routing strategy remains an explicit user choice. CCS must not infer
|
||||
round-robin or fill-first from account count, plan tier, or quota state.
|
||||
|
||||
## Image analysis
|
||||
|
||||
For profiles that need managed vision support, CCS prepares the image-analysis
|
||||
route before launch:
|
||||
|
||||
```text
|
||||
Resolve profile and provider
|
||||
|
|
||||
+---> Priority: CLI flags > ENV vars > config.yaml > defaults
|
||||
Resolve supported provider backend
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| ResolvedProxyConfig |
|
||||
| mode: local|remote |
|
||||
+------------------+
|
||||
Provision managed MCP/runtime configuration
|
||||
|
|
||||
+---> [mode = local] ---> Spawn local CLIProxyAPI binary
|
||||
| |
|
||||
| v
|
||||
| localhost:8317
|
||||
Target invokes ImageAnalysis
|
||||
|
|
||||
+---> [mode = remote] ---> Connect to remote server
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Health Check | remote-proxy-client.ts
|
||||
| /v1/models | 2s timeout
|
||||
+------------------+
|
||||
|
|
||||
+---> [reachable] ---> Use remote
|
||||
| |
|
||||
| v
|
||||
| protocol://host:port
|
||||
|
|
||||
+---> [unreachable] ---> Fallback decision
|
||||
|
|
||||
+-----------------------------+
|
||||
|
|
||||
+---> [fallbackEnabled] ---> Start local
|
||||
|
|
||||
+---> [remoteOnly] ---> Fail with error
|
||||
|
||||
CLI Flags:
|
||||
--proxy-host <host> Remote hostname/IP
|
||||
--proxy-port <port> Port (default: 8317 HTTP, 443 HTTPS)
|
||||
--proxy-protocol <proto> http or https
|
||||
--proxy-auth-token <token> Bearer authentication
|
||||
--local-proxy Force local mode
|
||||
--remote-only Fail if remote unreachable
|
||||
|
||||
Environment Variables:
|
||||
CCS_PROXY_HOST Remote hostname
|
||||
CCS_PROXY_PORT Remote port
|
||||
CCS_PROXY_PROTOCOL Protocol (http/https)
|
||||
CCS_PROXY_AUTH_TOKEN Auth token
|
||||
CCS_PROXY_FALLBACK_ENABLED Enable fallback (true/false)
|
||||
CCS sends provider-scoped request
|
||||
|
|
||||
Return text result
|
||||
```
|
||||
|
||||
### Configuration Resolution
|
||||
The managed route must not expose its internal credential in logs or user
|
||||
settings. If managed preparation, authentication, or proxy readiness is
|
||||
unavailable, CCS falls back to compatible native behavior where possible
|
||||
instead of failing the entire target launch.
|
||||
|
||||
```typescript
|
||||
// proxy-config-resolver.ts: Priority order
|
||||
const resolved = {
|
||||
...DEFAULT_CONFIG, // 4. Defaults (lowest)
|
||||
...yamlConfig, // 3. config.yaml
|
||||
...envConfig, // 2. Environment variables
|
||||
...cliFlags, // 1. CLI flags (highest)
|
||||
};
|
||||
```
|
||||
Provider-specific vision mappings are implementation data and should be read
|
||||
from the image-analysis routing services and tests.
|
||||
|
||||
### Health Check
|
||||
## Session and observability boundary
|
||||
|
||||
```typescript
|
||||
// remote-proxy-client.ts
|
||||
async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise<boolean> {
|
||||
try {
|
||||
const url = `${config.protocol}://${config.host}:${config.port}/v1/models`;
|
||||
const response = await fetch(url, {
|
||||
headers: config.authToken ? { Authorization: `Bearer ${config.authToken}` } : {},
|
||||
timeout: 2000,
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
Execution paths can record non-secret metadata such as profile identity,
|
||||
profile type, canonical provider, target type, timestamps, duration, and
|
||||
numeric process exit status. Logs must not contain provider tokens, API keys,
|
||||
raw auth payloads, or personal account identifiers.
|
||||
|
||||
---
|
||||
Textual CCS log codes and numeric child-process exit statuses are different
|
||||
contracts. See [Logging Contract](../logging-contract.md).
|
||||
|
||||
## Quota Management Flow (v7.14)
|
||||
## Invariants
|
||||
|
||||
### Overview
|
||||
- Canonical provider identity is registry-driven.
|
||||
- Provider aliases normalize before execution.
|
||||
- Backend restrictions are validated before starting local auth or routing.
|
||||
- Remote-only mode never starts a local fallback.
|
||||
- Persistent environment values are strings.
|
||||
- Account display text is not a unique runtime identity.
|
||||
- Manual pauses are never auto-resumed by quota management.
|
||||
- Provider credentials never appear in architecture examples.
|
||||
|
||||
Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account.
|
||||
Before local CLIProxy startup, CCS reconciles the whole active account pool for the provider, not only the default account. When CCS detects any quota-exhausted account and a healthy fallback exists, it temporarily pauses the exhausted account out of CLIProxy rotation and automatically resumes that pause after the configured cooldown expires. This durable self-pause uses the same account registry and token movement path as dashboard/manual pause, so the dashboard shows the account as paused and CLIProxy cannot rediscover its token from the live `auth/` folder.
|
||||
## Related documentation
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Quota Management Architecture (v7.14) |
|
||||
+===========================================================================+
|
||||
|
||||
Pre-Flight Check (before session start)
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| quota-manager.ts | Hybrid quota management
|
||||
+------------------+
|
||||
|
|
||||
+---> Get all active accounts for provider
|
||||
|
|
||||
+---> For each account:
|
||||
| |
|
||||
| v
|
||||
| +------------------+
|
||||
| | quota-fetcher.ts | Provider-specific API calls
|
||||
| +------------------+
|
||||
| |
|
||||
| +---> Check isPaused flag --> Skip if paused
|
||||
| |
|
||||
| +---> Fetch quota from provider API
|
||||
| | - Antigravity: fetchAvailableModels
|
||||
| | - Claude: policy limits endpoint
|
||||
| | - Codex: ChatGPT usage windows
|
||||
| | - Gemini CLI: Code Assist quota buckets
|
||||
| | - GitHub Copilot: copilot_internal/user snapshots (deprecated compatibility)
|
||||
| |
|
||||
| +---> Detect tier (free/paid/unknown)
|
||||
| |
|
||||
| +---> Check exhaustion status
|
||||
|
|
||||
+---> Pause exhausted non-default accounts when another healthy account exists
|
||||
|
|
||||
+---> Select best account (not paused, not exhausted)
|
||||
|
|
||||
+---> Auto-failover to next account if current exhausted
|
||||
|
|
||||
+---> Temporarily pause exhausted account when fallback exists
|
||||
| - move token out of live auth discovery
|
||||
| - persist cooldown expiry across launches
|
||||
| - auto-resume only CCS-created quota pauses
|
||||
|
||||
CLI Commands:
|
||||
ccs cliproxy pause <account> --> Set isPaused=true in account-manager
|
||||
ccs cliproxy resume <account> --> Set isPaused=false
|
||||
ccs cliproxy status [account] --> Display quota + tier info
|
||||
|
||||
Dashboard UI:
|
||||
- Pause/Resume toggle per account
|
||||
- Tier badge (free/paid/unknown)
|
||||
- Quota usage display
|
||||
```
|
||||
|
||||
### Account Selection Algorithm
|
||||
|
||||
```typescript
|
||||
// quota-manager.ts: Best account selection
|
||||
function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null {
|
||||
// Priority:
|
||||
// 1. Not paused
|
||||
// 2. Not exhausted
|
||||
// 3. Paid tier over free tier
|
||||
// 4. Highest remaining quota
|
||||
|
||||
return accounts
|
||||
.filter(acc => !acc.isPaused && !acc.isExhausted)
|
||||
.sort((a, b) => {
|
||||
if (a.tier !== b.tier) return (a.tier === 'paid' ? -1 : 1);
|
||||
return (b.remainingQuota || 0) - (a.remainingQuota || 0);
|
||||
})[0] || null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### OAuth Providers - Authorization Code Flow
|
||||
|
||||
**Providers**: Gemini, Codex, Antigravity, Kiro (aws method)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| OAuth - Authorization Code Flow (Port-based) |
|
||||
+===========================================================================+
|
||||
|
||||
1. User runs: ccs codex
|
||||
|
|
||||
v
|
||||
2. Check token cache (~/.ccs/cliproxy/auth/)
|
||||
|
|
||||
+---> [Valid token] ---> Use cached token
|
||||
|
|
||||
+---> [No/Expired token]
|
||||
|
|
||||
v
|
||||
3. Start local OAuth server (localhost:9876)
|
||||
|
|
||||
v
|
||||
4. Open browser with OAuth request
|
||||
| https://oauth-provider/authorize?redirect_uri=http://localhost:9876/callback
|
||||
v
|
||||
5. User authorizes in browser
|
||||
|
|
||||
v
|
||||
6. OAuth provider redirects to localhost:9876/callback?code=XXXX
|
||||
|
|
||||
v
|
||||
7. Exchange auth code for access token
|
||||
|
|
||||
v
|
||||
8. Cache token locally (~/.ccs/cliproxy/auth/gemini.json)
|
||||
|
|
||||
v
|
||||
9. Proceed with Claude CLI
|
||||
```
|
||||
|
||||
### OAuth Providers - Device Code Flow
|
||||
|
||||
**Providers**: GitHub Copilot (ghcp, deprecated compatibility)
|
||||
|
||||
Provider identity note:
|
||||
- Providers that do not expose a reliable email no longer require a manual nickname during first auth.
|
||||
- CCS derives a stable internal account identifier from the token/cache context and still allows the user to rename the account later.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| OAuth - Device Code Flow (No Port Needed) |
|
||||
+===========================================================================+
|
||||
|
||||
1. User runs: ccs ghcp
|
||||
|
|
||||
v
|
||||
2. Check token cache (~/.ccs/cliproxy/auth/)
|
||||
|
|
||||
+---> [Valid token] ---> Use cached token
|
||||
|
|
||||
+---> [No/Expired token]
|
||||
|
|
||||
v
|
||||
3. Request device code from GitHub
|
||||
|
|
||||
v
|
||||
4. Display user code + verification URL
|
||||
| "Enter code XXXX-XXXX at github.com/login/device"
|
||||
v
|
||||
5. User opens URL in browser and enters code
|
||||
|
|
||||
v
|
||||
6. Poll GitHub for token completion
|
||||
|
|
||||
v
|
||||
7. Receive and cache token locally
|
||||
|
|
||||
v
|
||||
8. Proceed with Claude CLI
|
||||
```
|
||||
|
||||
### Kiro OAuth - Method-Aware Flow
|
||||
|
||||
**Supported methods**:
|
||||
- `aws`: Device Code (default, AWS org friendly)
|
||||
- `aws-authcode`: Authorization Code via CLI flow
|
||||
- `google`: Social OAuth via management API
|
||||
- `github`: Social OAuth via management API (Dashboard flow)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Kiro OAuth - Method-Aware Flow |
|
||||
+===========================================================================+
|
||||
|
||||
Configuration:
|
||||
ccs_profile:
|
||||
target: claude
|
||||
cliproxy:
|
||||
provider: kiro
|
||||
kiro_method: aws # or aws-authcode, google, github
|
||||
|
||||
Flow:
|
||||
Device Code (aws)
|
||||
→ /start endpoint (no callback port)
|
||||
→ Opens browser
|
||||
→ User enters code
|
||||
→ Poll /status
|
||||
|
||||
Authorization Code (aws-authcode, google, github)
|
||||
→ /start-url endpoint
|
||||
→ Returns auth_url
|
||||
→ User visits URL
|
||||
→ Callback handled
|
||||
→ Poll /status for completion
|
||||
|
||||
Key behavior:
|
||||
- Device Code method uses /start route (no callback port)
|
||||
- Callback/social methods use /start-url + status polling
|
||||
- Some management flows return state first, auth_url later
|
||||
- Manual nicknames are optional when the upstream provider does not return an email
|
||||
- Account storage uses a stable internal identifier so reauth/update flows do not depend on dashboard list order
|
||||
```
|
||||
|
||||
### API Key Profiles (GLM, Kimi)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| API Key Profile (Non-OAuth) |
|
||||
+===========================================================================+
|
||||
|
||||
1. User configures API key in settings
|
||||
|
|
||||
v
|
||||
2. Key stored in ~/.ccs/<profile>.settings.json
|
||||
|
|
||||
v
|
||||
3. Profile detection: APIKeyProfile
|
||||
|
|
||||
v
|
||||
4. Key passed via ANTHROPIC_AUTH_TOKEN env var
|
||||
|
|
||||
v
|
||||
5. Target adapter (Claude/Droid) handles delivery
|
||||
|
|
||||
└─ Claude: env var
|
||||
└─ Droid: config file (~/.factory/settings.json)
|
||||
```
|
||||
|
||||
### Anthropic Direct API Key
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Anthropic Direct API Key (Native Auth) |
|
||||
+===========================================================================+
|
||||
|
||||
1. User creates profile: ccs api create --preset anthropic
|
||||
|
|
||||
v
|
||||
2. Key stored in ~/.ccs/<profile>.settings.json
|
||||
| env: { ANTHROPIC_API_KEY: "sk-ant-..." }
|
||||
| (NO ANTHROPIC_BASE_URL, NO ANTHROPIC_AUTH_TOKEN)
|
||||
v
|
||||
3. Profile detection: settings-based
|
||||
|
|
||||
v
|
||||
4. Key passed via ANTHROPIC_API_KEY env var
|
||||
| Claude CLI uses native endpoint (api.anthropic.com)
|
||||
v
|
||||
5. Claude CLI authenticates with x-api-key header
|
||||
|
||||
Detection logic (profile-writer.ts):
|
||||
- apiKey.startsWith('sk-ant-') -> native mode
|
||||
- baseUrl.includes('api.anthropic.com') -> native mode
|
||||
- Otherwise -> proxy mode (existing behavior)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Image Analysis Hook Flow (v7.34)
|
||||
|
||||
### Overview
|
||||
|
||||
Image Analysis Hook enables vision model proxying through CLIProxy with automatic injection for all profile types.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Image Analysis Hook Flow (v7.34) |
|
||||
+===========================================================================+
|
||||
|
||||
Claude CLI with image input
|
||||
|
|
||||
v
|
||||
Hook Installer (ensureProfileHooks)
|
||||
|
|
||||
+---> Check ~/.claude/hooks/openai-vision-hook.cjs exists
|
||||
|
|
||||
+---> If missing: auto-install via image-analyzer-hook-installer
|
||||
|
|
||||
v
|
||||
Hook Configuration
|
||||
|
|
||||
+---> Set ANTHROPIC_IMAGE_HOOK_URL
|
||||
| (proxy endpoint URL)
|
||||
|
|
||||
v
|
||||
Claude CLI processes image request
|
||||
|
|
||||
v
|
||||
Claude prefers ImageAnalysis MCP tool
|
||||
|
|
||||
v
|
||||
CCS provider-backed image analysis
|
||||
|
|
||||
+---> Provider route resolved before launch
|
||||
|
|
||||
+---> Direct request to /api/provider/<backend>/v1/messages
|
||||
|
|
||||
+---> Native Read fallback if runtime/auth/proxy is unavailable
|
||||
|
|
||||
v
|
||||
Text description returned to Claude CLI
|
||||
```
|
||||
|
||||
### Runtime Environment
|
||||
|
||||
```typescript
|
||||
// getImageAnalysisHookEnv()
|
||||
{
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: 'http://127.0.0.1:8317',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'ccs-internal-managed',
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Support
|
||||
|
||||
| Provider | Vision Support | Notes |
|
||||
|----------|---|---|
|
||||
| Gemini | ✓ | Via CCS ImageAnalysis provider route |
|
||||
| Codex | ✓ | Via CCS ImageAnalysis provider route |
|
||||
| Antigravity | ✓ | Via CCS ImageAnalysis provider route |
|
||||
| Kiro | ✓ | Via mapped CCS provider route when configured |
|
||||
| Copilot | ✓ | Deprecated compatibility route via mapped ghcp provider |
|
||||
| GLM/Kimi | ✓ | Via explicit or fallback backend mapping |
|
||||
|
||||
---
|
||||
|
||||
## Session Tracking
|
||||
|
||||
All execution paths record session metadata including target CLI used:
|
||||
|
||||
```typescript
|
||||
{
|
||||
profileName: 'gemini',
|
||||
profileType: 'clipproxy',
|
||||
provider: 'google-gemini',
|
||||
targetCli: 'claude', // NEW: which target was used
|
||||
timestamp: '2026-02-16T10:40:00Z',
|
||||
duration: 12345,
|
||||
exitCode: 0,
|
||||
model: 'claude-opus-4-6',
|
||||
}
|
||||
```
|
||||
|
||||
This enables analytics on target CLI usage and adoption.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [System Architecture Index](./index.md) — Overall system design
|
||||
- [Target Adapters](./target-adapters.md) — Multi-CLI adapter pattern
|
||||
- [Codebase Summary](../codebase-summary.md) — Module structure
|
||||
- [Code Standards](../code-standards.md) — Implementation guidelines
|
||||
- [System Architecture](./index.md)
|
||||
- [Target Adapters](./target-adapters.md)
|
||||
- [Logging Contract](../logging-contract.md)
|
||||
- [Codebase Summary](../codebase-summary.md)
|
||||
- [Code Standards](../code-standards.md)
|
||||
@@ -1,803 +1,181 @@
|
||||
# Target Adapters
|
||||
|
||||
Last Updated: 2026-05-18
|
||||
Target adapters are the last-mile boundary between CCS profile resolution and a
|
||||
supported CLI runtime. Profile discovery and credential resolution happen
|
||||
before this boundary; the selected adapter owns binary detection, target-native
|
||||
credential delivery, argument and environment construction, and child-process
|
||||
execution.
|
||||
|
||||
Detailed documentation of the target adapter pattern and implementations.
|
||||
Related architecture:
|
||||
|
||||
---
|
||||
- [System architecture](./index.md)
|
||||
- [Provider flows](./provider-flows.md)
|
||||
|
||||
## Overview
|
||||
## Contract
|
||||
|
||||
The target adapter system enables CCS to dispatch credential-resolved profiles to different CLI implementations while maintaining a unified configuration and profile system.
|
||||
The canonical interface and data types live in
|
||||
[`src/targets/target-adapter.ts`](../../src/targets/target-adapter.ts). Each
|
||||
adapter must:
|
||||
|
||||
**Key insight**: Profile resolution (detecting provider, loading auth, building credentials) is target-agnostic. Only the final credential delivery and process spawning differ per target.
|
||||
1. detect its runtime binary without changing user configuration;
|
||||
2. reject unsupported profile types before launch;
|
||||
3. prepare only the target-owned configuration needed for the launch;
|
||||
4. construct an argument vector and environment without exposing credentials in
|
||||
arguments;
|
||||
5. spawn the target with inherited stdio and forward process signals.
|
||||
|
||||
---
|
||||
The registry in
|
||||
[`src/targets/target-registry.ts`](../../src/targets/target-registry.ts) maps a
|
||||
target type to its adapter. Target names, built-in aliases, legacy alias
|
||||
environment variables, and persistence eligibility are centralized in
|
||||
[`src/targets/target-metadata.ts`](../../src/targets/target-metadata.ts).
|
||||
|
||||
## Target Adapter Interface
|
||||
|
||||
Each CLI target implements the `TargetAdapter` contract:
|
||||
|
||||
```typescript
|
||||
export interface TargetAdapter {
|
||||
readonly type: TargetType; // 'claude' | 'droid' | 'codex'
|
||||
readonly displayName: string; // "Claude Code" | "Factory Droid" | "Codex CLI"
|
||||
|
||||
/** Detect if the target CLI binary exists on system */
|
||||
detectBinary(): TargetBinaryInfo | null;
|
||||
|
||||
/** Prepare credentials for delivery to target CLI */
|
||||
prepareCredentials(creds: TargetCredentials): Promise<void>;
|
||||
|
||||
/** Build spawn arguments for the target CLI */
|
||||
buildArgs(
|
||||
profile: string,
|
||||
userArgs: string[],
|
||||
options?: {
|
||||
creds?: TargetCredentials;
|
||||
profileType?: ProfileType;
|
||||
binaryInfo?: TargetBinaryInfo;
|
||||
}
|
||||
): string[];
|
||||
|
||||
/** Build environment variables for the target CLI */
|
||||
buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv;
|
||||
|
||||
/** Spawn the target CLI process (replaces current process flow) */
|
||||
exec(args: string[], env: NodeJS.ProcessEnv, options?: { cwd?: string }): void;
|
||||
|
||||
/** Check if a profile type is supported by this target */
|
||||
supportsProfileType(profileType: string): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Type Definitions
|
||||
|
||||
```typescript
|
||||
export type TargetType = 'claude' | 'droid' | 'codex';
|
||||
|
||||
export interface TargetCredentials {
|
||||
baseUrl: string; // API endpoint
|
||||
apiKey: string; // Auth token
|
||||
model?: string; // Model ID
|
||||
provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api';
|
||||
envVars?: NodeJS.ProcessEnv; // Additional env vars
|
||||
}
|
||||
|
||||
export interface TargetBinaryInfo {
|
||||
path: string; // Full path to binary
|
||||
needsShell: boolean; // Windows .cmd/.bat/.ps1?
|
||||
version?: string; // Optional version string
|
||||
features?: readonly string[]; // Capability probes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
Do not duplicate the TypeScript interface in this guide. Interface signatures
|
||||
and credential fields change with runtime requirements; the source files are
|
||||
the contract checked by the compiler.
|
||||
|
||||
## Target Resolution
|
||||
|
||||
CCS resolves which adapter to use via priority-ordered checks:
|
||||
|
||||
### Resolution Priority
|
||||
|
||||
```
|
||||
1. --target flag (CLI argument) — highest priority
|
||||
└─ ccs --target droid glm
|
||||
└─ ccs --target codex
|
||||
|
||||
2. Explicit runtime entrypoint (`CCS_INTERNAL_ENTRY_TARGET`) — dedicated bin shims
|
||||
└─ ccs-droid / ccsd → droid
|
||||
└─ ccs-codex / ccsx → codex
|
||||
└─ ccsxp → codex, then prepends `--config model_provider="cliproxy"`
|
||||
|
||||
3. argv[0] detection (runtime alias pattern) — binary name mapping for same-binary/custom aliases
|
||||
└─ ccs-droid (explicit alias) → droid
|
||||
└─ ccsd (legacy shortcut) → droid
|
||||
└─ ccs-codex (explicit alias) → codex
|
||||
└─ ccsx (short alias) → codex
|
||||
└─ ccs (regular command) → default
|
||||
|
||||
4. Per-profile config (from ~/.ccs/config.yaml or settings.json)
|
||||
└─ persisted targets are currently only `claude` and `droid`
|
||||
└─ profiles:
|
||||
glm:
|
||||
target: droid
|
||||
|
||||
5. Fallback: 'claude' — lowest priority
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// src/targets/target-resolver.ts
|
||||
|
||||
export function resolveTargetType(
|
||||
args: string[],
|
||||
profileConfig?: { target?: TargetType }
|
||||
): TargetType {
|
||||
// 1. Parse --target flags (supports --target value and --target=value)
|
||||
// Repeated flags: last one wins.
|
||||
const parsed = parseTargetFlags(args);
|
||||
if (parsed.targetOverride) {
|
||||
return parsed.targetOverride;
|
||||
}
|
||||
|
||||
// 2. Check explicit runtime entrypoint shim
|
||||
const entrypointTarget = resolveEntrypointTarget();
|
||||
if (entrypointTarget) {
|
||||
return entrypointTarget;
|
||||
}
|
||||
|
||||
// 3. Check argv[0] (binary name / custom alias map)
|
||||
const binName = path.basename(process.argv[1] || process.argv0 || '').replace(/\.(cmd|bat|ps1|exe)$/i, '');
|
||||
if (ARGV0_TARGET_MAP[binName]) {
|
||||
return ARGV0_TARGET_MAP[binName];
|
||||
}
|
||||
|
||||
// 4. Check profile config
|
||||
if (profileConfig?.target) {
|
||||
// Persisted targets are validated before profile configuration is saved.
|
||||
return profileConfig.target;
|
||||
}
|
||||
|
||||
// 5. Default to claude
|
||||
return 'claude';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Claude Adapter
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// src/targets/claude-adapter.ts
|
||||
|
||||
export class ClaudeAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'claude';
|
||||
readonly displayName = 'Claude Code';
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null {
|
||||
const info = getClaudeCliInfo();
|
||||
if (!info) return null;
|
||||
return { path: info.path, needsShell: info.needsShell };
|
||||
}
|
||||
|
||||
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
|
||||
// No-op: Claude receives credentials via environment variables
|
||||
}
|
||||
|
||||
buildArgs(_profile: string, userArgs: string[]): string[] {
|
||||
return userArgs; // Pass through user arguments unchanged
|
||||
}
|
||||
|
||||
buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv {
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
|
||||
// For native profiles, strip stale proxy env to prevent interference
|
||||
const baseEnv =
|
||||
profileType === 'account' || profileType === 'default'
|
||||
? stripAnthropicEnv(process.env)
|
||||
: process.env;
|
||||
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv };
|
||||
|
||||
if (creds.envVars) {
|
||||
Object.assign(env, creds.envVars);
|
||||
}
|
||||
|
||||
// Deliver credentials via environment variables
|
||||
if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl;
|
||||
if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey;
|
||||
if (creds.model) env['ANTHROPIC_MODEL'] = creds.model;
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void {
|
||||
const claudeCli = detectClaudeCli();
|
||||
if (!claudeCli) {
|
||||
void ErrorManager.showClaudeNotFound();
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Windows shell requirements
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
|
||||
let child: ChildProcess;
|
||||
if (needsShell) {
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
child = spawn(cmdString, { shell: true, stdio: 'inherit', env });
|
||||
} else {
|
||||
child = spawn(claudeCli, args, { stdio: 'inherit', env });
|
||||
}
|
||||
|
||||
// Handle process termination
|
||||
const onSigInt = () => child.kill('SIGINT');
|
||||
const onSigTerm = () => child.kill('SIGTERM');
|
||||
process.once('SIGINT', onSigInt);
|
||||
process.once('SIGTERM', onSigTerm);
|
||||
child.on('exit', () => {
|
||||
process.removeListener('SIGINT', onSigInt);
|
||||
process.removeListener('SIGTERM', onSigTerm);
|
||||
});
|
||||
}
|
||||
|
||||
supportsProfileType(profileType: string): boolean {
|
||||
// Claude supports all profile types
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Native Claude launches keep user arguments session-scoped. The launch layer validates and normalizes
|
||||
`--effort low|medium|high|xhigh|max` before spawning Claude, then passes it through without writing
|
||||
to Claude or CCS configuration. CLIProxy-backed Claude launches still treat `--effort` as the CCS
|
||||
thinking alias handled by CLIProxy.
|
||||
|
||||
### Credential Delivery
|
||||
|
||||
**Method**: Environment variables
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
export ANTHROPIC_AUTH_TOKEN=sk-ant-...
|
||||
export ANTHROPIC_MODEL=claude-opus-4-6
|
||||
export WEBSEARCH_HOOK_ENV=... # Image analysis, websearch
|
||||
```
|
||||
|
||||
### Execution
|
||||
|
||||
```bash
|
||||
# Direct invocation
|
||||
ccs codex
|
||||
→ claude "args..."
|
||||
with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN set
|
||||
|
||||
# With --target override
|
||||
ccs --target claude glm
|
||||
→ claude "args..."
|
||||
with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN set
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Droid Adapter
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// src/targets/droid-adapter.ts
|
||||
|
||||
export class DroidAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'droid';
|
||||
readonly displayName = 'Factory Droid';
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null {
|
||||
const info = getDroidBinaryInfo();
|
||||
if (!info) return null;
|
||||
|
||||
// Non-blocking version compatibility check
|
||||
checkDroidVersion(info.path);
|
||||
return info;
|
||||
}
|
||||
|
||||
async prepareCredentials(creds: TargetCredentials): Promise<void> {
|
||||
// Write custom model entry to ~/.factory/settings.json
|
||||
await upsertCcsModel(creds.profile, {
|
||||
model: creds.model || 'claude-opus-4-6',
|
||||
displayName: `CCS ${creds.profile}`,
|
||||
baseUrl: creds.baseUrl,
|
||||
apiKey: creds.apiKey,
|
||||
provider: creds.provider || 'anthropic',
|
||||
});
|
||||
}
|
||||
|
||||
buildArgs(profile: string, userArgs: string[]): string[] {
|
||||
// Droid uses -m <model> syntax for model selection
|
||||
return ['-m', `custom:ccs-${profile}`, ...userArgs];
|
||||
}
|
||||
|
||||
buildEnv(_creds: TargetCredentials, _profileType: string): NodeJS.ProcessEnv {
|
||||
// Droid reads from config file — minimal env needed
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void {
|
||||
const droidPath = detectDroidCli();
|
||||
if (!droidPath) {
|
||||
console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Windows shell requirements
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(droidPath);
|
||||
|
||||
let child: ChildProcess;
|
||||
if (needsShell) {
|
||||
const cmdString = [droidPath, ...args].map(escapeShellArg).join(' ');
|
||||
child = spawn(cmdString, { shell: true, stdio: 'inherit', env });
|
||||
} else {
|
||||
child = spawn(droidPath, args, { stdio: 'inherit', env });
|
||||
}
|
||||
|
||||
// Handle process termination
|
||||
const onSigInt = () => child.kill('SIGINT');
|
||||
const onSigTerm = () => child.kill('SIGTERM');
|
||||
process.once('SIGINT', onSigInt);
|
||||
process.once('SIGTERM', onSigTerm);
|
||||
child.on('exit', () => {
|
||||
process.removeListener('SIGINT', onSigInt);
|
||||
process.removeListener('SIGTERM', onSigTerm);
|
||||
});
|
||||
}
|
||||
|
||||
supportsProfileType(profileType: string): boolean {
|
||||
// Droid currently supports direct settings/default paths only
|
||||
return profileType === 'settings' || profileType === 'default';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Credential Delivery
|
||||
|
||||
**Method**: Config file (`~/.factory/settings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"customModels": [
|
||||
{
|
||||
"model": "claude-opus-4-6",
|
||||
"displayName": "CCS gemini",
|
||||
"baseUrl": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"apiKey": "AIza...",
|
||||
"provider": "openai"
|
||||
},
|
||||
{
|
||||
"model": "glm-4",
|
||||
"displayName": "CCS glm",
|
||||
"baseUrl": "https://open.bigmodel.cn/api/paas/v4/",
|
||||
"apiKey": "your-glm-key",
|
||||
"provider": "openai"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Execution
|
||||
|
||||
```bash
|
||||
# Direct invocation
|
||||
ccs codex
|
||||
→ droid -m custom:ccs-codex "args..."
|
||||
(credentials loaded from ~/.factory/settings.json)
|
||||
|
||||
# With --target override
|
||||
ccs --target droid glm
|
||||
→ droid -m custom:ccs-glm "args..."
|
||||
(credentials loaded from ~/.factory/settings.json)
|
||||
```
|
||||
|
||||
### Runtime Alias Pattern
|
||||
|
||||
```bash
|
||||
# Built-in package bin aliases
|
||||
ccs-droid glm
|
||||
→ Target: droid (forced by runtime alias)
|
||||
→ droid -m custom:ccs-glm "args..."
|
||||
|
||||
# Legacy shortcut still works
|
||||
ccsd glm
|
||||
→ Target: droid (forced by runtime alias)
|
||||
→ droid -m custom:ccs-glm "args..."
|
||||
```
|
||||
|
||||
On Windows, `ccs-droid.cmd`, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized.
|
||||
|
||||
Additional alias names can be configured at runtime after you create a matching
|
||||
symlink or another launcher that preserves the invoked basename. Use `CCS_TARGET_ALIASES` (preferred,
|
||||
`target=alias1,alias2;...`) or legacy `CCS_DROID_ALIASES` (comma-separated).
|
||||
Example:
|
||||
|
||||
```bash
|
||||
ln -s /path/to/ccs /path/to/mydroid
|
||||
CCS_TARGET_ALIASES=droid=mydroid
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Codex Adapter
|
||||
|
||||
### Implementation
|
||||
|
||||
The Codex adapter keeps CCS-backed Codex launches transient. It does not rewrite
|
||||
`~/.codex/config.toml`. Instead it:
|
||||
|
||||
- passes through native default Codex sessions unchanged
|
||||
- probes the installed Codex binary for `--config <key=value>` support
|
||||
- injects CCS-backed provider credentials through temporary `-c` overrides
|
||||
- stores the routed API key only in process env via `CCS_CODEX_API_KEY`
|
||||
|
||||
```typescript
|
||||
// src/targets/codex-adapter.ts
|
||||
|
||||
export class CodexAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'codex';
|
||||
readonly displayName = 'Codex CLI';
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null {
|
||||
return getCodexBinaryInfo();
|
||||
}
|
||||
|
||||
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
|
||||
// No file writes. Codex uses transient -c overrides plus env_key injection.
|
||||
}
|
||||
|
||||
buildArgs(profile: string, userArgs: string[], options?: BuildOptions): string[] {
|
||||
if ((options?.profileType || 'default') === 'default') {
|
||||
return userArgs;
|
||||
}
|
||||
|
||||
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
|
||||
throw new Error('Upgrade Codex before using CCS-backed Codex profiles.');
|
||||
}
|
||||
|
||||
return [
|
||||
'-c',
|
||||
'model_provider=\"ccs_runtime\"',
|
||||
'-c',
|
||||
'model_providers.ccs_runtime.base_url=\"http://127.0.0.1:8317/api/provider/codex\"',
|
||||
'-c',
|
||||
'model_providers.ccs_runtime.env_key=\"CCS_CODEX_API_KEY\"',
|
||||
'-c',
|
||||
'model_providers.ccs_runtime.wire_api=\"responses\"',
|
||||
...userArgs,
|
||||
];
|
||||
}
|
||||
|
||||
buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv {
|
||||
const env = { ...stripAnthropicEnv(process.env) };
|
||||
if (profileType !== 'default') {
|
||||
env['CCS_CODEX_API_KEY'] = creds.apiKey;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Support Matrix
|
||||
|
||||
Codex is a real runtime target, but it is intentionally narrower than Claude or Droid in v1:
|
||||
|
||||
| Profile Type | Codex Target | Notes |
|
||||
|--------------|--------------|-------|
|
||||
| `default` | Yes | Uses existing native Codex auth/config |
|
||||
| `cliproxy` provider=`codex` | Yes | Routed through CLIProxy Codex Responses bridge |
|
||||
| `cliproxy` composite | No | Not proven native-Codex-safe |
|
||||
| `settings` with Codex bridge metadata | Yes | Only when the API profile resolves to a Codex CLIProxy bridge |
|
||||
| `settings` generic API profile | No | Claude/Droid only |
|
||||
| `account` | No | Claude-only account isolation concept |
|
||||
| `copilot` | No | Not a native Codex provider path |
|
||||
|
||||
### Codex Dashboard Surface
|
||||
|
||||
CCS also exposes a dedicated dashboard route at `ccs config` -> `Compatible` -> `Codex CLI`.
|
||||
That page is intentionally narrower than the Droid dashboard in overall scope, but it is no
|
||||
longer read-mostly:
|
||||
|
||||
- reads and writes only the user config layer: `~/.codex/config.toml` or `$CODEX_HOME/config.toml`
|
||||
- provides guided controls for top-level settings, project trust, profiles, model providers,
|
||||
MCP servers, and supported feature flags
|
||||
- keeps a raw `config.toml` editor as the escape hatch for unsupported or fidelity-sensitive edits
|
||||
- shows binary detection, user-layer config summaries, support-matrix guidance, and upstream docs
|
||||
- normalizes TOML formatting and drops comments on structured saves
|
||||
- keeps structured controls disabled while raw TOML is dirty or invalid, validates project trust
|
||||
paths as absolute or `~/...`, and lets feature flags reset back to Codex defaults
|
||||
- warns that transient CCS runtime overrides such as `codex -c key=value` and
|
||||
`CCS_CODEX_API_KEY` can change the effective runtime without persisting into the file editor
|
||||
|
||||
This keeps the dashboard honest about Codex's merged configuration model while still giving users
|
||||
one place to inspect and manage the user-owned layer safely.
|
||||
|
||||
### Runtime Entrypoints and argv[0] Fallback
|
||||
|
||||
```bash
|
||||
# Built-in package bin entrypoints
|
||||
ccs-codex
|
||||
→ dist/bin/codex-runtime.js
|
||||
→ CCS_INTERNAL_ENTRY_TARGET=codex
|
||||
|
||||
ccsx
|
||||
→ dist/bin/codex-runtime.js
|
||||
→ CCS_INTERNAL_ENTRY_TARGET=codex
|
||||
→ passes native Codex diagnostics plus known upstream Codex subcommands and aliases through before CCS profile detection
|
||||
→ reserves CCS-owned `auth`, `doctor`, and `update`
|
||||
|
||||
ccsxp
|
||||
→ dist/bin/ccsxp-runtime.js
|
||||
→ CCS_INTERNAL_ENTRY_TARGET=codex
|
||||
→ injects native `model_provider="cliproxy"` override
|
||||
→ pins CODEX_HOME to native `~/.codex` unless `CCSXP_CODEX_HOME` is set
|
||||
→ repairs `[model_providers.cliproxy]` in the active Codex `config.toml`
|
||||
→ preserves valid custom `base_url` values for remote or non-default CLIProxy endpoints
|
||||
→ injects the effective CCS CLIProxy auth token into the provider's configured `env_key`
|
||||
→ ignores the configured CCS default account/profile and stays in native Codex default mode
|
||||
```
|
||||
|
||||
If a user launches CCS through a custom shim instead of the built-in package bins, target
|
||||
resolution falls back to `argv[0]` aliases from `CCS_TARGET_ALIASES` or legacy
|
||||
`CCS_CODEX_ALIASES`:
|
||||
|
||||
```bash
|
||||
ln -s /path/to/ccs /path/to/mycodex
|
||||
CCS_TARGET_ALIASES='codex=mycodex'
|
||||
# Legacy fallback:
|
||||
CCS_CODEX_ALIASES='mycodex'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registry and Lookup
|
||||
|
||||
The target registry is a simple map-based store for adapters:
|
||||
|
||||
```typescript
|
||||
// src/targets/target-registry.ts
|
||||
|
||||
const adapters = new Map<TargetType, TargetAdapter>();
|
||||
|
||||
export function registerTarget(adapter: TargetAdapter): void {
|
||||
adapters.set(adapter.type, adapter);
|
||||
}
|
||||
|
||||
export function getTarget(type: TargetType): TargetAdapter {
|
||||
const adapter = adapters.get(type);
|
||||
if (!adapter) {
|
||||
throw new Error(`Unknown target "${type}"`);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export function getDefaultTarget(): TargetAdapter {
|
||||
return getTarget('claude');
|
||||
}
|
||||
```
|
||||
|
||||
### Adapter Registration
|
||||
|
||||
At startup, adapters self-register:
|
||||
|
||||
```typescript
|
||||
// src/ccs.ts (initialization)
|
||||
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
registerTarget(new CodexAdapter());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Flow
|
||||
|
||||
### Step-by-Step
|
||||
|
||||
```
|
||||
1. Parse command-line arguments
|
||||
└─ args: ['--target', 'droid', 'glm']
|
||||
|
||||
2. Resolve target type
|
||||
└─ resolveTargetType(args) → 'droid'
|
||||
└─ stripTargetFlag(args) → ['glm']
|
||||
|
||||
3. Detect and resolve profile
|
||||
└─ detectProfile(['glm']) → { profile: 'glm', ... }
|
||||
└─ Load credentials from config/CLIProxy/env
|
||||
|
||||
4. Build credentials object
|
||||
└─ TargetCredentials {
|
||||
baseUrl: '...',
|
||||
apiKey: '...',
|
||||
model: 'claude-opus-4-6',
|
||||
envVars: { CCS_PROFILE_NAME: 'glm', ... }
|
||||
}
|
||||
|
||||
5. Get target adapter
|
||||
└─ getTarget('droid') → DroidAdapter instance
|
||||
|
||||
6. Prepare credentials
|
||||
└─ adapter.prepareCredentials(creds)
|
||||
└─ DroidAdapter: writes to ~/.factory/settings.json
|
||||
|
||||
7. Build spawn arguments
|
||||
└─ adapter.buildArgs('glm', []) → ['-m', 'custom:ccs-glm']
|
||||
|
||||
8. Build environment
|
||||
└─ adapter.buildEnv(creds, profileType) → process.env
|
||||
|
||||
9. Spawn target CLI
|
||||
└─ adapter.exec(spawnArgs, env)
|
||||
└─ exec spawn('droid', ['-m', 'custom:ccs-glm', ...])
|
||||
|
||||
10. Replace current process
|
||||
└─ Child process inherits stdio
|
||||
└─ Signal handlers propagate to child
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Target
|
||||
|
||||
To support a new CLI (e.g., MyAI CLI), follow this pattern:
|
||||
|
||||
### 1. Create Adapter Class
|
||||
|
||||
```typescript
|
||||
// src/targets/myai-adapter.ts
|
||||
|
||||
export class MyAiAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'myai';
|
||||
readonly displayName = 'MyAI CLI';
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null {
|
||||
const path = which.sync('myai', { nothrow: true });
|
||||
if (!path) return null;
|
||||
return { path, needsShell: process.platform === 'win32' };
|
||||
}
|
||||
|
||||
async prepareCredentials(creds: TargetCredentials): Promise<void> {
|
||||
// Write to ~/.myai/config or similar
|
||||
}
|
||||
|
||||
buildArgs(profile: string, userArgs: string[]): string[] {
|
||||
return ['-p', profile, ...userArgs];
|
||||
}
|
||||
|
||||
buildEnv(creds: TargetCredentials, _profileType: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
MYAI_API_KEY: creds.apiKey,
|
||||
MYAI_API_URL: creds.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
exec(args: string[], env: NodeJS.ProcessEnv): void {
|
||||
const myaiPath = this.detectBinary()?.path;
|
||||
if (!myaiPath) {
|
||||
console.error('[X] MyAI CLI not found');
|
||||
process.exit(1);
|
||||
}
|
||||
spawn(myaiPath, args, { stdio: 'inherit', env });
|
||||
}
|
||||
|
||||
supportsProfileType(profileType: string): boolean {
|
||||
return true; // or implement specific logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Update Type Definition
|
||||
|
||||
```typescript
|
||||
// src/targets/target-adapter.ts
|
||||
|
||||
export type TargetType = 'claude' | 'droid' | 'codex' | 'myai';
|
||||
```
|
||||
|
||||
### 3. Register in ccs.ts
|
||||
|
||||
```typescript
|
||||
registerTarget(new MyAiAdapter());
|
||||
```
|
||||
|
||||
### 4. Update Documentation
|
||||
|
||||
- Add to [Codebase Summary](../codebase-summary.md)
|
||||
- Update Code Standards adapter examples
|
||||
- Document CLI-specific behavior
|
||||
|
||||
---
|
||||
|
||||
## Cross-Platform Considerations
|
||||
|
||||
### Windows Shell Detection
|
||||
|
||||
Both adapters check for shell-requiring binaries:
|
||||
|
||||
```typescript
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(binaryPath);
|
||||
|
||||
if (needsShell) {
|
||||
const cmdString = [binaryPath, ...args].map(escapeShellArg).join(' ');
|
||||
spawn(cmdString, { shell: true, stdio: 'inherit' });
|
||||
} else {
|
||||
spawn(binaryPath, args, { stdio: 'inherit' });
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variable Escaping
|
||||
|
||||
Arguments passed to shell are escaped to prevent injection:
|
||||
|
||||
```typescript
|
||||
export function escapeShellArg(arg: string): string {
|
||||
// Wrap in quotes and escape internal quotes
|
||||
return `"${arg.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
```
|
||||
|
||||
### Signal Handling
|
||||
|
||||
Both adapters propagate signals from parent to child:
|
||||
|
||||
```typescript
|
||||
const onSigInt = () => child.kill('SIGINT');
|
||||
const onSigTerm = () => child.kill('SIGTERM');
|
||||
process.once('SIGINT', onSigInt);
|
||||
process.once('SIGTERM', onSigTerm);
|
||||
|
||||
child.on('exit', () => {
|
||||
process.removeListener('SIGINT', onSigInt);
|
||||
process.removeListener('SIGTERM', onSigTerm);
|
||||
});
|
||||
```
|
||||
|
||||
This ensures CTRL+C and graceful shutdowns work correctly.
|
||||
|
||||
---
|
||||
|
||||
## Testing Target Adapters
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
describe('ClaudeAdapter', () => {
|
||||
it('detects Claude CLI', () => {
|
||||
const adapter = new ClaudeAdapter();
|
||||
const binary = adapter.detectBinary();
|
||||
expect(binary).not.toBeNull();
|
||||
});
|
||||
|
||||
it('builds env with credentials', () => {
|
||||
const adapter = new ClaudeAdapter();
|
||||
const env = adapter.buildEnv({
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
apiKey: 'sk-ant-...',
|
||||
model: 'claude-opus-4-6',
|
||||
}, 'cliproxy');
|
||||
|
||||
expect(env['ANTHROPIC_AUTH_TOKEN']).toBe('sk-ant-...');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
# Test Claude adapter
|
||||
ccs --target claude help
|
||||
|
||||
# Test Droid adapter (if installed)
|
||||
ccs --target droid help
|
||||
|
||||
# Test Codex adapter (if installed)
|
||||
ccs --target codex
|
||||
ccs-codex
|
||||
ccsxp
|
||||
|
||||
# Test argv[0] detection
|
||||
ccs-droid help
|
||||
ccsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Codebase Summary](../codebase-summary.md) — Module structure
|
||||
- [Code Standards](../code-standards.md) — Adapter pattern guidelines
|
||||
- [System Architecture Index](./index.md) — Overall system design
|
||||
[`src/targets/target-resolver.ts`](../../src/targets/target-resolver.ts) selects
|
||||
the runtime in this order:
|
||||
|
||||
1. `--target <name>` or `--target=<name>`; if repeated, the last flag wins.
|
||||
2. A trusted package runtime entrypoint identified by
|
||||
`CCS_INTERNAL_ENTRY_TARGET`.
|
||||
3. The invoked binary name, including built-in or configured aliases.
|
||||
4. A persisted per-profile target.
|
||||
5. `claude`.
|
||||
|
||||
All `--target` flags are removed before the remaining arguments reach the
|
||||
runtime. Parsing stops at the `--` option terminator.
|
||||
|
||||
The built-in runtime aliases are derived from target metadata:
|
||||
|
||||
| Target | Built-in aliases |
|
||||
| --- | --- |
|
||||
| Claude Code | Base `ccs` command |
|
||||
| Factory Droid | `ccs-droid`, `ccsd` |
|
||||
| Codex CLI | `ccs-codex`, `ccsx`, `ccsxp` |
|
||||
|
||||
Custom aliases use `CCS_TARGET_ALIASES` with entries such as
|
||||
`droid=team-droid;codex=team-codex`. The target-specific legacy environment
|
||||
variables remain compatibility inputs. Alias values are validated and cannot
|
||||
replace reserved package binary names.
|
||||
|
||||
## Runtime Compatibility
|
||||
|
||||
Adapter-level checks are deliberately conservative. Flow-specific compatibility
|
||||
is evaluated in
|
||||
[`src/targets/target-runtime-compatibility.ts`](../../src/targets/target-runtime-compatibility.ts),
|
||||
which has the provider and bridge context needed for an accurate decision.
|
||||
|
||||
| Profile flow | Claude | Droid | Codex |
|
||||
| --- | --- | --- | --- |
|
||||
| Native default | Supported | Conditional: requires resolved `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` | Supported |
|
||||
| Settings/API profile | Supported | Supported | Codex CLIProxy bridge only |
|
||||
| CLIProxy profile | Supported | Supported | Non-composite `codex` provider only |
|
||||
| Claude account | Supported | Not supported | Not supported |
|
||||
| Copilot | Supported | Not supported | Not supported |
|
||||
| Cursor local proxy | Supported | Not supported | Not supported |
|
||||
|
||||
When changing compatibility, update both the runtime evaluator and its focused
|
||||
tests. The authoritative coverage is in
|
||||
[`tests/unit/targets/target-runtime-compatibility.test.ts`](../../tests/unit/targets/target-runtime-compatibility.test.ts).
|
||||
|
||||
The compatibility evaluator accepts Droid's native-default flow, but adapter
|
||||
preparation still validates the resolved credentials. A default Droid launch
|
||||
cannot proceed without both a non-empty base URL and auth token.
|
||||
|
||||
## Credential and Configuration Boundaries
|
||||
|
||||
### Claude Code
|
||||
|
||||
[`src/targets/claude-adapter.ts`](../../src/targets/claude-adapter.ts) delivers
|
||||
resolved provider values through the child environment. Native account and
|
||||
default launches remove stale Anthropic routing variables before execution.
|
||||
Browser and WebSearch launch preparation may add runtime-specific arguments and
|
||||
environment values.
|
||||
|
||||
The adapter does not persist provider credentials to Claude settings.
|
||||
|
||||
### Factory Droid
|
||||
|
||||
[`src/targets/droid-adapter.ts`](../../src/targets/droid-adapter.ts) validates
|
||||
the resolved base URL and token, then delegates the target-owned write to
|
||||
[`src/targets/droid-config-manager.ts`](../../src/targets/droid-config-manager.ts).
|
||||
That manager updates the CCS custom model and active model in Factory settings.
|
||||
The adapter passes user arguments through; it does not inject a `-m` selector.
|
||||
|
||||
Factory settings are a persistent user-owned surface. Writes must preserve
|
||||
unrelated settings and use the configuration manager rather than direct JSON
|
||||
replacement.
|
||||
|
||||
### Codex CLI
|
||||
|
||||
[`src/targets/codex-adapter.ts`](../../src/targets/codex-adapter.ts) keeps normal
|
||||
CCS-backed launches transient:
|
||||
|
||||
- native default sessions keep native Codex authentication and configuration;
|
||||
- CCS-backed sessions use `-c key=value` overrides for a Responses-compatible
|
||||
runtime provider;
|
||||
- the resolved API key is supplied through the provider's environment key, not
|
||||
on the command line;
|
||||
- stale Anthropic and nested Codex-session variables are removed before spawn.
|
||||
|
||||
The `ccsxp` shortcut is the explicit exception. It may repair the dedicated
|
||||
`cliproxy` provider block in the active Codex configuration through
|
||||
[`src/targets/codex-cliproxy-provider-config.ts`](../../src/targets/codex-cliproxy-provider-config.ts).
|
||||
That repair preserves a valid custom base URL and the configured environment-key
|
||||
name. General Codex launches must not rewrite `config.toml`.
|
||||
|
||||
## Execution Invariants
|
||||
|
||||
The dispatcher owns the order of operations:
|
||||
|
||||
1. parse CCS-owned arguments and resolve the target;
|
||||
2. resolve the profile and provider credentials;
|
||||
3. evaluate target/profile/provider compatibility;
|
||||
4. detect the target binary;
|
||||
5. call `prepareCredentials`;
|
||||
6. build target arguments and environment;
|
||||
7. execute the child runtime.
|
||||
|
||||
Flow implementations live under
|
||||
[`src/dispatcher/flows/`](../../src/dispatcher/flows/). Shared target execution
|
||||
logic lives in
|
||||
[`src/dispatcher/target-executor.ts`](../../src/dispatcher/target-executor.ts).
|
||||
|
||||
All adapters must preserve these invariants:
|
||||
|
||||
- user credentials never appear in documented examples, logs, or spawn
|
||||
arguments;
|
||||
- target-owned persistent writes are explicit and scoped;
|
||||
- stale routing variables from another runtime do not leak into the child;
|
||||
- Windows wrapper handling does not use a shell unless the wrapper format
|
||||
requires one;
|
||||
- child signals and exit behavior propagate to the CCS process;
|
||||
- binary and launch failures run registered cleanup before exit.
|
||||
|
||||
## Adding or Changing a Target
|
||||
|
||||
Change the smallest complete set:
|
||||
|
||||
1. update `TargetType` and the adapter contract only when required;
|
||||
2. add target metadata and aliases in `target-metadata.ts`;
|
||||
3. implement and register the adapter through `src/targets/index.ts`;
|
||||
4. add flow-aware compatibility rules;
|
||||
5. add focused resolver, adapter, compatibility, and integration tests;
|
||||
6. update CLI help and dashboard controls if the target becomes user
|
||||
configurable.
|
||||
|
||||
Start with these test suites:
|
||||
|
||||
- [`tests/unit/targets/target-resolver.test.ts`](../../tests/unit/targets/target-resolver.test.ts)
|
||||
- [`tests/unit/targets/target-registry.test.ts`](../../tests/unit/targets/target-registry.test.ts)
|
||||
- [`tests/unit/targets/target-runtime-compatibility.test.ts`](../../tests/unit/targets/target-runtime-compatibility.test.ts)
|
||||
- target-specific tests under
|
||||
[`tests/unit/targets/`](../../tests/unit/targets/)
|
||||
|
||||
Do not describe a target as supported from adapter registration alone. Support
|
||||
requires a compatible profile flow, safe credential delivery, binary detection,
|
||||
execution behavior, and tests for the full combination.
|
||||
+123
-130
@@ -1,96 +1,85 @@
|
||||
# WebSearch Configuration Guide
|
||||
|
||||
Last Updated: 2026-04-11
|
||||
CCS provides a local `WebSearch` MCP tool for managed Claude launches that use a
|
||||
third-party provider. Native Claude sessions keep Anthropic's native search
|
||||
behavior.
|
||||
|
||||
CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API.
|
||||
## Launch Contract
|
||||
|
||||
## How WebSearch Works
|
||||
For a third-party Claude launch, CCS:
|
||||
|
||||
### Native Claude Accounts
|
||||
1. suppresses the native `WebSearch` tool because the third-party backend cannot
|
||||
execute it;
|
||||
2. when WebSearch is enabled, adds a short steering prompt that prefers the CCS
|
||||
MCP `WebSearch` tool;
|
||||
3. when WebSearch is enabled, installs the managed MCP server and adds the
|
||||
`ccs-websearch` entry to the applicable Claude configuration;
|
||||
4. searches enabled and ready providers in deterministic order.
|
||||
|
||||
Native Claude subscription accounts still use Anthropic's server-side WebSearch directly.
|
||||
Enabled launches fail closed if the MCP runtime or configuration cannot be
|
||||
prepared. This prevents a launch where native search is suppressed but the
|
||||
managed replacement is missing. When `websearch.enabled` is `false`, CCS skips
|
||||
MCP provisioning but still suppresses native WebSearch for third-party
|
||||
profiles; the model can use ordinary network or shell tools when allowed.
|
||||
|
||||
### Third-Party Profiles
|
||||
Claude subcommands that reject session flags are passed through without
|
||||
WebSearch argument injection.
|
||||
|
||||
Third-party profiles cannot execute Anthropic's server-side WebSearch because the tool never reaches their backend. CCS now handles that by provisioning a first-class local MCP tool when the managed runtime is available, suppressing native `WebSearch` for those launches, appending a short launch-time steering hint, and running real local search providers directly.
|
||||
Implementation sources:
|
||||
|
||||
## Architecture
|
||||
- [`src/utils/websearch/mcp-installer.ts`](../src/utils/websearch/mcp-installer.ts)
|
||||
- [`src/utils/websearch/claude-tool-args.ts`](../src/utils/websearch/claude-tool-args.ts)
|
||||
- [`src/dispatcher/flows/settings-flow.ts`](../src/dispatcher/flows/settings-flow.ts)
|
||||
- [`lib/mcp/ccs-websearch-server.cjs`](../lib/mcp/ccs-websearch-server.cjs)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Claude Code CLI │
|
||||
│ │
|
||||
│ Search Request │
|
||||
│ │ │
|
||||
│ ├── Native Claude Account? → Anthropic WebSearch API │
|
||||
│ │ │
|
||||
│ └── Third-party Profile? → native WebSearch disabled │
|
||||
│ │ │
|
||||
│ ├── CCS MCP tool when ready │
|
||||
│ │ ccs-websearch.WebSearch │
|
||||
│ │ │ │
|
||||
│ │ ├── 1. Exa │
|
||||
│ │ ├── 2. Tavily │
|
||||
│ │ ├── 3. Brave │
|
||||
│ │ ├── 4. SearXNG │
|
||||
│ │ ├── 5. DuckDuckGo│
|
||||
│ │ └── 6. Legacy CLI│
|
||||
│ │ fallback │
|
||||
│ │ (Gemini/ │
|
||||
│ │ OpenCode/│
|
||||
│ │ Grok) │
|
||||
│ └── Bash/network fallback │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
## Provider Order, Runtime Eligibility, and Dashboard Status
|
||||
|
||||
## Why This Changed
|
||||
The managed runtime tries eligible providers sequentially:
|
||||
|
||||
The previous design asked another model CLI to perform web search and summarize the answer. A later compatibility path also depended on a denied native-tool hook. Both were brittle:
|
||||
1. Exa
|
||||
2. Tavily
|
||||
3. Brave Search
|
||||
4. SearXNG
|
||||
5. DuckDuckGo
|
||||
6. Antigravity CLI
|
||||
7. Gemini CLI compatibility fallback
|
||||
8. OpenCode
|
||||
9. Grok CLI
|
||||
|
||||
- CLI syntax changed upstream
|
||||
- auth state varied per tool
|
||||
- prompt/tool behavior drifted across releases
|
||||
- hook-shaped denial output produced awkward host UX
|
||||
The first successful provider wins. Temporarily failing providers can enter a
|
||||
bounded cooldown and be skipped on later calls. Runtime attempt eligibility is
|
||||
defined in
|
||||
[`lib/hooks/websearch-transformer.cjs`](../lib/hooks/websearch-transformer.cjs);
|
||||
do not duplicate that list in other architecture docs.
|
||||
|
||||
The new flow matches the `goclaw` model more closely: web search is treated as a first-class deterministic capability, not an LLM-to-LLM workaround or a denied native tool call.
|
||||
The transformer and dashboard answer related but different questions:
|
||||
|
||||
When provisioned, the managed MCP tool is exposed as `ccs-websearch.WebSearch`, not a generic `search` helper. That naming is deliberate: it gives Claude a tool that matches the native `WebSearch` concept more directly, which should reduce cases where the model reaches for ad hoc Bash or `curl` fetches instead.
|
||||
| Provider | Transformer attempts when | Dashboard reports available when |
|
||||
| --- | --- | --- |
|
||||
| Exa | Enabled and `EXA_API_KEY` is present. | Enabled and the key is available through the active process or enabled Global Env. |
|
||||
| Tavily | Enabled and `TAVILY_API_KEY` is present. | Enabled and the key is available through the active process or enabled Global Env. |
|
||||
| Brave Search | Enabled and `BRAVE_API_KEY` is present. | Enabled and the key is available through the active process or enabled Global Env. |
|
||||
| SearXNG | Enabled and a base URL is present. | Enabled with a valid normalized base URL. |
|
||||
| DuckDuckGo | Enabled. | Enabled. |
|
||||
| Antigravity | Enabled and the `agy` executable is available. | Enabled and the CLI is installed. |
|
||||
| Gemini compatibility | Enabled and the `gemini` executable is available. | Enabled, installed, and authenticated. |
|
||||
| OpenCode | Enabled and the `opencode` executable is available. | Enabled and the CLI is installed. |
|
||||
| Grok | Enabled and the `grok` executable is available. | Enabled, installed, and `GROK_API_KEY` is available. |
|
||||
|
||||
CCS also appends a third-party-only `--append-system-prompt` hint telling Claude to prefer that managed `WebSearch` tool for web lookups and current-information requests. This is soft steering only: if the user explicitly asks for shell commands, or the tool is unavailable, Claude can still fall back to Bash/network tools.
|
||||
That shared launch helper applies to normal third-party settings profiles, CLIProxy/Copilot-backed Claude launches, and CCS headless/delegation runs that execute through a settings profile.
|
||||
|
||||
`websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party profiles. That native tool cannot be satisfied by Exa, Tavily, Brave, DuckDuckGo, or other non-Anthropic backends, so CCS avoids sending a broken native-tool request and lets Claude fall back to normal shell/network tools instead.
|
||||
|
||||
## Providers
|
||||
|
||||
| Provider | Type | Setup | Default | Notes |
|
||||
|----------|------|-------|---------|-------|
|
||||
| Exa | HTTP API | `EXA_API_KEY` | No | High-quality API search with extracted content |
|
||||
| Tavily | HTTP API | `TAVILY_API_KEY` | No | Agent-oriented search API |
|
||||
| Brave Search | HTTP API | `BRAVE_API_KEY` | No | Cleaner snippets and metadata |
|
||||
| SearXNG | JSON API | `providers.searxng.url` | No | Self-hosted/public SearXNG backend via `/search?format=json` |
|
||||
| DuckDuckGo | HTML fetch | None | Yes | Built-in zero-setup fallback |
|
||||
| Antigravity (agy) | LLM CLI | `curl -fsSL https://antigravity.google/cli/install.sh \| bash` | No | Recommended LLM CLI fallback (Gemini CLI successor) |
|
||||
| Gemini CLI | LLM CLI | Deprecated, use Antigravity (agy) | No | Deprecated. Google retired the gemini CLI on 2026-06-18 |
|
||||
| OpenCode | LLM CLI | `curl -fsSL https://opencode.ai/install \| bash` | No | Optional compatibility fallback |
|
||||
| Grok CLI | LLM CLI | `npm i -g @vibe-kit/grok-cli` + `GROK_API_KEY` | No | Optional compatibility fallback |
|
||||
DuckDuckGo is the default zero-setup provider. API-backed and CLI fallback
|
||||
providers are disabled by default. Dashboard availability and setup guidance
|
||||
are computed separately by
|
||||
[`src/utils/websearch/status.ts`](../src/utils/websearch/status.ts); they do not
|
||||
change the transformer's attempt predicate.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Via Dashboard
|
||||
Use `ccs config` and open `Settings` → `WebSearch`, or edit the `websearch`
|
||||
section in the unified CCS configuration:
|
||||
|
||||
Open `ccs config` → `Settings` → `WebSearch`.
|
||||
|
||||
- Enable Exa, Tavily, Brave, SearXNG, or DuckDuckGo in the backend chain
|
||||
- Configure the SearXNG base URL (for example `https://search.example.com`) when SearXNG is enabled
|
||||
Do not include `/search`, embedded credentials, query parameters, or URL fragments. CCS appends `/search?format=json`.
|
||||
- Set or rotate Exa, Tavily, and Brave API keys directly inside each provider card
|
||||
- Saved keys are persisted in `global_env` and injected at runtime, so readiness updates from the same screen
|
||||
- Review whether any legacy fallback CLIs are still enabled in config
|
||||
|
||||
### Via Config File
|
||||
|
||||
Edit `~/.ccs/config.yaml`:
|
||||
The runtime schema supports Antigravity through `providers.agy`; the current
|
||||
dashboard editor does not expose that provider, so configure it in
|
||||
`config.yaml`.
|
||||
|
||||
```yaml
|
||||
websearch:
|
||||
@@ -129,80 +118,84 @@ websearch:
|
||||
timeout: 55
|
||||
```
|
||||
|
||||
Note: `enabled: false` stops provisioning the managed local `ccs-websearch.WebSearch` runtime. It does not re-enable Anthropic's native `WebSearch` for third-party backends.
|
||||
The schema is
|
||||
[`src/config/schemas/websearch.ts`](../src/config/schemas/websearch.ts), and
|
||||
defaults are in
|
||||
[`src/config/schemas/unified-config.ts`](../src/config/schemas/unified-config.ts).
|
||||
Deprecated top-level WebSearch fields remain load-compatible but must not be
|
||||
used for new configuration.
|
||||
|
||||
## Environment Variables
|
||||
### SearXNG URL
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `EXA_API_KEY` | Enables Exa when `providers.exa.enabled: true` |
|
||||
| `TAVILY_API_KEY` | Enables Tavily when `providers.tavily.enabled: true` |
|
||||
| `BRAVE_API_KEY` | Enables Brave Search when `providers.brave.enabled: true` |
|
||||
| `CCS_WEBSEARCH_SEARXNG_URL` | Runtime URL used when `providers.searxng.enabled: true` |
|
||||
| `CCS_WEBSEARCH_SEARXNG_MAX_RESULTS` | Optional runtime override for SearXNG result count (clamped 1..10) |
|
||||
| `GROK_API_KEY` | Required only for legacy Grok CLI fallback |
|
||||
| `CCS_WEBSEARCH_SKIP` | Disable the CCS local WebSearch runtime for the current process; third-party launches still keep native Anthropic `WebSearch` disabled |
|
||||
| `CCS_DEBUG` | Verbose WebSearch runtime logging |
|
||||
| `CCS_WEBSEARCH_TRACE` | Write opt-in JSONL trace records under `~/.ccs/logs/websearch-trace.jsonl` |
|
||||
| `CCS_WEBSEARCH_TRACE_FILE` | Override the trace file path (must stay inside `~/.ccs/`, your system temp directory, or `/var/log`) |
|
||||
Configure the instance base URL, for example
|
||||
`https://search.example.invalid`. Do not include `/search`, credentials, query
|
||||
parameters, or a URL fragment. CCS normalizes the base and calls the JSON search
|
||||
endpoint.
|
||||
|
||||
## Managed Runtime Files
|
||||
### Dashboard-managed API keys
|
||||
|
||||
- `~/.claude.json` → CCS manages `mcpServers.ccs-websearch`
|
||||
- `~/.ccs/mcp/ccs-websearch-server.cjs` → local MCP server binary
|
||||
- `~/.ccs/hooks/websearch-transformer.cjs` → shared provider runtime plus legacy compatibility fallback
|
||||
The dashboard stores supported provider keys in `global_env`. They are
|
||||
available to WebSearch only when global environment injection is enabled.
|
||||
Shell-provided environment values remain supported. Never place real keys in
|
||||
documentation, tests, or committed configuration.
|
||||
|
||||
## Troubleshooting
|
||||
## Managed Files
|
||||
|
||||
### WebSearch says "Ready (DuckDuckGo)"
|
||||
In the default user layout, CCS manages:
|
||||
|
||||
That is expected. DuckDuckGo is the default zero-setup backend.
|
||||
- `~/.claude.json` → `mcpServers.ccs-websearch`
|
||||
- `~/.ccs/mcp/ccs-websearch-server.cjs`
|
||||
- `~/.ccs/hooks/websearch-transformer.cjs`
|
||||
|
||||
### Exa, Tavily, or Brave is enabled but not ready
|
||||
CCS installation and test paths can differ because configuration helpers honor
|
||||
the active CCS and Claude home locations. Provisioning uses a lock and
|
||||
preserves unrelated MCP server entries. Malformed Claude configuration is not
|
||||
overwritten.
|
||||
|
||||
Set the matching API key in the WebSearch dashboard card, or export it in the environment that launches CCS, then refresh status:
|
||||
## Runtime Environment
|
||||
|
||||
```bash
|
||||
export EXA_API_KEY="your-api-key"
|
||||
# or: export TAVILY_API_KEY="your-api-key"
|
||||
# or: export BRAVE_API_KEY="your-api-key"
|
||||
ccs config
|
||||
```
|
||||
[`src/utils/websearch/hook-env.ts`](../src/utils/websearch/hook-env.ts) converts
|
||||
the resolved configuration into runtime environment values. Provider API keys
|
||||
remain environment inputs; boolean and limit variables describe provider
|
||||
selection.
|
||||
|
||||
If the dashboard says the key is stored but still not ready, check whether `Settings -> Global Env` is disabled. WebSearch reuses that injection path for dashboard-managed keys.
|
||||
Operational overrides:
|
||||
|
||||
### SearXNG is enabled but not ready
|
||||
| Variable | Effect |
|
||||
| --- | --- |
|
||||
| `CCS_DEBUG` | Enables verbose diagnostics and WebSearch trace collection. |
|
||||
| `CCS_WEBSEARCH_TRACE` | Enables WebSearch JSONL trace collection. |
|
||||
| `CCS_WEBSEARCH_TRACE_FILE` | Requests a trace path within an allowed CCS log, system temporary, or `/var/log` boundary. |
|
||||
|
||||
1. Confirm the configured base URL is valid (for example `https://search.example.com`)
|
||||
2. Confirm the instance exposes `GET /search?q=<query>&format=json`
|
||||
3. If the hook reports `SearXNG returned 403: format=json is disabled on this instance`, enable JSON format on that SearXNG deployment or switch to another backend
|
||||
An unsafe trace-file override is ignored. Trace writes are best effort and do
|
||||
not change launch or search results.
|
||||
|
||||
### I still want Gemini/OpenCode/Grok fallback
|
||||
## Diagnostics
|
||||
|
||||
Those providers remain supported, but they are no longer the primary path. Enable them explicitly in `config.yaml` if you want them as last-resort fallback.
|
||||
By default, trace records are written under
|
||||
`~/.ccs/logs/websearch-trace.jsonl`. They correlate launch preparation, MCP
|
||||
exposure, tool calls, provider attempts, provider success/failure, and session
|
||||
summaries.
|
||||
|
||||
### I need to see whether CCS exposed WebSearch or the model bypassed it
|
||||
Normal trace metadata uses a query fingerprint and length instead of the raw
|
||||
query. Provider-failure records can also include `error` detail returned by a
|
||||
provider implementation, including HTTP response excerpts or CLI stderr.
|
||||
That provider-generated detail is not guaranteed to exclude query text or
|
||||
other sensitive content unless the implementation redacts it. Treat the trace
|
||||
file as sensitive operational data.
|
||||
|
||||
Run the launch with `CCS_WEBSEARCH_TRACE=1` (or `CCS_DEBUG=1`). CCS writes a JSONL trace to `~/.ccs/logs/websearch-trace.jsonl` with:
|
||||
For delegated/headless sessions, a likely-bypass summary means the tool was
|
||||
exposed but no WebSearch call occurred and another allowed tool path was used.
|
||||
|
||||
1. source-side launch records from CCS (`ccs_websearch_launch`)
|
||||
2. MCP exposure and call records (`mcp_initialize`, `mcp_tools_list`, `mcp_tool_call_*`)
|
||||
3. provider attempt and winner records (`websearch_provider_attempt`, `websearch_provider_success`)
|
||||
4. session summaries (`mcp_session_summary`, and headless `headless_websearch_summary` when applicable)
|
||||
When search is unavailable:
|
||||
|
||||
Queries are fingerprinted (`queryHash`, `queryLength`) instead of logged raw by default. For headless/delegation runs, `headless_websearch_summary.likelyBypassed=true` means the MCP tool was exposed, no WebSearch call occurred, and Claude fell back to `Bash` or `WebFetch`.
|
||||
1. confirm `websearch.enabled` and at least one provider are enabled;
|
||||
2. check readiness in the dashboard;
|
||||
3. verify API keys or the SearXNG base URL without printing secrets;
|
||||
4. enable `CCS_WEBSEARCH_TRACE=1` for one launch;
|
||||
5. inspect provider attempt and cooldown events.
|
||||
|
||||
### WebSearch returns no results
|
||||
|
||||
1. Check `websearch.enabled: true`
|
||||
2. Keep DuckDuckGo enabled unless you have a strong reason to disable it
|
||||
3. If using Exa, Tavily, or Brave, verify the matching API key
|
||||
4. Run with `CCS_DEBUG=1` for runtime logs, or `CCS_WEBSEARCH_TRACE=1` for correlated launch/MCP/provider traces
|
||||
5. If DuckDuckGo returns a non-result HTML error, retry later or enable another provider. CCS now treats that as a provider failure instead of a false empty result.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- API keys entered from the dashboard are stored in `~/.ccs/config.yaml` under `global_env` and injected as environment variables at runtime
|
||||
- Shell-exported keys still work and are detected as external environment input
|
||||
- Never commit API keys to version control
|
||||
- Use the dashboard only on trusted machines, and protect `~/.ccs/config.yaml` with normal user-level filesystem permissions
|
||||
Focused coverage lives under
|
||||
[`tests/unit/utils/websearch/`](../tests/unit/utils/websearch/),
|
||||
[`tests/unit/hooks/`](../tests/unit/hooks/), and
|
||||
[`tests/unit/targets/settings-profile-websearch-launch.test.ts`](../tests/unit/targets/settings-profile-websearch-launch.test.ts).
|
||||
@@ -49,7 +49,7 @@
|
||||
"src/cliproxy/executor/auth-coordinator.ts:176",
|
||||
"src/cliproxy/executor/auth-coordinator.ts:317",
|
||||
"src/cliproxy/executor/browser-launch-setup.ts:120",
|
||||
"src/cliproxy/executor/index.ts:391",
|
||||
"src/cliproxy/executor/index.ts:390",
|
||||
"src/cliproxy/executor/lifecycle-manager.ts:129",
|
||||
"src/cliproxy/executor/lifecycle-manager.ts:162",
|
||||
"src/cliproxy/executor/lifecycle-manager.ts:59",
|
||||
@@ -161,14 +161,14 @@
|
||||
"src/cursor/cursor-translator.ts:189",
|
||||
"src/delegation/headless-executor.ts:126",
|
||||
"src/delegation/headless-executor.ts:141",
|
||||
"src/delegation/headless-executor.ts:270",
|
||||
"src/delegation/headless-executor.ts:679",
|
||||
"src/delegation/headless-executor.ts:272",
|
||||
"src/delegation/headless-executor.ts:683",
|
||||
"src/dispatcher/cli-argument-parser.ts:310",
|
||||
"src/dispatcher/cli-argument-parser.ts:314",
|
||||
"src/dispatcher/cli-argument-parser.ts:324",
|
||||
"src/dispatcher/cli-argument-parser.ts:328",
|
||||
"src/dispatcher/flows/default-flow.ts:76",
|
||||
"src/dispatcher/flows/settings-flow.ts:135",
|
||||
"src/dispatcher/flows/settings-flow.ts:133",
|
||||
"src/docker/docker-assets.ts:35",
|
||||
"src/docker/docker-executor.ts:154",
|
||||
"src/docker/docker-executor.ts:435",
|
||||
@@ -280,7 +280,7 @@
|
||||
"src/utils/shell-completion.ts:269",
|
||||
"src/utils/shell-completion.ts:288",
|
||||
"src/utils/shell-completion.ts:74",
|
||||
"src/utils/websearch/mcp-installer.ts:438",
|
||||
"src/utils/websearch/mcp-installer.ts:443",
|
||||
"src/utils/websearch/profile-hook-injector.ts:206",
|
||||
"src/web-server/index.ts:266",
|
||||
"src/web-server/services/claude-extension-binding-service.ts:144",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# CCS - Claude Code Switch (Bootstrap)
|
||||
# CCS - Claude Codex Switch (Bootstrap)
|
||||
# Delegates to Node.js implementation via npx
|
||||
# https://github.com/kaitranntt/ccs
|
||||
set -euo pipefail
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# CCS - Claude Code Switch (Bootstrap)
|
||||
# CCS - Claude Codex Switch (Bootstrap)
|
||||
# Delegates to Node.js implementation via npx
|
||||
# https://github.com/kaitranntt/ccs
|
||||
|
||||
|
||||
@@ -44,3 +44,29 @@ the first time via right-click then Open, or clear quarantine with
|
||||
`xattr -dr com.apple.quarantine "/Applications/CCS Bar.app"`. Developer ID
|
||||
signing + notarization (`CCS_BAR_SIGNING=developer-id`) is the public-launch
|
||||
path and is not required for ad-hoc distribution.
|
||||
|
||||
The app ships as `CCS-Bar.app.zip` on the floating `ccs-bar-latest` GitHub
|
||||
release. `macos-bar/VERSION` is the single semver source; bump it in the same PR
|
||||
when a new app version is required.
|
||||
|
||||
### Automatic release
|
||||
|
||||
`.github/workflows/bar-release.yml` publishes the asset after a push to `main`
|
||||
that changes `macos-bar/**`, or from a manual `workflow_dispatch` run whose
|
||||
selected ref is `main`. It runs on the dedicated self-hosted macOS runner
|
||||
labelled `ccs-bar`.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
From a macOS machine with Swift and `gh`:
|
||||
|
||||
```bash
|
||||
cd macos-bar
|
||||
./Scripts/package_app.sh
|
||||
gh release upload ccs-bar-latest dist/CCS-Bar.app.zip --clobber
|
||||
```
|
||||
|
||||
Pass a version to `package_app.sh` only when intentionally overriding
|
||||
`macos-bar/VERSION`. Ad-hoc signing is the default; use
|
||||
`CCS_BAR_SIGNING=developer-id` only with the configured Apple signing and
|
||||
notarization credentials.
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "8.8.1",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"version": "8.8.1-dev.22",
|
||||
"description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
"claude",
|
||||
@@ -81,6 +81,7 @@
|
||||
"test:unit": "bun test tests/unit",
|
||||
"test:npm": "bun test tests/npm/",
|
||||
"test:native": "bash tests/native/unix/edge-cases.sh",
|
||||
"test:runtime-matrix": "bun run build && CCS_RUNTIME_MATRIX=1 bun test tests/integration/proxy/runtime-transport-matrix.test.ts --timeout 60000",
|
||||
"test:e2e": "bun test tests/e2e/ --bail --timeout 60000",
|
||||
"report:hardening": "node scripts/hardening-inventory.js",
|
||||
"dev": "bun run build:server && node dist/ccs.js config --dev",
|
||||
|
||||
@@ -57,36 +57,9 @@ if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Hardening inventory freshness: the maintainability metrics artifact must be
|
||||
# regenerated within 30 days so the burndown stays current. Runs only after the
|
||||
# skip conditions above (CCS_SKIP_PREPUSH_GATE, detached HEAD, behind origin).
|
||||
HARDENING_JSON="docs/reports/hardening-inventory.json"
|
||||
if [[ ! -f "$HARDENING_JSON" ]]; then
|
||||
echo "[X] Missing $HARDENING_JSON."
|
||||
echo " Regenerate with: bun run report:hardening"
|
||||
exit 1
|
||||
fi
|
||||
HARDENING_TS=""
|
||||
# If the working-tree copy differs from HEAD (contributor regenerated but not
|
||||
# yet committed), use filesystem mtime; otherwise use the last commit time,
|
||||
# which is stable across CI clones (checkout resets mtimes) and so correctly
|
||||
# flags a stale committed artifact.
|
||||
if git diff --quiet -- "$HARDENING_JSON" 2>/dev/null && git diff --cached --quiet -- "$HARDENING_JSON" 2>/dev/null; then
|
||||
HARDENING_TS=$(git log -1 --format=%ct -- "$HARDENING_JSON" 2>/dev/null)
|
||||
else
|
||||
HARDENING_TS=$(stat -f %m "$HARDENING_JSON" 2>/dev/null || stat -c %Y "$HARDENING_JSON" 2>/dev/null)
|
||||
fi
|
||||
if [[ -n "$HARDENING_TS" ]]; then
|
||||
NOW_TS=$(date +%s)
|
||||
AGE_DAYS=$(( (NOW_TS - HARDENING_TS) / 86400 ))
|
||||
if (( AGE_DAYS > 30 )); then
|
||||
echo "[X] Hardening inventory is stale (${AGE_DAYS}d old; max 30d)."
|
||||
echo " Regenerate with: bun run report:hardening"
|
||||
echo " Then commit docs/reports/hardening-inventory.{json,md}."
|
||||
exit 1
|
||||
fi
|
||||
echo "[i] Hardening inventory fresh (${AGE_DAYS}d old; max 30d)."
|
||||
fi
|
||||
# Age does not prove that generated metrics match the checked-out source tree.
|
||||
# Compare both inventory artifacts byte-for-byte before expensive parity checks.
|
||||
node scripts/hardening-inventory.js --check
|
||||
|
||||
echo "[i] Running CI-parity local checks..."
|
||||
# `set -euo pipefail` above makes every step fail fast. Keep these commands
|
||||
@@ -94,6 +67,7 @@ echo "[i] Running CI-parity local checks..."
|
||||
bun run typecheck
|
||||
bun run lint
|
||||
bun run format:check
|
||||
bun run test:runtime-matrix
|
||||
bun run build:all
|
||||
bun run test:all
|
||||
CCS_E2E_SKIP_BUILD=1 bun run test:e2e
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Bash completion for CCS (Claude Code Switch)
|
||||
# Bash completion for CCS (Claude Codex Switch)
|
||||
# Compatible with bash 3.2+
|
||||
|
||||
_ccs_completion() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Fish completion for CCS (Claude Code Switch)
|
||||
# Fish completion for CCS (Claude Codex Switch)
|
||||
|
||||
function __fish_ccs_complete
|
||||
set -l tokens_before_current (commandline -opc)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# PowerShell completion for CCS (Claude Code Switch)
|
||||
# PowerShell completion for CCS (Claude Codex Switch)
|
||||
|
||||
function Invoke-CcsCompletionBackend {
|
||||
param(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#compdef ccs
|
||||
|
||||
# Zsh completion for CCS (Claude Code Switch)
|
||||
# Zsh completion for CCS (Claude Codex Switch)
|
||||
|
||||
_ccs() {
|
||||
local current
|
||||
|
||||
@@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { collectMaintainabilityMetrics } = require('./maintainability-metrics.js');
|
||||
const { isTestFile } = require('./runtime-source-classifier.js');
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const SRC_DIR = path.join(ROOT_DIR, 'src');
|
||||
@@ -98,7 +99,7 @@ function isSourceFile(filePath) {
|
||||
}
|
||||
|
||||
function isHotpath(filePath) {
|
||||
return HOTPATH_PATTERNS.some((pattern) => pattern.test(filePath));
|
||||
return !isTestFile(filePath) && HOTPATH_PATTERNS.some((pattern) => pattern.test(filePath));
|
||||
}
|
||||
|
||||
function walkFiles(dirPath) {
|
||||
@@ -450,7 +451,9 @@ function renderMarkdown(report) {
|
||||
lines.push(`| Sync fs occurrences (all) | ${report.syncFs.totalOccurrences} |`);
|
||||
lines.push(`| Sync fs files affected (all) | ${report.syncFs.filesAffected} |`);
|
||||
lines.push(`| Sync fs occurrences (runtime hotpaths) | ${report.syncFs.hotpathOccurrences} |`);
|
||||
lines.push(`| Sync fs files affected (runtime hotpaths) | ${report.syncFs.hotpathFilesAffected} |`);
|
||||
lines.push(
|
||||
`| Sync fs files affected (runtime hotpaths) | ${report.syncFs.hotpathFilesAffected} |`
|
||||
);
|
||||
lines.push(`| Legacy shim markers | ${report.legacyShim.totalMarkers} |`);
|
||||
lines.push(`| Legacy shim files affected | ${report.legacyShim.filesAffected} |`);
|
||||
lines.push('');
|
||||
@@ -547,10 +550,39 @@ function renderMarkdown(report) {
|
||||
|
||||
function main() {
|
||||
const report = buildReport();
|
||||
const jsonContent = JSON.stringify(report, null, 2) + '\n';
|
||||
const markdownContent = renderMarkdown(report);
|
||||
const checkOnly = process.argv.includes('--check');
|
||||
|
||||
if (checkOnly) {
|
||||
const staleReports = [
|
||||
[JSON_REPORT_PATH, jsonContent],
|
||||
[MD_REPORT_PATH, markdownContent],
|
||||
].filter(([reportPath, expected]) => {
|
||||
try {
|
||||
return fs.readFileSync(reportPath, 'utf8') !== expected;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
if (staleReports.length > 0) {
|
||||
console.error('[X] Hardening inventory does not match the current source tree:');
|
||||
for (const [reportPath] of staleReports) {
|
||||
console.error(` ${relativePath(reportPath)}`);
|
||||
}
|
||||
console.error(' Regenerate with: bun run report:hardening');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[OK] Hardening inventory matches the current source tree.');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(REPORT_DIR, { recursive: true });
|
||||
fs.writeFileSync(JSON_REPORT_PATH, JSON.stringify(report, null, 2) + '\n', 'utf8');
|
||||
fs.writeFileSync(MD_REPORT_PATH, renderMarkdown(report), 'utf8');
|
||||
fs.writeFileSync(JSON_REPORT_PATH, jsonContent, 'utf8');
|
||||
fs.writeFileSync(MD_REPORT_PATH, markdownContent, 'utf8');
|
||||
|
||||
const relJson = relativePath(JSON_REPORT_PATH);
|
||||
const relMd = relativePath(MD_REPORT_PATH);
|
||||
@@ -575,6 +607,7 @@ function main() {
|
||||
module.exports = {
|
||||
buildReport,
|
||||
collectSyncCallSites,
|
||||
isTestFile,
|
||||
renderMarkdown,
|
||||
stripComments,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { isTestFile } = require('./runtime-source-classifier.js');
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
||||
|
||||
@@ -79,14 +80,6 @@ function isSourceFile(filePath) {
|
||||
return SOURCE_EXTENSIONS.has(path.extname(filePath));
|
||||
}
|
||||
|
||||
function isTestFile(relPath) {
|
||||
return (
|
||||
/(?:^|\/)(?:__tests__|tests?)\//.test(relPath) ||
|
||||
/\.test\./.test(relPath) ||
|
||||
/\.spec\./.test(relPath)
|
||||
);
|
||||
}
|
||||
|
||||
function isCliUxExempt(relPath) {
|
||||
return CLI_UX_EXEMPT_PREFIXES.some(function (prefix) {
|
||||
return relPath.startsWith(prefix);
|
||||
@@ -350,6 +343,7 @@ module.exports = {
|
||||
classifyThrows: classifyThrows,
|
||||
countConsoleErrors: countConsoleErrors,
|
||||
hasCreateLogger: hasCreateLogger,
|
||||
isTestFile: isTestFile,
|
||||
countLoc: countLoc,
|
||||
TYPED_ERROR_CLASSES: TYPED_ERROR_CLASSES,
|
||||
TYPED_ADOPTION_SUBDOMAINS: TYPED_ADOPTION_SUBDOMAINS,
|
||||
|
||||
@@ -40,6 +40,7 @@ const slowTests = [
|
||||
'tests/unit/targets/settings-profile-websearch-launch.test.ts',
|
||||
'tests/unit/web-server/cursor-routes.test.ts',
|
||||
'tests/unit/web-server/websearch-routes.test.ts',
|
||||
'src/cliproxy/auth/__tests__/oauth-handler-gemini-backend-guidance.test.ts',
|
||||
];
|
||||
// CommonJS-heavy JS suites stay slow by default because many of them mutate
|
||||
// module cache or process state. Opt them into `test:fast` only after they are
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
function isTestFile(filePath) {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
return (
|
||||
/(^|\/)(__tests__|__mocks__|fixtures)(\/|$)/.test(normalizedPath) ||
|
||||
/(^|\/)(tests?|test-fixtures)(\/|$)/.test(normalizedPath) ||
|
||||
/\.(test|spec)\.[^.]+$/.test(normalizedPath)
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isTestFile,
|
||||
};
|
||||
@@ -10,22 +10,16 @@ import {
|
||||
} from '../../cliproxy/ai-providers/model-id-normalizer';
|
||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { ANTHROPIC_MODEL_ENV_KEYS } from '../../shared/extended-context-utils';
|
||||
import type { ProfileValidationIssue, ProfileValidationSummary } from './profile-types';
|
||||
|
||||
const MODEL_ENV_KEYS = [
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
] as const;
|
||||
|
||||
const ALLOWED_ANTHROPIC_ENV_KEYS = new Set<string>([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
// Written by profile-writer when --extra-models is supplied
|
||||
'ANTHROPIC_EXTRA_MODELS',
|
||||
...MODEL_ENV_KEYS,
|
||||
...ANTHROPIC_MODEL_ENV_KEYS,
|
||||
]);
|
||||
|
||||
function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null {
|
||||
@@ -118,7 +112,7 @@ export function validateApiProfileSettingsPayload(settings: unknown): ProfileVal
|
||||
}
|
||||
|
||||
const provider = resolveProviderFromBaseUrl(baseUrl);
|
||||
for (const modelKey of MODEL_ENV_KEYS) {
|
||||
for (const modelKey of ANTHROPIC_MODEL_ENV_KEYS) {
|
||||
const value = envObj[modelKey];
|
||||
if (typeof value !== 'string' || value.trim().length === 0) continue;
|
||||
const denyReason = getDeniedModelIdReasonForProvider(value, provider);
|
||||
|
||||
@@ -142,7 +142,7 @@ class AuthCommands {
|
||||
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)`
|
||||
` ${color('--bare', 'command')} Create clean profile without shared settings/instructions/resources`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--mode <mode>', 'command')} Shared resource mode for resources: shared|profile-local`
|
||||
@@ -169,10 +169,10 @@ class AuthCommands {
|
||||
);
|
||||
console.log(` Account logins, tokens, and .anthropic stay isolated for every profile.`);
|
||||
console.log(
|
||||
` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show <profile>', 'command')} shows the link state.`
|
||||
` Non-bare account profiles share ${color('settings.json', 'path')} and ${color('CLAUDE.md', 'path')} from ${color('~/.claude/', 'path')}; ${color('ccs auth show <profile>', 'command')} shows resource mode and settings link state.`
|
||||
);
|
||||
console.log(
|
||||
` Shared Resources control plugins/commands/skills/agents/settings.json; History Sync controls project/session continuity only.`
|
||||
` Shared Resources control plugins/commands/skills/agents/settings.json/CLAUDE.md; History Sync controls project/session continuity only.`
|
||||
);
|
||||
console.log(
|
||||
` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.`
|
||||
|
||||
@@ -4,9 +4,11 @@ import { getDefaultClaudeConfigDir } from '../utils/claude-config-path';
|
||||
|
||||
import InstanceManager from '../management/instance-manager';
|
||||
import ProfileDetector from './profile-detector';
|
||||
import ProfileRegistry from './profile-registry';
|
||||
import { resolveConfiguredContinuitySourceAccount } from './profile-continuity-inheritance';
|
||||
import type { ProfileType } from '../types/profile';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
import { isValidAccountProfileName } from './account-context';
|
||||
|
||||
export type ResumeLaneKind =
|
||||
| 'native'
|
||||
@@ -29,6 +31,37 @@ export interface ResumeFlagIntent {
|
||||
explicitSessionId?: string;
|
||||
}
|
||||
|
||||
export interface ResumeSessionLane {
|
||||
kind: 'native' | 'account';
|
||||
configDir: string;
|
||||
accountName?: string;
|
||||
}
|
||||
|
||||
export interface ResumeSessionScanDependencies {
|
||||
beforeSessionOpen?: (sessionPath: string) => void;
|
||||
}
|
||||
|
||||
const SESSION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isPathWithin(candidate: string, root: string): boolean {
|
||||
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
||||
}
|
||||
|
||||
function hasSameIdentity(before: fs.Stats, after: fs.Stats): boolean {
|
||||
return before.dev === after.dev && before.ino === after.ino;
|
||||
}
|
||||
|
||||
function isStableDirectory(directoryPath: string, before: fs.Stats): boolean {
|
||||
const after = fs.lstatSync(directoryPath);
|
||||
return after.isDirectory() && !after.isSymbolicLink() && hasSameIdentity(before, after);
|
||||
}
|
||||
|
||||
function isStableFile(filePath: string, before: fs.Stats): boolean {
|
||||
const after = fs.lstatSync(filePath);
|
||||
return after.isFile() && !after.isSymbolicLink() && hasSameIdentity(before, after);
|
||||
}
|
||||
|
||||
function countTopLevelProjectDirs(projectsDir: string): number {
|
||||
try {
|
||||
return fs
|
||||
@@ -39,6 +72,103 @@ function countTopLevelProjectDirs(projectsDir: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
function laneContainsSession(
|
||||
configDir: string,
|
||||
sessionId: string,
|
||||
deps: ResumeSessionScanDependencies
|
||||
): boolean {
|
||||
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const projectsDir = path.join(configDir, 'projects');
|
||||
try {
|
||||
const configStat = fs.lstatSync(configDir);
|
||||
if (!configStat.isDirectory() || configStat.isSymbolicLink()) return false;
|
||||
const configRealPath = fs.realpathSync(configDir);
|
||||
const projectsStat = fs.lstatSync(projectsDir);
|
||||
if (!projectsStat.isDirectory() || projectsStat.isSymbolicLink()) return false;
|
||||
const projectsRealPath = fs.realpathSync(projectsDir);
|
||||
if (!isPathWithin(projectsRealPath, configRealPath)) return false;
|
||||
|
||||
return fs.readdirSync(projectsDir, { withFileTypes: true }).some((projectEntry) => {
|
||||
if (!projectEntry.isDirectory() || projectEntry.isSymbolicLink()) {
|
||||
return false;
|
||||
}
|
||||
const projectPath = path.join(projectsDir, projectEntry.name);
|
||||
const sessionPath = path.join(projectPath, `${sessionId}.jsonl`);
|
||||
let sessionFd: number | undefined;
|
||||
try {
|
||||
const projectStat = fs.lstatSync(projectPath);
|
||||
if (!projectStat.isDirectory() || projectStat.isSymbolicLink()) return false;
|
||||
const projectRealPath = fs.realpathSync(projectPath);
|
||||
if (!isPathWithin(projectRealPath, projectsRealPath)) return false;
|
||||
const sessionRealPath = fs.realpathSync(sessionPath);
|
||||
if (!isPathWithin(sessionRealPath, projectRealPath)) return false;
|
||||
const sessionStat = fs.lstatSync(sessionPath);
|
||||
if (!sessionStat.isFile() || sessionStat.isSymbolicLink()) return false;
|
||||
deps.beforeSessionOpen?.(sessionPath);
|
||||
sessionFd = fs.openSync(
|
||||
sessionPath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)
|
||||
);
|
||||
const openedSessionStat = fs.fstatSync(sessionFd);
|
||||
if (!openedSessionStat.isFile() || !hasSameIdentity(sessionStat, openedSessionStat)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isStableDirectory(configDir, configStat) &&
|
||||
isStableDirectory(projectsDir, projectsStat) &&
|
||||
isStableDirectory(projectPath, projectStat) &&
|
||||
isStableFile(sessionPath, sessionStat) &&
|
||||
fs.realpathSync(configDir) === configRealPath &&
|
||||
fs.realpathSync(projectsDir) === projectsRealPath &&
|
||||
fs.realpathSync(projectPath) === projectRealPath &&
|
||||
fs.realpathSync(sessionPath) === sessionRealPath
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (sessionFd !== undefined) fs.closeSync(sessionFd);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function findResumeSessionLanes(
|
||||
sessionId: string,
|
||||
deps: ResumeSessionScanDependencies = {}
|
||||
): ResumeSessionLane[] {
|
||||
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const instanceMgr = new InstanceManager();
|
||||
const profileRegistry = new ProfileRegistry();
|
||||
const lanes: ResumeSessionLane[] = [];
|
||||
const nativeConfigDir = getDefaultClaudeConfigDir();
|
||||
if (laneContainsSession(nativeConfigDir, sessionId, deps)) {
|
||||
lanes.push({ kind: 'native', configDir: nativeConfigDir });
|
||||
}
|
||||
|
||||
const accountNames = Object.entries(profileRegistry.getAllProfilesMerged())
|
||||
.filter(
|
||||
([accountName, profile]) =>
|
||||
profile.type === 'account' && isValidAccountProfileName(accountName)
|
||||
)
|
||||
.map(([accountName]) => accountName);
|
||||
for (const accountName of accountNames) {
|
||||
const configDir = instanceMgr.getInstancePath(accountName);
|
||||
if (laneContainsSession(configDir, sessionId, deps)) {
|
||||
lanes.push({ kind: 'account', configDir, accountName });
|
||||
}
|
||||
}
|
||||
|
||||
return lanes;
|
||||
}
|
||||
|
||||
function resolveNativeLaneSummary(
|
||||
kind: ResumeLaneKind = 'native',
|
||||
profileName?: string
|
||||
|
||||
@@ -1,17 +1,65 @@
|
||||
import * as path from 'path';
|
||||
import { info, warn } from '../utils/ui';
|
||||
import { detectShell, formatExport, formatUnset, type Shell } from '../codex-auth/shell-detect';
|
||||
import { ANTHROPIC_ROUTING_ENV_KEYS } from '../utils/shell-executor';
|
||||
import { isValidAccountProfileName } from './account-context';
|
||||
import {
|
||||
findResumeSessionLanes,
|
||||
parseResumeFlagIntent,
|
||||
resolveRuntimePlainCcsResumeLane,
|
||||
type ResumeSessionLane,
|
||||
type ResumeLaneSummary,
|
||||
} from './resume-lane-diagnostics';
|
||||
|
||||
interface ResumeLaneWarningDependencies {
|
||||
resolvePlainLane?: () => Promise<ResumeLaneSummary>;
|
||||
findSessionLanes?: (sessionId: string) => ResumeSessionLane[];
|
||||
shell?: Shell;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: string;
|
||||
parentProcessName?: string;
|
||||
log?: (message: string) => void;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
function joinShellStatements(shell: Shell, statements: string[]): string {
|
||||
if (shell === 'cmd') return statements.join(' && ');
|
||||
if (shell === 'fish') return statements.join(' ');
|
||||
return statements.join('; ');
|
||||
}
|
||||
|
||||
function buildExplicitNativeResumeCommand(
|
||||
configDir: string,
|
||||
sessionId: string,
|
||||
shell: Shell
|
||||
): string {
|
||||
const statements = ANTHROPIC_ROUTING_ENV_KEYS.map((key) => formatUnset(shell, key));
|
||||
statements.push(formatExport(shell, 'CLAUDE_CONFIG_DIR', configDir));
|
||||
statements.push(`claude --resume ${sessionId}`);
|
||||
return joinShellStatements(shell, statements);
|
||||
}
|
||||
|
||||
function buildResumeCommand(
|
||||
sourceLane: ResumeSessionLane,
|
||||
plainLane: ResumeLaneSummary,
|
||||
sessionId: string,
|
||||
shell: Shell
|
||||
): string | null {
|
||||
if (sourceLane.kind === 'account') {
|
||||
if (!sourceLane.accountName || !isValidAccountProfileName(sourceLane.accountName)) return null;
|
||||
return `ccs ${sourceLane.accountName} --resume ${sessionId}`;
|
||||
}
|
||||
|
||||
if (
|
||||
plainLane.kind === 'native' &&
|
||||
path.resolve(sourceLane.configDir) === path.resolve(plainLane.configDir)
|
||||
) {
|
||||
return `ccs --resume ${sessionId}`;
|
||||
}
|
||||
|
||||
return buildExplicitNativeResumeCommand(sourceLane.configDir, sessionId, shell);
|
||||
}
|
||||
|
||||
export async function maybeWarnAboutResumeLaneMismatch(
|
||||
profileName: string,
|
||||
accountConfigDir: string,
|
||||
@@ -26,8 +74,20 @@ export async function maybeWarnAboutResumeLaneMismatch(
|
||||
const log = deps.log ?? console.error;
|
||||
|
||||
try {
|
||||
const sessionLanes = resumeIntent.explicitSessionId
|
||||
? (deps.findSessionLanes ?? findResumeSessionLanes)(resumeIntent.explicitSessionId)
|
||||
: [];
|
||||
if (
|
||||
sessionLanes.some((lane) => path.resolve(lane.configDir) === path.resolve(accountConfigDir))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plainLane = await (deps.resolvePlainLane ?? resolveRuntimePlainCcsResumeLane)();
|
||||
if (path.resolve(plainLane.configDir) === path.resolve(accountConfigDir)) {
|
||||
if (
|
||||
sessionLanes.length === 0 &&
|
||||
path.resolve(plainLane.configDir) === path.resolve(accountConfigDir)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,18 +98,70 @@ export async function maybeWarnAboutResumeLaneMismatch(
|
||||
);
|
||||
log(info(` Account lane: ${accountConfigDir}`));
|
||||
log(info(` Plain ccs lane: ${plainLane.label} (${plainLane.configDir})`));
|
||||
const sourceLanes = sessionLanes.filter(
|
||||
(lane) => path.resolve(lane.configDir) !== path.resolve(accountConfigDir)
|
||||
);
|
||||
const shell =
|
||||
deps.shell ??
|
||||
detectShell(
|
||||
deps.env ?? process.env,
|
||||
deps.platform ?? process.platform,
|
||||
deps.parentProcessName
|
||||
);
|
||||
if (resumeIntent.explicitSessionId) {
|
||||
if (sourceLanes.length === 1) {
|
||||
const sourceLane = sourceLanes[0];
|
||||
const command = buildResumeCommand(
|
||||
sourceLane,
|
||||
plainLane,
|
||||
resumeIntent.explicitSessionId,
|
||||
shell
|
||||
);
|
||||
if (command) {
|
||||
log(info(` Resume from the lane that owns this session: ${command}`));
|
||||
} else {
|
||||
log(warn(' Session owner has an unsafe account name; no executable command emitted.'));
|
||||
}
|
||||
} else if (sourceLanes.length > 1) {
|
||||
log(info(' This session exists in multiple lanes; choose one without merging history:'));
|
||||
for (const sourceLane of sourceLanes) {
|
||||
const command = buildResumeCommand(
|
||||
sourceLane,
|
||||
plainLane,
|
||||
resumeIntent.explicitSessionId,
|
||||
shell
|
||||
);
|
||||
if (command) log(info(` ${command}`));
|
||||
}
|
||||
} else {
|
||||
log(
|
||||
info(
|
||||
' This explicit session ID may have been created in a different lane, so Claude may not find it here.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (sourceLanes.length === 0) {
|
||||
log(info(' Recover the original lane first: ccs -r'));
|
||||
log(info(' Back it up before changing setup: ccs auth backup default'));
|
||||
if (isValidAccountProfileName(profileName)) {
|
||||
log(
|
||||
info(
|
||||
` For future work, align plain ccs with this account: ccs auth default ${profileName}`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
sourceLanes.length === 1 &&
|
||||
sourceLanes[0].accountName &&
|
||||
isValidAccountProfileName(sourceLanes[0].accountName)
|
||||
) {
|
||||
log(
|
||||
info(
|
||||
' This explicit session ID may have been created in a different lane, so Claude may not find it here.'
|
||||
` Back up that lane before changing setup: ccs auth backup ${sourceLanes[0].accountName}`
|
||||
)
|
||||
);
|
||||
}
|
||||
log(info(' Recover the original lane first: ccs -r'));
|
||||
log(info(' Back it up before changing setup: ccs auth backup default'));
|
||||
log(
|
||||
info(` For future work, align plain ccs with this account: ccs auth default ${profileName}`)
|
||||
);
|
||||
log('');
|
||||
} catch (error) {
|
||||
log(
|
||||
|
||||
+6
-2
@@ -1,5 +1,3 @@
|
||||
import './utils/fetch-proxy-setup';
|
||||
|
||||
import { ErrorManager } from './utils/error-manager';
|
||||
import { fail } from './utils/ui';
|
||||
// Import centralized error handling
|
||||
@@ -7,6 +5,7 @@ import { handleError, runCleanup } from './errors';
|
||||
|
||||
import { createLogger, runWithRequestId } from './services/logging';
|
||||
import { redactArgv } from './services/logging/log-redaction';
|
||||
import { applyGlobalFetchProxy } from './utils/fetch-proxy-setup';
|
||||
// Import target adapter system
|
||||
import { registerTarget, ClaudeAdapter, DroidAdapter, CodexAdapter } from './targets';
|
||||
|
||||
@@ -19,6 +18,11 @@ import { resolveProfileAndTarget } from './dispatcher/profile-resolver';
|
||||
// ========== Main Execution ==========
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const fetchProxySetup = applyGlobalFetchProxy();
|
||||
if (fetchProxySetup.error) {
|
||||
console.error(`[!] Skipping global fetch proxy setup: ${fetchProxySetup.error}`);
|
||||
}
|
||||
|
||||
// Register target adapters (singleton wiring — stays in main)
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* CLIProxy request-retry config — generator tests.
|
||||
*
|
||||
* Covers:
|
||||
* 1. Unset retry config produces today's exact request-retry/max-retry-interval bytes (0/0).
|
||||
* 2. Configured retry values are emitted verbatim into the generated CLIProxy config.yaml.
|
||||
* 3. Invalid persisted values (negative/non-integer) fall back to 0, matching the
|
||||
* unset default (defensive: a corrupt config.yaml must not crash the generator).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
|
||||
function createTestHome(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-retry-test-'));
|
||||
const ccsDir = path.join(dir, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8');
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('CLIProxy retry config — generator', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = createTestHome();
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function regenerate(tag: string): string {
|
||||
return path.join(tempHome, '.ccs', 'cliproxy', `config-${tag}.yaml`);
|
||||
}
|
||||
|
||||
it("unset retry config emits request-retry: 0 and max-retry-interval: 0 (today's default)", async () => {
|
||||
const { regenerateConfig } = await import(`../config/generator?crgen1=${Date.now()}`);
|
||||
const configPath = regenerate('unset');
|
||||
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
regenerateConfig(8317, { configPath, authDir });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(content).toContain('request-retry: 0');
|
||||
expect(content).toContain('max-retry-interval: 0');
|
||||
});
|
||||
|
||||
it('unset retry config produces byte-identical output to an explicit 0/0 config', async () => {
|
||||
const { mutateConfig, invalidateConfigCache } = await import(
|
||||
`../../config/config-loader-facade?crgen2a=${Date.now()}`
|
||||
);
|
||||
const { regenerateConfig } = await import(`../config/generator?crgen2a=${Date.now()}`);
|
||||
|
||||
const unsetPath = regenerate('unset-baseline');
|
||||
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(path.dirname(unsetPath), { recursive: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
regenerateConfig(8317, { configPath: unsetPath, authDir });
|
||||
const unsetContent = fs.readFileSync(unsetPath, 'utf-8');
|
||||
|
||||
mutateConfig((cfg: { cliproxy?: { retry?: Record<string, unknown> } }) => {
|
||||
cfg.cliproxy = cfg.cliproxy ?? {};
|
||||
cfg.cliproxy.retry = { request_retry: 0, max_retry_interval: 0 };
|
||||
});
|
||||
invalidateConfigCache();
|
||||
|
||||
const explicitPath = regenerate('explicit-zero');
|
||||
regenerateConfig(8317, { configPath: explicitPath, authDir });
|
||||
const explicitContent = fs.readFileSync(explicitPath, 'utf-8');
|
||||
|
||||
const stripTimestamp = (s: string) => s.replace(/# Generated: .+/g, '# Generated: TIMESTAMP');
|
||||
expect(stripTimestamp(explicitContent)).toBe(stripTimestamp(unsetContent));
|
||||
});
|
||||
|
||||
it('configured retry values are emitted into the generated config', async () => {
|
||||
const { mutateConfig, invalidateConfigCache } = await import(
|
||||
`../../config/config-loader-facade?crgen3=${Date.now()}`
|
||||
);
|
||||
mutateConfig((cfg: { cliproxy?: { retry?: Record<string, unknown> } }) => {
|
||||
cfg.cliproxy = cfg.cliproxy ?? {};
|
||||
cfg.cliproxy.retry = { request_retry: 3, max_retry_interval: 30 };
|
||||
});
|
||||
invalidateConfigCache();
|
||||
|
||||
const { regenerateConfig } = await import(`../config/generator?crgen3=${Date.now()}`);
|
||||
const configPath = regenerate('configured');
|
||||
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
regenerateConfig(8317, { configPath, authDir });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(content).toContain('request-retry: 3');
|
||||
expect(content).toContain('max-retry-interval: 30');
|
||||
});
|
||||
|
||||
it('only request_retry set falls back to 0 for max_retry_interval', async () => {
|
||||
const { mutateConfig, invalidateConfigCache } = await import(
|
||||
`../../config/config-loader-facade?crgen4=${Date.now()}`
|
||||
);
|
||||
mutateConfig((cfg: { cliproxy?: { retry?: Record<string, unknown> } }) => {
|
||||
cfg.cliproxy = cfg.cliproxy ?? {};
|
||||
cfg.cliproxy.retry = { request_retry: 5 };
|
||||
});
|
||||
invalidateConfigCache();
|
||||
|
||||
const { regenerateConfig } = await import(`../config/generator?crgen4=${Date.now()}`);
|
||||
const configPath = regenerate('partial');
|
||||
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
regenerateConfig(8317, { configPath, authDir });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(content).toContain('request-retry: 5');
|
||||
expect(content).toContain('max-retry-interval: 0');
|
||||
});
|
||||
|
||||
it('a negative persisted request_retry falls back to 0 (defensive, does not throw)', async () => {
|
||||
const { mutateConfig, invalidateConfigCache } = await import(
|
||||
`../../config/config-loader-facade?crgen5=${Date.now()}`
|
||||
);
|
||||
mutateConfig((cfg: { cliproxy?: { retry?: Record<string, unknown> } }) => {
|
||||
cfg.cliproxy = cfg.cliproxy ?? {};
|
||||
cfg.cliproxy.retry = { request_retry: -1, max_retry_interval: 2.5 };
|
||||
});
|
||||
invalidateConfigCache();
|
||||
|
||||
const { regenerateConfig } = await import(`../config/generator?crgen5=${Date.now()}`);
|
||||
const configPath = regenerate('invalid');
|
||||
const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
expect(() => regenerateConfig(8317, { configPath, authDir })).not.toThrow();
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(content).toContain('request-retry: 0');
|
||||
expect(content).toContain('max-retry-interval: 0');
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,18 @@ describe('model-catalog compatibility lookups', () => {
|
||||
expect(catalog?.models[0]?.extendedContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps positive integer live context length into contextWindow metadata', () => {
|
||||
const catalog = mergeCatalog('xai', [
|
||||
{
|
||||
id: 'grok-4.3',
|
||||
display_name: 'Grok 4.3 Live',
|
||||
context_length: 1_000_000,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(catalog?.models[0]?.contextWindow).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it('does not re-add stale static-only models when live catalog data is present', () => {
|
||||
const catalog = mergeCatalog('gemini', [
|
||||
{
|
||||
|
||||
@@ -236,6 +236,22 @@ describe('Model Catalog', () => {
|
||||
assert.strictEqual(fable5.extendedContext, true);
|
||||
});
|
||||
|
||||
it('includes Claude Opus 5 with adaptive levels and extended context', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const opus5 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-5');
|
||||
assert(opus5, 'Should include Claude Opus 5');
|
||||
assert.strictEqual(opus5.name, 'Claude Opus 5');
|
||||
// Opus 5 requires adaptive thinking (type: 'levels') like Sonnet 5 and Opus 4.8;
|
||||
// manual budget_tokens is rejected with 400. Proxy reports zero + dynamic allowed.
|
||||
assert.strictEqual(opus5.thinking.type, 'levels');
|
||||
assert.deepStrictEqual(opus5.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
assert.strictEqual(opus5.thinking.maxLevel, 'max');
|
||||
assert.strictEqual(opus5.thinking.zeroAllowed, true);
|
||||
assert.strictEqual(opus5.thinking.dynamicAllowed, true);
|
||||
assert.strictEqual(opus5.nativeImageInput, true);
|
||||
assert.strictEqual(opus5.extendedContext, true);
|
||||
});
|
||||
|
||||
it('retains previous 4.5 snapshot models for explicit selection', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const ids = MODEL_CATALOG.claude.models.map((m) => m.id);
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { PROVIDER_CAPABILITIES } from '../provider-capabilities';
|
||||
import { PROVIDER_TYPE_VALUES } from '../auth/auth-types';
|
||||
import { getAuthDir, getCliproxyDir } from '../config/config-generator';
|
||||
import { withSyncLockRetry } from '../../utils/sync-lock-retry';
|
||||
import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL, DrainOrderConfig } from './types';
|
||||
import {
|
||||
getAccountsRegistryPath,
|
||||
@@ -338,24 +338,24 @@ function recoverAccountsRegistryFromCorruption(registryPath: string): AccountsRe
|
||||
|
||||
function withAccountsRegistryLock<T>(callback: () => T): T {
|
||||
const lockTarget = getCliproxyDir();
|
||||
let release: (() => void) | undefined;
|
||||
|
||||
if (!fs.existsSync(lockTarget)) {
|
||||
fs.mkdirSync(lockTarget, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
try {
|
||||
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
|
||||
return callback();
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
release();
|
||||
} catch {
|
||||
// Best-effort release
|
||||
}
|
||||
}
|
||||
}
|
||||
// proper-lockfile rejects built-in retries in synchronous mode.
|
||||
// Keep this API sync while waiting for short-lived concurrent launches.
|
||||
// Preserve the existing stale-lock window.
|
||||
// Retry only contention failures.
|
||||
// Wait in short intervals so released locks are picked up promptly.
|
||||
// Bound the wait so a genuinely stuck process still produces an error.
|
||||
// The helper adds recovery guidance when that timeout is reached.
|
||||
return withSyncLockRetry(lockTarget, callback, {
|
||||
description: 'CLIProxy account registry lock',
|
||||
staleMs: 10000,
|
||||
retryDelayMs: 200,
|
||||
retryTimeoutMs: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
function readAccountsRegistryFromDisk(): AccountsRegistry {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as childProcess from 'child_process';
|
||||
import {
|
||||
extractAdvertisedCliFlags,
|
||||
getOAuthFlagCandidatesForProvider,
|
||||
OAUTH_HELP_PROBE_TIMEOUT_MS,
|
||||
probeCliProxyAdvertisedFlags,
|
||||
resolveAdvertisedAuthFlag,
|
||||
} from '../oauth-cli-capabilities';
|
||||
|
||||
describe('oauth CLI capability probing', () => {
|
||||
it('extracts advertised flags from Go help output', () => {
|
||||
const flags = extractAdvertisedCliFlags(`
|
||||
Usage of cli-proxy-api-plus
|
||||
-login
|
||||
Login Google Account
|
||||
-codex-login
|
||||
Login to Codex using OAuth
|
||||
`);
|
||||
|
||||
expect(flags.has('--login')).toBe(true);
|
||||
expect(flags.has('--codex-login')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the legacy Kiro Google alias when advertised', () => {
|
||||
const selected = resolveAdvertisedAuthFlag(
|
||||
'kiro',
|
||||
getOAuthFlagCandidatesForProvider('kiro', 'google'),
|
||||
new Set(['--kiro-login'])
|
||||
);
|
||||
|
||||
expect(selected).toBe('--kiro-login');
|
||||
});
|
||||
|
||||
it('probes help with a raw argv array so paths with spaces stay intact', () => {
|
||||
const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({
|
||||
status: 0,
|
||||
stdout: ' -login\n',
|
||||
stderr: '',
|
||||
pid: 0,
|
||||
output: [],
|
||||
signal: null,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
const binaryPath = 'C:\\Program Files\\CCS\\cli-proxy-api-plus.exe';
|
||||
const flags = probeCliProxyAdvertisedFlags(binaryPath);
|
||||
|
||||
expect(flags.has('--login')).toBe(true);
|
||||
expect(spawnSpy).toHaveBeenCalledWith(
|
||||
binaryPath,
|
||||
['--help'],
|
||||
expect.objectContaining({
|
||||
encoding: 'utf8',
|
||||
shell: false,
|
||||
timeout: OAUTH_HELP_PROBE_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
spawnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails fast when help probing times out', () => {
|
||||
const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({
|
||||
status: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
pid: 0,
|
||||
output: [],
|
||||
signal: 'SIGTERM',
|
||||
error: Object.assign(new Error('spawnSync timed out'), { code: 'ETIMEDOUT' }),
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => probeCliProxyAdvertisedFlags('/tmp/cli-proxy-api')).toThrow(
|
||||
`Timed out after ${OAUTH_HELP_PROBE_TIMEOUT_MS}ms`
|
||||
);
|
||||
} finally {
|
||||
spawnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildOAuthArgs } from '../oauth-cli-args';
|
||||
|
||||
describe('buildOAuthArgs capability negotiation', () => {
|
||||
it('uses the advertised Gemini login flag when supported', () => {
|
||||
expect(
|
||||
buildOAuthArgs('gemini', '/tmp/cliproxy-config.yaml', false, false, {
|
||||
advertisedFlags: new Set(['--login']),
|
||||
})
|
||||
).toEqual(['--config', '/tmp/cliproxy-config.yaml', '--login']);
|
||||
});
|
||||
|
||||
it('uses the legacy Kiro Google alias when the binary only advertises it', () => {
|
||||
expect(
|
||||
buildOAuthArgs('kiro', '/tmp/path with spaces/cliproxy config.yaml', false, false, {
|
||||
kiroMethod: 'google',
|
||||
advertisedFlags: new Set(['--kiro-login']),
|
||||
})
|
||||
).toEqual(['--config', '/tmp/path with spaces/cliproxy config.yaml', '--kiro-login']);
|
||||
});
|
||||
|
||||
it('fails early when Gemini login is unsupported by the installed binary', () => {
|
||||
expect(() =>
|
||||
buildOAuthArgs('gemini', '/tmp/cliproxy-config.yaml', false, false, {
|
||||
backend: 'original',
|
||||
advertisedFlags: new Set(['--codex-login']),
|
||||
})
|
||||
).toThrow('cliproxy.backend: original');
|
||||
});
|
||||
|
||||
it('preserves Windows config paths as a separate argv entry', () => {
|
||||
const args = buildOAuthArgs(
|
||||
'gemini',
|
||||
'C:\\Users\\Kai Tran\\AppData\\Roaming\\CCS\\cliproxy config.yaml',
|
||||
true,
|
||||
false,
|
||||
{
|
||||
advertisedFlags: new Set(['--login']),
|
||||
}
|
||||
);
|
||||
|
||||
expect(args[1]).toBe('C:\\Users\\Kai Tran\\AppData\\Roaming\\CCS\\cliproxy config.yaml');
|
||||
expect(args).toContain('--no-browser');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as childProcess from 'child_process';
|
||||
import * as path from 'path';
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
const childTestPath = path.resolve(
|
||||
import.meta.dir,
|
||||
'../test-fixtures/gemini-backend-guidance-child.scenario.ts'
|
||||
);
|
||||
|
||||
function runScenario(scenario: string): void {
|
||||
const result = childProcess.spawnSync(process.execPath, ['test', childTestPath], {
|
||||
cwd: path.resolve(import.meta.dir, '../../..'),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_GUIDANCE_SCENARIO: scenario,
|
||||
},
|
||||
});
|
||||
|
||||
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
||||
expect(result.status, output || `child scenario ${scenario} failed`).toBe(0);
|
||||
}
|
||||
|
||||
describe('triggerOAuth Gemini backend guidance', () => {
|
||||
it('fails early on plus when Gemini OAuth client env vars are missing', () => {
|
||||
runScenario('plus-missing-env');
|
||||
});
|
||||
|
||||
it('reports original backend Gemini incompatibility after probing direct CLI auth', () => {
|
||||
runScenario('original-direct-unsupported');
|
||||
});
|
||||
|
||||
it('reports original backend Gemini incompatibility after probing headless auto paste mode', () => {
|
||||
runScenario('original-headless-auto-paste-unsupported');
|
||||
});
|
||||
|
||||
it('allows pinned original binaries that still advertise Gemini login to continue past the probe', () => {
|
||||
runScenario('original-headless-auto-paste-supported');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { OAUTH_CALLBACK_PORTS } from '../../../management/oauth-port-diagnostics';
|
||||
import { buildOAuthArgs } from '../oauth-handler';
|
||||
import { buildOAuthArgs } from '../oauth-cli-args';
|
||||
|
||||
describe('xAI OAuth process arguments', () => {
|
||||
it('starts CLIProxy device auth without a callback port argument', () => {
|
||||
expect(OAUTH_CALLBACK_PORTS.xai).toBeNull();
|
||||
expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', false, false)).toEqual([
|
||||
'--config',
|
||||
'/tmp/cliproxy-config.yaml',
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ValidationError, AuthError } from '../../errors/error-types';
|
||||
import { getUnsupportedAuthStartReason } from '../provider-capabilities';
|
||||
import { CLIProxyBackend, CLIProxyProvider } from '../types';
|
||||
import {
|
||||
getKiroCLIAuthFlag,
|
||||
getOAuthConfig,
|
||||
isKiroCLIAuthMethod,
|
||||
normalizeKiroAuthMethod,
|
||||
normalizeKiroIDCFlow,
|
||||
type OAuthOptions,
|
||||
} from './auth-types';
|
||||
import {
|
||||
getOAuthFlagCandidatesForProvider,
|
||||
resolveAdvertisedAuthFlag,
|
||||
} from './oauth-cli-capabilities';
|
||||
|
||||
export function buildOAuthArgs(
|
||||
provider: CLIProxyProvider,
|
||||
configPath: string,
|
||||
headless: boolean,
|
||||
noIncognito: boolean,
|
||||
options: {
|
||||
advertisedFlags?: ReadonlySet<string>;
|
||||
backend?: CLIProxyBackend;
|
||||
kiroMethod?: OAuthOptions['kiroMethod'];
|
||||
kiroIDCStartUrl?: string;
|
||||
kiroIDCRegion?: string;
|
||||
kiroIDCFlow?: OAuthOptions['kiroIDCFlow'];
|
||||
} = {}
|
||||
): string[] {
|
||||
const unsupportedReason = getUnsupportedAuthStartReason(provider);
|
||||
if (unsupportedReason) {
|
||||
throw new AuthError(unsupportedReason, provider);
|
||||
}
|
||||
|
||||
const args = ['--config', configPath];
|
||||
const advertisedFlags = options.advertisedFlags;
|
||||
|
||||
if (provider === 'kiro') {
|
||||
const method = normalizeKiroAuthMethod(options.kiroMethod);
|
||||
if (!isKiroCLIAuthMethod(method)) {
|
||||
throw new AuthError(`Kiro auth method '${method}' is not supported by CLI flow.`, 'kiro');
|
||||
}
|
||||
|
||||
const selectedKiroFlag = advertisedFlags
|
||||
? resolveAdvertisedAuthFlag(
|
||||
provider,
|
||||
getOAuthFlagCandidatesForProvider(provider, method),
|
||||
advertisedFlags,
|
||||
{ backend: options.backend }
|
||||
)
|
||||
: getKiroCLIAuthFlag(method);
|
||||
|
||||
if (method !== 'idc') {
|
||||
args.push(selectedKiroFlag);
|
||||
} else {
|
||||
const startUrl = options.kiroIDCStartUrl?.trim();
|
||||
if (!startUrl) {
|
||||
throw new ValidationError(
|
||||
'Kiro IDC login requires --kiro-idc-start-url',
|
||||
'kiroIDCStartUrl'
|
||||
);
|
||||
}
|
||||
|
||||
args.push(selectedKiroFlag, '--kiro-idc-start-url', startUrl);
|
||||
const region = options.kiroIDCRegion?.trim();
|
||||
if (region) {
|
||||
args.push('--kiro-idc-region', region);
|
||||
}
|
||||
args.push('--kiro-idc-flow', normalizeKiroIDCFlow(options.kiroIDCFlow));
|
||||
}
|
||||
} else {
|
||||
args.push(
|
||||
advertisedFlags
|
||||
? resolveAdvertisedAuthFlag(
|
||||
provider,
|
||||
getOAuthFlagCandidatesForProvider(provider),
|
||||
advertisedFlags,
|
||||
{ backend: options.backend }
|
||||
)
|
||||
: getOAuthConfig(provider).authFlag
|
||||
);
|
||||
}
|
||||
|
||||
if (headless) {
|
||||
args.push('--no-browser');
|
||||
}
|
||||
if (provider === 'kiro' && noIncognito) {
|
||||
args.push('--no-incognito');
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as childProcess from 'child_process';
|
||||
import { AuthError, BinaryError } from '../../errors/error-types';
|
||||
import type { CLIProxyBackend, CLIProxyProvider } from '../types';
|
||||
import { getKiroCLIAuthFlag, getOAuthConfig, type KiroCLIAuthMethod } from './auth-types';
|
||||
|
||||
const HELP_FLAG_PATTERN = /(?:^|\n)\s+-([a-z0-9][a-z0-9-]*)\b/gi;
|
||||
export const OAUTH_HELP_PROBE_TIMEOUT_MS = 5000;
|
||||
|
||||
export function extractAdvertisedCliFlags(helpText: string): Set<string> {
|
||||
const flags = new Set<string>();
|
||||
|
||||
for (const match of helpText.matchAll(HELP_FLAG_PATTERN)) {
|
||||
const flagName = match[1]?.trim();
|
||||
if (!flagName) {
|
||||
continue;
|
||||
}
|
||||
flags.add(`--${flagName}`);
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
export function getOAuthFlagCandidatesForProvider(
|
||||
provider: CLIProxyProvider,
|
||||
kiroMethod?: KiroCLIAuthMethod
|
||||
): readonly string[] {
|
||||
if (provider !== 'kiro') {
|
||||
return [getOAuthConfig(provider).authFlag];
|
||||
}
|
||||
|
||||
switch (kiroMethod) {
|
||||
case 'google':
|
||||
return ['--kiro-google-login', '--kiro-login'];
|
||||
case 'aws':
|
||||
case 'aws-authcode':
|
||||
case 'idc':
|
||||
return [getKiroCLIAuthFlag(kiroMethod)];
|
||||
default:
|
||||
return [getKiroCLIAuthFlag('aws')];
|
||||
}
|
||||
}
|
||||
|
||||
export function selectAdvertisedAuthFlag(
|
||||
candidates: readonly string[],
|
||||
advertisedFlags: ReadonlySet<string>
|
||||
): string | null {
|
||||
for (const candidate of candidates) {
|
||||
if (advertisedFlags.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveAdvertisedAuthFlag(
|
||||
provider: CLIProxyProvider,
|
||||
candidates: readonly string[],
|
||||
advertisedFlags: ReadonlySet<string>,
|
||||
options: { backend?: CLIProxyBackend } = {}
|
||||
): string {
|
||||
const selected = selectAdvertisedAuthFlag(candidates, advertisedFlags);
|
||||
if (selected) {
|
||||
return selected;
|
||||
}
|
||||
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
if (provider === 'gemini' && options.backend === 'original') {
|
||||
throw new AuthError(
|
||||
'Installed CLIProxy binary does not advertise Google Gemini login support (--login). The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS. To use Gemini OAuth, switch `cliproxy.backend` to `plus`, set CLIPROXY_GEMINI_OAUTH_CLIENT_ID and CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET before starting CLIProxy Plus, reinstall the maintained Plus fork, and retry auth.',
|
||||
provider
|
||||
);
|
||||
}
|
||||
|
||||
throw new AuthError(
|
||||
`Installed CLIProxy binary does not advertise a supported ${oauthConfig.displayName} login flag (${candidates.join(' or ')}). Run \`ccs cliproxy status\`, then reinstall the active backend binary before retrying auth.`,
|
||||
provider
|
||||
);
|
||||
}
|
||||
|
||||
export function probeCliProxyAdvertisedFlags(binaryPath: string): Set<string> {
|
||||
const result = childProcess.spawnSync(binaryPath, ['--help'], {
|
||||
encoding: 'utf8',
|
||||
shell: false,
|
||||
timeout: OAUTH_HELP_PROBE_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const errorCode = (result.error as NodeJS.ErrnoException).code;
|
||||
if (errorCode === 'ETIMEDOUT') {
|
||||
throw new BinaryError(
|
||||
`Timed out after ${OAUTH_HELP_PROBE_TIMEOUT_MS}ms while inspecting CLIProxy login capabilities`,
|
||||
binaryPath
|
||||
);
|
||||
}
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
throw new BinaryError(
|
||||
`CLIProxy capability probe was interrupted while inspecting login capabilities (${result.signal})`,
|
||||
binaryPath
|
||||
);
|
||||
}
|
||||
|
||||
return extractAdvertisedCliFlags(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fail, info, warn, color, ok } from '../../utils/ui';
|
||||
import { createLogger } from '../../services/logging';
|
||||
import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager';
|
||||
import { ensureCLIProxyBinary, getConfiguredBackend } from '../binary-manager';
|
||||
import { generateConfig } from '../config/config-generator';
|
||||
import { AuthError, ConfigError } from '../../errors/error-types';
|
||||
import { AuthError, BinaryError, ConfigError } from '../../errors/error-types';
|
||||
import { CLIProxyBackend, CLIProxyProvider } from '../types';
|
||||
import {
|
||||
AccountInfo,
|
||||
@@ -37,8 +37,6 @@ import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
DEFAULT_KIRO_IDC_FLOW,
|
||||
getKiroCallbackPort,
|
||||
getKiroCLIAuthArgs,
|
||||
isKiroCLIAuthMethod,
|
||||
isKiroDeviceCodeMethod,
|
||||
getOAuthConfig,
|
||||
ProviderOAuthConfig,
|
||||
@@ -47,6 +45,7 @@ import {
|
||||
getManagementOAuthCallbackPath,
|
||||
normalizeKiroAuthMethod,
|
||||
normalizeKiroIDCFlow,
|
||||
isKiroCLIAuthMethod,
|
||||
} from './auth-types';
|
||||
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
|
||||
import {
|
||||
@@ -60,6 +59,12 @@ import {
|
||||
import { executeOAuthProcess } from './oauth-process';
|
||||
import { importKiroToken } from './kiro-import';
|
||||
import { parseGitLabPatAuthResponse } from './gitlab-pat-response';
|
||||
import {
|
||||
getOAuthFlagCandidatesForProvider,
|
||||
probeCliProxyAdvertisedFlags,
|
||||
selectAdvertisedAuthFlag,
|
||||
} from './oauth-cli-capabilities';
|
||||
import { buildOAuthArgs } from './oauth-cli-args';
|
||||
import {
|
||||
buildOAuthStartFailureGuidance,
|
||||
formatOAuthStartFailureForCli,
|
||||
@@ -144,14 +149,27 @@ function buildPlusOAuthCredentialMessage(
|
||||
displayName: string,
|
||||
idEnv: string,
|
||||
secretEnv: string,
|
||||
missing?: string[]
|
||||
missing?: string[],
|
||||
options?: { originalFallbackSupported?: boolean }
|
||||
): string {
|
||||
const missingText = missing?.length ? ` Missing: ${missing.join(', ')}.` : '';
|
||||
const fallbackText =
|
||||
options?.originalFallbackSupported === false
|
||||
? ' Current `cliproxy.backend: original` releases do not advertise Gemini login, so switching back to original will not restore Gemini OAuth.'
|
||||
: ` or switch \`cliproxy.backend\` to \`original\` for ${displayName}.`;
|
||||
return (
|
||||
`${displayName} OAuth from CLIProxy Plus is missing Google OAuth client credentials.` +
|
||||
missingText +
|
||||
` Set ${idEnv} and ${secretEnv} before starting CLIProxy Plus,` +
|
||||
` or switch \`cliproxy.backend\` to \`original\` for ${displayName}.`
|
||||
fallbackText
|
||||
);
|
||||
}
|
||||
|
||||
function getGeminiOriginalBackendOAuthMessage(): string {
|
||||
return (
|
||||
'Installed CLIProxy binary does not advertise Google Gemini login support (--login). ' +
|
||||
'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS. ' +
|
||||
`To use Gemini OAuth, switch \`cliproxy.backend\` to \`plus\`, set ${GEMINI_PLUS_CLIENT_ID_ENV} and ${GEMINI_PLUS_CLIENT_SECRET_ENV} before starting CLIProxy Plus, reinstall the maintained Plus fork, and retry auth.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,7 +195,9 @@ export function getPlusOAuthCredentialError(
|
||||
|
||||
const missing = [entry.idEnv, entry.secretEnv].filter((name) => !env[name]?.trim());
|
||||
return missing.length > 0
|
||||
? buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv, missing)
|
||||
? buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv, missing, {
|
||||
originalFallbackSupported: provider !== 'gemini',
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -205,7 +225,15 @@ export function getPlusAuthUrlCredentialError(
|
||||
const clientId = parsed.searchParams.get('client_id')?.trim();
|
||||
return clientId
|
||||
? null
|
||||
: buildPlusOAuthCredentialMessage(entry.displayName, entry.idEnv, entry.secretEnv);
|
||||
: buildPlusOAuthCredentialMessage(
|
||||
entry.displayName,
|
||||
entry.idEnv,
|
||||
entry.secretEnv,
|
||||
undefined,
|
||||
{
|
||||
originalFallbackSupported: provider !== 'gemini',
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -579,12 +607,21 @@ async function runPreflightChecks(
|
||||
*/
|
||||
async function prepareBinary(
|
||||
provider: CLIProxyProvider,
|
||||
verbose: boolean
|
||||
): Promise<{ binaryPath: string; tokenDir: string; configPath: string } | null> {
|
||||
verbose: boolean,
|
||||
backend: CLIProxyBackend
|
||||
): Promise<{
|
||||
binaryPath: string;
|
||||
tokenDir: string;
|
||||
configPath: string;
|
||||
backend: CLIProxyBackend;
|
||||
} | null> {
|
||||
showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
|
||||
|
||||
try {
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true });
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose, {
|
||||
backend,
|
||||
skipAutoUpdate: true,
|
||||
});
|
||||
process.stdout.write('\x1b[1A\x1b[2K');
|
||||
showStep(1, 4, 'ok', 'CLIProxy binary ready');
|
||||
|
||||
@@ -596,7 +633,7 @@ async function prepareBinary(
|
||||
console.error(`[auth] Config generated: ${configPath}`);
|
||||
}
|
||||
|
||||
return { binaryPath, tokenDir, configPath };
|
||||
return { binaryPath, tokenDir, configPath, backend };
|
||||
} catch (error) {
|
||||
process.stdout.write('\x1b[1A\x1b[2K');
|
||||
showStep(1, 4, 'fail', 'Failed to prepare CLIProxy binary');
|
||||
@@ -605,49 +642,31 @@ async function prepareBinary(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOAuthArgs(
|
||||
async function prepareOAuthRuntime(
|
||||
provider: CLIProxyProvider,
|
||||
configPath: string,
|
||||
headless: boolean,
|
||||
noIncognito: boolean,
|
||||
options: {
|
||||
kiroMethod?: OAuthOptions['kiroMethod'];
|
||||
kiroIDCStartUrl?: string;
|
||||
kiroIDCRegion?: string;
|
||||
kiroIDCFlow?: OAuthOptions['kiroIDCFlow'];
|
||||
} = {}
|
||||
): string[] {
|
||||
const unsupportedReason = getUnsupportedAuthStartReason(provider);
|
||||
if (unsupportedReason) {
|
||||
throw new AuthError(unsupportedReason, provider);
|
||||
verbose: boolean,
|
||||
backend: CLIProxyBackend
|
||||
): Promise<{
|
||||
advertisedFlags: ReadonlySet<string>;
|
||||
backend: CLIProxyBackend;
|
||||
binaryPath: string;
|
||||
configPath: string;
|
||||
tokenDir: string;
|
||||
}> {
|
||||
const prepared = await prepareBinary(provider, verbose, backend);
|
||||
if (!prepared) {
|
||||
throw new BinaryError('CLIProxy binary preparation returned no runtime');
|
||||
}
|
||||
|
||||
const args = ['--config', configPath];
|
||||
return {
|
||||
...prepared,
|
||||
advertisedFlags: probeCliProxyAdvertisedFlags(prepared.binaryPath),
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'kiro') {
|
||||
const method = normalizeKiroAuthMethod(options.kiroMethod);
|
||||
if (!isKiroCLIAuthMethod(method)) {
|
||||
throw new AuthError(`Kiro auth method '${method}' is not supported by CLI flow.`, 'kiro');
|
||||
}
|
||||
args.push(
|
||||
...getKiroCLIAuthArgs(method, {
|
||||
idcStartUrl: options.kiroIDCStartUrl,
|
||||
idcRegion: options.kiroIDCRegion,
|
||||
idcFlow: options.kiroIDCFlow,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
args.push(getOAuthConfig(provider).authFlag);
|
||||
}
|
||||
|
||||
if (headless) {
|
||||
args.push('--no-browser');
|
||||
}
|
||||
if (provider === 'kiro' && noIncognito) {
|
||||
args.push('--no-incognito');
|
||||
}
|
||||
|
||||
return args;
|
||||
function formatOAuthRuntimePreparationError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `Unable to prepare CLIProxy OAuth runtime: ${message}`;
|
||||
}
|
||||
|
||||
export function usesKiroLocalCallbackReplay(
|
||||
@@ -1223,12 +1242,10 @@ export async function triggerOAuth(
|
||||
usesKiroLocalCallbackReplay(resolvedKiroMethod, resolvedKiroIDCFlow);
|
||||
const useSelectedKiroDirectCliFlow =
|
||||
provider === 'kiro' && (isDeviceCodeFlow || useSelectedKiroLocalPasteCallback);
|
||||
const activeBackend = getConfiguredBackend();
|
||||
|
||||
if (!(selectedPasteCallback && !useSelectedKiroDirectCliFlow)) {
|
||||
const credentialError = getGeminiPlusOAuthCredentialError(
|
||||
provider,
|
||||
getStoredConfiguredBackend()
|
||||
);
|
||||
const credentialError = getGeminiPlusOAuthCredentialError(provider, activeBackend);
|
||||
if (credentialError) {
|
||||
console.log(fail(credentialError));
|
||||
return null;
|
||||
@@ -1266,7 +1283,26 @@ export async function triggerOAuth(
|
||||
}
|
||||
|
||||
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
const target = getProxyTarget();
|
||||
let tokenDir = getProviderTokenDir(provider);
|
||||
if (provider === 'gemini' && activeBackend === 'original' && !target.isRemote) {
|
||||
try {
|
||||
const runtime = await prepareOAuthRuntime(provider, verbose, activeBackend);
|
||||
tokenDir = runtime.tokenDir;
|
||||
const selectedFlag = selectAdvertisedAuthFlag(
|
||||
getOAuthFlagCandidatesForProvider(provider),
|
||||
runtime.advertisedFlags
|
||||
);
|
||||
if (!selectedFlag) {
|
||||
console.log(fail(getGeminiOriginalBackendOAuthMessage()));
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(fail(formatOAuthRuntimePreparationError(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return handlePasteCallbackMode(
|
||||
provider,
|
||||
oauthConfig,
|
||||
@@ -1289,11 +1325,16 @@ export async function triggerOAuth(
|
||||
|
||||
console.log('');
|
||||
|
||||
// Prepare binary
|
||||
const prepared = await prepareBinary(provider, verbose);
|
||||
if (!prepared) return null;
|
||||
|
||||
const { binaryPath, tokenDir, configPath } = prepared;
|
||||
let runtime;
|
||||
let advertisedFlags: ReadonlySet<string>;
|
||||
try {
|
||||
runtime = await prepareOAuthRuntime(provider, verbose, activeBackend);
|
||||
advertisedFlags = runtime.advertisedFlags;
|
||||
} catch (error) {
|
||||
console.log(fail(formatOAuthRuntimePreparationError(error)));
|
||||
return null;
|
||||
}
|
||||
const { binaryPath, tokenDir, configPath } = runtime;
|
||||
|
||||
// Free callback port if needed (only for authorization code flows)
|
||||
const localCallbackPort = callbackPort;
|
||||
@@ -1308,6 +1349,8 @@ export async function triggerOAuth(
|
||||
let args: string[];
|
||||
try {
|
||||
args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, {
|
||||
advertisedFlags,
|
||||
backend: activeBackend,
|
||||
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
|
||||
kiroIDCStartUrl: options.kiroIDCStartUrl,
|
||||
kiroIDCRegion: options.kiroIDCRegion,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as childProcess from 'child_process';
|
||||
import { ConfigError } from '../../../errors/error-types';
|
||||
import type { ProxyTarget } from '../../proxy/proxy-target-resolver';
|
||||
|
||||
const scenario = process.env['GEMINI_GUIDANCE_SCENARIO'];
|
||||
if (!scenario) {
|
||||
throw new ConfigError('GEMINI_GUIDANCE_SCENARIO is required');
|
||||
}
|
||||
|
||||
let activeBackend: 'original' | 'plus' = 'original';
|
||||
let headlessEnvironment = false;
|
||||
const proxyTarget: ProxyTarget = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
};
|
||||
|
||||
const ensureBinaryMock = mock(async () => '/tmp/fake-cli-proxy-api');
|
||||
const generateConfigMock = mock(() => '/tmp/cliproxy-config.yaml');
|
||||
const preflightCheckMock = mock(async () => ({
|
||||
ready: true,
|
||||
checks: [],
|
||||
firewallWarning: false,
|
||||
firewallFixCommand: undefined,
|
||||
}));
|
||||
|
||||
const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function restoreIsTTY(): void {
|
||||
if (originalIsTTY) {
|
||||
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
|
||||
}
|
||||
}
|
||||
|
||||
function getAdvertisedHelpText(localScenario: string): string {
|
||||
switch (localScenario) {
|
||||
case 'original-headless-auto-paste-supported':
|
||||
return ' -login\n';
|
||||
case 'original-direct-unsupported':
|
||||
case 'original-headless-auto-paste-unsupported':
|
||||
return ' -codex-login\n';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function registerScenarioMocks(): Promise<void> {
|
||||
const [
|
||||
realConfigGenerator,
|
||||
realAccountManager,
|
||||
realTokenManager,
|
||||
realEnvironmentDetector,
|
||||
realProxyTargetResolver,
|
||||
realAccountSafety,
|
||||
realAccountSafetyCrossLane,
|
||||
realPoolOptInPrompt,
|
||||
realOAuthPortDiagnostics,
|
||||
] = await Promise.all([
|
||||
import('../../config/config-generator'),
|
||||
import('../../accounts/account-manager'),
|
||||
import('../token-manager'),
|
||||
import('../environment-detector'),
|
||||
import('../../proxy/proxy-target-resolver'),
|
||||
import('../../accounts/account-safety'),
|
||||
import('../../accounts/account-safety-cross-lane'),
|
||||
import('../../routing/pool-opt-in-prompt'),
|
||||
import('../../../management/oauth-port-diagnostics'),
|
||||
]);
|
||||
|
||||
mock.module('../../binary-manager', () => ({
|
||||
ensureCLIProxyBinary: ensureBinaryMock,
|
||||
getConfiguredBackend: () => activeBackend,
|
||||
}));
|
||||
|
||||
mock.module('../../config/config-generator', () => ({
|
||||
...realConfigGenerator,
|
||||
generateConfig: generateConfigMock,
|
||||
}));
|
||||
|
||||
mock.module('../../../management/oauth-port-diagnostics', () => ({
|
||||
...realOAuthPortDiagnostics,
|
||||
enhancedPreflightOAuthCheck: preflightCheckMock,
|
||||
OAUTH_CALLBACK_PORTS: { gemini: 8085 },
|
||||
}));
|
||||
|
||||
mock.module('../environment-detector', () => ({
|
||||
...realEnvironmentDetector,
|
||||
isHeadlessEnvironment: () => headlessEnvironment,
|
||||
killProcessOnPort: () => false,
|
||||
showStep: () => {},
|
||||
}));
|
||||
|
||||
mock.module('../../proxy/proxy-target-resolver', () => ({
|
||||
...realProxyTargetResolver,
|
||||
getProxyTarget: () => proxyTarget,
|
||||
buildProxyUrl: (target: ProxyTarget, endpointPath: string) =>
|
||||
`${target.protocol}://${target.host}:${target.port}${
|
||||
endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`
|
||||
}`,
|
||||
buildManagementHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
mock.module('../../accounts/account-manager', () => ({
|
||||
...realAccountManager,
|
||||
getProviderAccounts: () => [],
|
||||
getDefaultAccount: () => null,
|
||||
touchAccount: () => undefined,
|
||||
hasAccountNameConflict: () => false,
|
||||
findAccountNameMatch: () => null,
|
||||
PROVIDERS_WITHOUT_EMAIL: [],
|
||||
validateNickname: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../token-manager', () => ({
|
||||
...realTokenManager,
|
||||
getProviderTokenDir: () => '/tmp/ccs-gemini-auth-tests',
|
||||
isAuthenticated: () => false,
|
||||
listProviderTokenSnapshots: () => [],
|
||||
findNewTokenSnapshotForAuthAttempt: () => null,
|
||||
registerAccountFromToken: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../../accounts/account-safety', () => ({
|
||||
...realAccountSafety,
|
||||
checkNewAccountConflict: () => null,
|
||||
warnNewAccountConflict: () => undefined,
|
||||
warnOAuthBanRisk: () => undefined,
|
||||
warnPossible403Ban: () => undefined,
|
||||
}));
|
||||
|
||||
mock.module('../../accounts/account-safety-cross-lane', () => ({
|
||||
...realAccountSafetyCrossLane,
|
||||
checkCrossLaneEmailOverlap: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../../routing/pool-opt-in-prompt', () => ({
|
||||
...realPoolOptInPrompt,
|
||||
maybeOfferPoolRouting: async () => undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLIPROXY_GEMINI_OAUTH_CLIENT_ID;
|
||||
delete process.env.CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
restoreIsTTY();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('child Gemini backend guidance scenario', () => {
|
||||
it(`validates ${scenario}`, async () => {
|
||||
if (scenario === 'plus-missing-env') {
|
||||
activeBackend = 'plus';
|
||||
} else if (
|
||||
scenario === 'original-headless-auto-paste-unsupported' ||
|
||||
scenario === 'original-headless-auto-paste-supported'
|
||||
) {
|
||||
headlessEnvironment = true;
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (scenario === 'original-headless-auto-paste-supported') {
|
||||
globalThis.fetch = mock(async () => {
|
||||
throw new ConfigError('fetch failed');
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const spawnSpy = spyOn(childProcess, 'spawnSync').mockReturnValue({
|
||||
status: 0,
|
||||
stdout: getAdvertisedHelpText(scenario),
|
||||
stderr: '',
|
||||
pid: 0,
|
||||
output: [],
|
||||
signal: null,
|
||||
error: undefined,
|
||||
});
|
||||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await registerScenarioMocks();
|
||||
const { triggerOAuth } = await import(`../oauth-handler?gemini-guidance-child=${Date.now()}`);
|
||||
const account = await triggerOAuth('gemini', {
|
||||
headless: scenario === 'original-direct-unsupported' ? false : undefined,
|
||||
});
|
||||
const output = logSpy.mock.calls.map(([message]) => String(message)).join('\n');
|
||||
|
||||
expect(account).toBeNull();
|
||||
|
||||
if (scenario === 'plus-missing-env') {
|
||||
expect(ensureBinaryMock).not.toHaveBeenCalled();
|
||||
expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_ID');
|
||||
expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET');
|
||||
expect(output).toContain(
|
||||
'Current `cliproxy.backend: original` releases do not advertise Gemini login'
|
||||
);
|
||||
expect(output).not.toContain('switch `cliproxy.backend` to `original` for Gemini');
|
||||
return;
|
||||
}
|
||||
|
||||
expect(ensureBinaryMock).toHaveBeenCalledTimes(1);
|
||||
const firstEnsureBinaryCall = ensureBinaryMock.mock.calls.at(0) as unknown[] | undefined;
|
||||
expect(firstEnsureBinaryCall?.[1]).toEqual(
|
||||
expect.objectContaining({ backend: 'original', skipAutoUpdate: true })
|
||||
);
|
||||
|
||||
if (scenario === 'original-headless-auto-paste-supported') {
|
||||
expect(output).not.toContain(
|
||||
'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS'
|
||||
);
|
||||
expect(output).toContain('Starting Google Gemini OAuth (paste-callback mode)...');
|
||||
expect(output).toContain('Failed to start OAuth flow');
|
||||
return;
|
||||
}
|
||||
|
||||
expect(output).toContain(
|
||||
'The active `cliproxy.backend: original` runtime cannot start Gemini OAuth from CCS'
|
||||
);
|
||||
expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_ID');
|
||||
expect(output).toContain('CLIPROXY_GEMINI_OAUTH_CLIENT_SECRET');
|
||||
} finally {
|
||||
spawnSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -244,6 +244,7 @@ export class BinaryManager {
|
||||
|
||||
export interface EnsureCLIProxyBinaryOptions {
|
||||
allowInstall?: boolean;
|
||||
backend?: CLIProxyBackend;
|
||||
skipAutoUpdate?: boolean;
|
||||
}
|
||||
|
||||
@@ -252,7 +253,7 @@ export async function ensureCLIProxyBinary(
|
||||
verbose = false,
|
||||
options: EnsureCLIProxyBinaryOptions = {}
|
||||
): Promise<string> {
|
||||
const configuredBackend = getConfiguredOrDefaultBackend();
|
||||
const configuredBackend = options.backend ?? getConfiguredOrDefaultBackend();
|
||||
const backend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true });
|
||||
|
||||
// Migrate old shared pin to backend-specific location (one-time migration)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
|
||||
import { invalidateConfigCache } from '../../../config/config-loader-facade';
|
||||
import { clearConfigCache } from '../base-config-loader';
|
||||
import { applyClaudeAutoCompactWindow } from '../env-builder';
|
||||
import { buildClaudeEnvironment } from '../../executor/env-resolver';
|
||||
|
||||
describe('applyClaudeAutoCompactWindow', () => {
|
||||
it('injects the catalog window for the effective model after suffix normalization', () => {
|
||||
const env = applyClaudeAutoCompactWindow(
|
||||
{
|
||||
ANTHROPIC_MODEL: 'gpt-5.6-sol-xhigh[1m]',
|
||||
},
|
||||
'codex'
|
||||
);
|
||||
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('372000');
|
||||
});
|
||||
|
||||
it('uses the selected provider when the same model family exists in multiple catalogs', () => {
|
||||
const env = applyClaudeAutoCompactWindow(
|
||||
{
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
|
||||
},
|
||||
'agy'
|
||||
);
|
||||
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('200000');
|
||||
});
|
||||
|
||||
it('does not inject a value for unknown or custom models', () => {
|
||||
const env = applyClaudeAutoCompactWindow(
|
||||
{
|
||||
ANTHROPIC_MODEL: 'custom-model-with-unknown-window',
|
||||
},
|
||||
'codex'
|
||||
);
|
||||
|
||||
expect('CLAUDE_CODE_AUTO_COMPACT_WINDOW' in env).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves an explicit user value', () => {
|
||||
const env = applyClaudeAutoCompactWindow(
|
||||
{
|
||||
ANTHROPIC_MODEL: 'gpt-5.6-sol',
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '123456',
|
||||
},
|
||||
'codex'
|
||||
);
|
||||
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('123456');
|
||||
});
|
||||
|
||||
it('treats an explicit empty value as authoritative', () => {
|
||||
const env = applyClaudeAutoCompactWindow(
|
||||
{
|
||||
ANTHROPIC_MODEL: 'gpt-5.6-sol',
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '',
|
||||
},
|
||||
'codex'
|
||||
);
|
||||
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('');
|
||||
});
|
||||
|
||||
it('does not mistake a differently cased variable for the Claude Code key', () => {
|
||||
const env = applyClaudeAutoCompactWindow(
|
||||
{
|
||||
ANTHROPIC_MODEL: 'gpt-5.6-sol',
|
||||
claude_code_auto_compact_window: '123456',
|
||||
},
|
||||
'codex'
|
||||
);
|
||||
|
||||
expect(env.claude_code_auto_compact_window).toBe('123456');
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('372000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude launch auto-compact integration', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalAutoCompactWindow: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalAutoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auto-compact-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) delete process.env.CCS_HOME;
|
||||
else process.env.CCS_HOME = originalCcsHome;
|
||||
|
||||
if (originalAutoCompactWindow === undefined) {
|
||||
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = originalAutoCompactWindow;
|
||||
}
|
||||
|
||||
invalidateConfigCache();
|
||||
clearConfigCache();
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('injects the catalog window for the provider default model on Claude launches', () => {
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4(high)');
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('1050000');
|
||||
});
|
||||
|
||||
it('preserves an inherited user value on Claude launches', () => {
|
||||
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '';
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('');
|
||||
});
|
||||
|
||||
it('uses the composite default tier provider for model lookup', () => {
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
verbose: false,
|
||||
isComposite: true,
|
||||
compositeTiers: {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
sonnet: { provider: 'codex', model: 'gpt-5.6-sol' },
|
||||
haiku: { provider: 'kimi', model: 'kimi-k2' },
|
||||
},
|
||||
compositeDefaultTier: 'opus',
|
||||
});
|
||||
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('200000');
|
||||
});
|
||||
|
||||
it('preserves an explicit empty value from a custom settings file', () => {
|
||||
const settingsPath = path.join(tempHome, 'codex-custom.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
|
||||
ANTHROPIC_AUTH_TOKEN: 'test-token',
|
||||
ANTHROPIC_MODEL: 'gpt-5.6-sol',
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
customSettingsPath: settingsPath,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
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 * as yaml from 'js-yaml';
|
||||
import { configNeedsRegeneration, regenerateConfig } from '../generator';
|
||||
|
||||
interface GeneratedCliProxyConfig {
|
||||
'oauth-model-alias'?: Record<string, Array<{ name: string; alias: string; fork?: boolean }>>;
|
||||
payload?: {
|
||||
override?: Array<{
|
||||
models: Array<{ name: string; protocol?: string }>;
|
||||
params: Record<string, unknown>;
|
||||
}>;
|
||||
[section: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
describe('Codex user routing config generation', () => {
|
||||
let testHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let generatedConfigPath: string;
|
||||
let generatedAuthDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-routing-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = testHome;
|
||||
const ccsDir = path.join(testHome, '.ccs');
|
||||
const cliproxyDir = path.join(ccsDir, 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
generatedConfigPath = path.join(cliproxyDir, 'config.yaml');
|
||||
generatedAuthDir = path.join(cliproxyDir, 'auth');
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
`version: 14
|
||||
cliproxy:
|
||||
oauth_model_alias:
|
||||
codex:
|
||||
- name: gpt-5.6-sol
|
||||
alias: gpt-5.6-sol-fast
|
||||
fork: true
|
||||
payload:
|
||||
override:
|
||||
- models:
|
||||
- name: gpt-5.6-sol-fast
|
||||
protocol: codex
|
||||
params:
|
||||
service_tier: priority
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function readGeneratedConfig(): GeneratedCliProxyConfig {
|
||||
return yaml.load(fs.readFileSync(generatedConfigPath, 'utf8')) as GeneratedCliProxyConfig;
|
||||
}
|
||||
|
||||
it('generates external client alias semantics with a Codex-only priority override', () => {
|
||||
regenerateConfig(8317, {
|
||||
configPath: generatedConfigPath,
|
||||
authDir: generatedAuthDir,
|
||||
});
|
||||
|
||||
const generated = readGeneratedConfig();
|
||||
expect(generated['oauth-model-alias']?.codex).toEqual([
|
||||
{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true },
|
||||
]);
|
||||
expect(generated.payload?.override).toEqual([
|
||||
{
|
||||
models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }],
|
||||
params: { service_tier: 'priority' },
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
generated.payload?.override?.some((rule) =>
|
||||
rule.models.some((model) => model.name === 'gpt-5.6-sol')
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves manual provider/payload sections and stays stable across regeneration', () => {
|
||||
fs.writeFileSync(
|
||||
generatedConfigPath,
|
||||
`# CLIProxyAPI config generated by CCS v20
|
||||
port: 8317
|
||||
api-keys:
|
||||
- "ccs-internal-managed"
|
||||
auth-dir: "${generatedAuthDir}"
|
||||
oauth-model-alias:
|
||||
codex:
|
||||
- name: existing-upstream
|
||||
alias: existing-client
|
||||
- name: gpt-5.6-sol
|
||||
alias: gpt-5.6-sol-fast
|
||||
future-provider:
|
||||
- name: future-upstream
|
||||
alias: future-client
|
||||
payload:
|
||||
default:
|
||||
- models:
|
||||
- name: existing-client
|
||||
params:
|
||||
temperature: 0.2
|
||||
override:
|
||||
- models:
|
||||
- name: gpt-5.6-sol-fast
|
||||
protocol: codex
|
||||
params:
|
||||
service_tier: standard
|
||||
- models:
|
||||
- name: manual-client
|
||||
protocol: codex
|
||||
params:
|
||||
custom_flag: preserved
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(configNeedsRegeneration()).toBe(true);
|
||||
regenerateConfig(8317, {
|
||||
configPath: generatedConfigPath,
|
||||
authDir: generatedAuthDir,
|
||||
});
|
||||
const first = readGeneratedConfig();
|
||||
|
||||
expect(first['oauth-model-alias']?.codex).toEqual([
|
||||
{ name: 'existing-upstream', alias: 'existing-client' },
|
||||
{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true },
|
||||
]);
|
||||
expect(first['oauth-model-alias']?.['future-provider']).toEqual([
|
||||
{ name: 'future-upstream', alias: 'future-client' },
|
||||
]);
|
||||
expect(first.payload?.default).toBeDefined();
|
||||
expect(first.payload?.override).toEqual([
|
||||
{
|
||||
models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }],
|
||||
params: { service_tier: 'priority' },
|
||||
},
|
||||
{
|
||||
models: [{ name: 'manual-client', protocol: 'codex' }],
|
||||
params: { custom_flag: 'preserved' },
|
||||
},
|
||||
]);
|
||||
expect(configNeedsRegeneration()).toBe(false);
|
||||
|
||||
regenerateConfig(8317, {
|
||||
configPath: generatedConfigPath,
|
||||
authDir: generatedAuthDir,
|
||||
});
|
||||
expect(readGeneratedConfig()).toEqual(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
mergeOAuthModelAliases,
|
||||
parseOAuthModelAliasSection,
|
||||
serializeOAuthModelAliasBody,
|
||||
} from '../oauth-model-alias-config';
|
||||
import {
|
||||
mergePayloadConfig,
|
||||
parsePayloadSection,
|
||||
serializePayloadSection,
|
||||
} from '../payload-rule-config';
|
||||
|
||||
describe('CLIProxy user routing config', () => {
|
||||
it('parses and round-trips provider aliases without dropping provider sections', () => {
|
||||
const body = ` codex:
|
||||
- name: gpt-5.6-sol
|
||||
alias: gpt-5.6-sol-fast
|
||||
fork: true
|
||||
future-provider:
|
||||
- name: upstream-model
|
||||
alias: client-model
|
||||
`;
|
||||
|
||||
const parsed = parseOAuthModelAliasSection(body);
|
||||
expect(parsed.codex).toEqual([{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }]);
|
||||
expect(parsed['future-provider']).toEqual([{ name: 'upstream-model', alias: 'client-model' }]);
|
||||
expect(parseOAuthModelAliasSection(serializeOAuthModelAliasBody(parsed))).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('preserves insertion order and deduplicates exact aliases while promoting fork', () => {
|
||||
const merged = mergeOAuthModelAliases(
|
||||
{
|
||||
codex: [
|
||||
{ name: 'first-upstream', alias: 'first-client' },
|
||||
{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast' },
|
||||
],
|
||||
},
|
||||
{
|
||||
codex: [
|
||||
{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true },
|
||||
{ name: 'last-upstream', alias: 'last-client' },
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
expect(merged.codex).toEqual([
|
||||
{ name: 'first-upstream', alias: 'first-client' },
|
||||
{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true },
|
||||
{ name: 'last-upstream', alias: 'last-client' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lets configured aliases replace an existing provider client alias in place', () => {
|
||||
const merged = mergeOAuthModelAliases(
|
||||
{
|
||||
codex: [
|
||||
{ name: 'first-upstream', alias: 'first-client' },
|
||||
{ name: 'old-upstream', alias: 'gpt-5.6-sol-fast', fork: true },
|
||||
{ name: 'last-upstream', alias: 'last-client' },
|
||||
],
|
||||
},
|
||||
{
|
||||
codex: [{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast' }],
|
||||
}
|
||||
);
|
||||
|
||||
expect(merged.codex).toEqual([
|
||||
{ name: 'first-upstream', alias: 'first-client' },
|
||||
{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast' },
|
||||
{ name: 'last-upstream', alias: 'last-client' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('round-trips unknown payload subsections and replaces matching scoped rules', () => {
|
||||
const existing = parsePayloadSection(` default:
|
||||
- models:
|
||||
- name: keep-default
|
||||
params:
|
||||
temperature: 0.2
|
||||
override:
|
||||
- models:
|
||||
- name: gpt-5.6-sol-fast
|
||||
protocol: codex
|
||||
params:
|
||||
service_tier: standard
|
||||
`);
|
||||
const configured = {
|
||||
override: [
|
||||
{
|
||||
models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }],
|
||||
params: { service_tier: 'priority' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const merged = mergePayloadConfig(existing, configured);
|
||||
expect(merged?.default).toBeDefined();
|
||||
expect(merged?.override).toEqual(configured.override);
|
||||
expect(parsePayloadSection(serializePayloadSection(merged).replace(/^payload:\n/, ''))).toEqual(
|
||||
merged
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves distinct full predicates that select the same model', () => {
|
||||
const existing = {
|
||||
override: [
|
||||
{
|
||||
models: [
|
||||
{
|
||||
name: 'gpt-5.6-sol-fast',
|
||||
protocol: 'codex',
|
||||
'from-protocol': 'openai',
|
||||
},
|
||||
],
|
||||
headers: { 'x-tenant': 'alpha' },
|
||||
match: { project: '^priority-' },
|
||||
params: { service_tier: 'standard' },
|
||||
},
|
||||
{
|
||||
models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }],
|
||||
headers: { 'x-tenant': 'beta' },
|
||||
'not-match': { project: '^disabled-' },
|
||||
exist: ['metadata.project'],
|
||||
params: { service_tier: 'standard' },
|
||||
},
|
||||
],
|
||||
};
|
||||
const configured = {
|
||||
override: [
|
||||
{
|
||||
models: [
|
||||
{
|
||||
protocol: 'codex',
|
||||
'from-protocol': 'openai',
|
||||
name: 'gpt-5.6-sol-fast',
|
||||
},
|
||||
],
|
||||
match: { project: '^priority-' },
|
||||
headers: { 'x-tenant': 'alpha' },
|
||||
params: { service_tier: 'priority' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const merged = mergePayloadConfig(existing, configured);
|
||||
expect(merged?.override).toEqual([configured.override[0], existing.override[1]]);
|
||||
expect(parsePayloadSection(serializePayloadSection(merged).replace(/^payload:\n/, ''))).toEqual(
|
||||
merged
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '../../config/config-loader-facade';
|
||||
import { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route';
|
||||
import { ConfigError } from '../../errors/error-types';
|
||||
import { findModel } from '../model-catalog';
|
||||
|
||||
export { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route';
|
||||
|
||||
@@ -68,11 +69,38 @@ const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
]);
|
||||
const CLAUDE_AUTO_COMPACT_WINDOW_ENV = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW';
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Claude Code's auto-compact window for a known effective model.
|
||||
* Existing env keys win by presence, including an explicit empty value.
|
||||
*/
|
||||
export function applyClaudeAutoCompactWindow(
|
||||
envVars: NodeJS.ProcessEnv,
|
||||
provider: CLIProxyProvider
|
||||
): NodeJS.ProcessEnv {
|
||||
if (Object.prototype.hasOwnProperty.call(envVars, CLAUDE_AUTO_COMPACT_WINDOW_ENV)) {
|
||||
return envVars;
|
||||
}
|
||||
|
||||
const modelId = envVars.ANTHROPIC_MODEL;
|
||||
if (typeof modelId !== 'string' || modelId.trim().length === 0) return envVars;
|
||||
|
||||
const contextWindow = findModel(provider, modelId)?.contextWindow;
|
||||
if (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0) {
|
||||
return envVars;
|
||||
}
|
||||
|
||||
return {
|
||||
...envVars,
|
||||
[CLAUDE_AUTO_COMPACT_WINDOW_ENV]: String(contextWindow),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file.
|
||||
* CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention.
|
||||
|
||||
@@ -16,6 +16,18 @@ import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-res
|
||||
import { CLIPROXY_DEFAULT_PORT } from './port-manager';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||
import { getActiveDockerLegacyApiKeys } from '../../docker/docker-key-rotation';
|
||||
import { isValidCliproxyRetryValue } from '../../config/schemas/cliproxy';
|
||||
import type { CLIProxyOAuthModelAliasConfig } from '../../config/schemas/cliproxy';
|
||||
import {
|
||||
mergeOAuthModelAliases,
|
||||
parseOAuthModelAliasSection,
|
||||
serializeOAuthModelAliasBody,
|
||||
} from './oauth-model-alias-config';
|
||||
import {
|
||||
mergePayloadConfig,
|
||||
parsePayloadSection,
|
||||
serializePayloadSection,
|
||||
} from './payload-rule-config';
|
||||
|
||||
/** Internal API key for CCS-managed requests */
|
||||
export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
|
||||
@@ -45,8 +57,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
* v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config
|
||||
* v19: Persist backend-aware management panel repository from CCS unified config
|
||||
* v20: Pool-gated cooling/routing/retry-cap block; disable-cooling flips to false for pool users
|
||||
* v21: Persist user-defined OAuth aliases and scoped payload override rules
|
||||
*/
|
||||
export const CLIPROXY_CONFIG_VERSION = 20;
|
||||
export const CLIPROXY_CONFIG_VERSION = 21;
|
||||
|
||||
export const ORIGINAL_MANAGEMENT_PANEL_REPOSITORY =
|
||||
'https://github.com/router-for-me/Cli-Proxy-API-Management-Center';
|
||||
@@ -192,6 +205,25 @@ function getSessionAffinityTtl(): string {
|
||||
return ttl && GO_DURATION_PATTERN.test(ttl) && hasPositiveDuration(ttl) ? ttl : '1h';
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of times CLIProxy retries a request on a transient error
|
||||
* (403, 408, 500, 502, 503, 504). Opt-in via config.cliproxy.retry.request_retry;
|
||||
* defaults to 0 (disabled) to avoid burning quota on multi-account pools.
|
||||
*/
|
||||
function getRequestRetry(): number {
|
||||
const value = loadOrCreateUnifiedConfig().cliproxy?.retry?.request_retry;
|
||||
return isValidCliproxyRetryValue(value) ? value : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum wait time in seconds for a cooled-down credential before CLIProxy
|
||||
* retries. Opt-in via config.cliproxy.retry.max_retry_interval; defaults to 0.
|
||||
*/
|
||||
function getMaxRetryInterval(): number {
|
||||
const value = loadOrCreateUnifiedConfig().cliproxy?.retry?.max_retry_interval;
|
||||
return isValidCliproxyRetryValue(value) ? value : 0;
|
||||
}
|
||||
|
||||
function normalizeManagementPanelRepository(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
@@ -276,7 +308,7 @@ function addAliasEntry(
|
||||
if (getDeniedModelIdReasonForProvider(normalized.name, 'agy')) return;
|
||||
if (getDeniedModelIdReasonForProvider(normalized.alias, 'agy')) return;
|
||||
|
||||
const key = `${normalized.name}\u0000${normalized.alias}`;
|
||||
const key = normalized.alias;
|
||||
const existingIndex = indexByKey.get(key);
|
||||
if (existingIndex !== undefined) {
|
||||
if (normalized.fork) entries[existingIndex].fork = true;
|
||||
@@ -287,6 +319,33 @@ function addAliasEntry(
|
||||
entries.push(normalized);
|
||||
}
|
||||
|
||||
function upsertConfiguredAliasEntry(
|
||||
entries: OAuthModelAliasEntry[],
|
||||
indexByKey: Map<string, number>,
|
||||
entry: OAuthModelAliasEntry
|
||||
): void {
|
||||
const normalized: OAuthModelAliasEntry = {
|
||||
name: sanitizeYamlScalar(entry.name),
|
||||
alias: normalizeAntigravityAlias(entry.alias),
|
||||
fork: entry.fork || undefined,
|
||||
};
|
||||
if (!normalized.name || !normalized.alias) return;
|
||||
if (getDeniedModelIdReasonForProvider(normalized.name, 'agy')) return;
|
||||
if (getDeniedModelIdReasonForProvider(normalized.alias, 'agy')) return;
|
||||
|
||||
const existingIndex = entries.findIndex((candidate) => candidate.alias === normalized.alias);
|
||||
if (existingIndex === -1) {
|
||||
addAliasEntry(entries, indexByKey, normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
entries[existingIndex] = normalized;
|
||||
indexByKey.clear();
|
||||
entries.forEach((candidate, index) => {
|
||||
indexByKey.set(candidate.alias, index);
|
||||
});
|
||||
}
|
||||
|
||||
function buildAntigravityAliasKey(entry: Pick<OAuthModelAliasEntry, 'name' | 'alias'>): string {
|
||||
return `${sanitizeYamlScalar(entry.name)}\u0000${normalizeAntigravityAlias(entry.alias)}`;
|
||||
}
|
||||
@@ -602,9 +661,13 @@ function writeLegacyGeminiAliasCleanupBackup(configPath: string, existingContent
|
||||
* Generate oauth-model-alias YAML section.
|
||||
* Merges default Antigravity aliases with any user-added custom aliases.
|
||||
*/
|
||||
function generateOAuthModelAliasSection(existingAliases?: string): string {
|
||||
function generateOAuthModelAliasSection(
|
||||
existingAliases?: string,
|
||||
configuredAliases?: CLIProxyOAuthModelAliasConfig
|
||||
): string {
|
||||
const aliasEntries: OAuthModelAliasEntry[] = [];
|
||||
const aliasIndexByKey = new Map<string, number>();
|
||||
const normalizedConfiguredAliases = mergeOAuthModelAliases({}, configuredAliases);
|
||||
|
||||
// Start with default aliases.
|
||||
for (const alias of DEFAULT_ANTIGRAVITY_ALIASES) {
|
||||
@@ -619,6 +682,11 @@ function generateOAuthModelAliasSection(existingAliases?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// CCS-owned structured aliases replace the same client alias in place.
|
||||
for (const alias of normalizedConfiguredAliases.antigravity ?? []) {
|
||||
upsertConfiguredAliasEntry(aliasEntries, aliasIndexByKey, alias);
|
||||
}
|
||||
|
||||
// Expand lightweight compatibility aliases (dot/hyphen + customtools toggle).
|
||||
for (const alias of getCompatibilityAliases(aliasEntries)) {
|
||||
addAliasEntry(aliasEntries, aliasIndexByKey, alias);
|
||||
@@ -632,7 +700,15 @@ function generateOAuthModelAliasSection(existingAliases?: string): string {
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `oauth-model-alias:\n antigravity:\n${entries}`;
|
||||
const existingAliasConfig = parseOAuthModelAliasSection(existingAliases ?? '');
|
||||
const mergedAliasConfig = mergeOAuthModelAliases(
|
||||
existingAliasConfig,
|
||||
normalizedConfiguredAliases
|
||||
);
|
||||
delete mergedAliasConfig.antigravity;
|
||||
const otherProviders = serializeOAuthModelAliasBody(mergedAliasConfig);
|
||||
|
||||
return `oauth-model-alias:\n antigravity:\n${entries}${otherProviders ? `\n${otherProviders}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -643,11 +719,13 @@ function generateOAuthModelAliasSection(existingAliases?: string): string {
|
||||
* @param port - Server port (default: 8317)
|
||||
* @param userApiKeys - User-added API keys to preserve (default: [])
|
||||
* @param existingAliases - Existing oauth-model-alias content to merge with defaults
|
||||
* @param existingPayload - Existing payload content to merge with structured CCS rules
|
||||
*/
|
||||
function generateUnifiedConfigContent(
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
userApiKeys: string[] = [],
|
||||
existingAliases?: string
|
||||
existingAliases?: string,
|
||||
existingPayload?: string
|
||||
): string {
|
||||
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
|
||||
// Convert Windows backslashes to forward slashes for YAML compatibility
|
||||
@@ -659,7 +737,13 @@ function generateUnifiedConfigContent(
|
||||
const routingStrategy = getRoutingStrategy();
|
||||
const sessionAffinityEnabled = getSessionAffinityEnabled();
|
||||
const sessionAffinityTtl = getSessionAffinityTtl();
|
||||
const requestRetry = getRequestRetry();
|
||||
const maxRetryInterval = getMaxRetryInterval();
|
||||
const managementPanelRepository = getManagementPanelRepository();
|
||||
const userRoutingConfig = loadOrCreateUnifiedConfig().cliproxy;
|
||||
const payloadSection = serializePayloadSection(
|
||||
mergePayloadConfig(parsePayloadSection(existingPayload ?? ''), userRoutingConfig.payload)
|
||||
);
|
||||
|
||||
// Get effective auth tokens (respects user customization)
|
||||
const effectiveApiKey = getEffectiveApiKey();
|
||||
@@ -751,8 +835,8 @@ ${coolingComment}
|
||||
disable-cooling: ${disableCoolingValue}
|
||||
|
||||
# Auto-retry on transient errors (403, 408, 500, 502, 503, 504)
|
||||
request-retry: 0
|
||||
max-retry-interval: 0
|
||||
request-retry: ${requestRetry}
|
||||
max-retry-interval: ${maxRetryInterval}
|
||||
${poolRoutingBlock}
|
||||
# Auto-switch accounts on quota exceeded (429)
|
||||
# This enables seamless multi-account rotation when rate limited
|
||||
@@ -773,7 +857,8 @@ ${apiKeysYaml}
|
||||
|
||||
# OAuth tokens directory (auto-discovered by CLIProxyAPI)
|
||||
auth-dir: "${authDirNormalized}"
|
||||
${generateOAuthModelAliasSection(existingAliases)}
|
||||
${generateOAuthModelAliasSection(existingAliases, userRoutingConfig.oauth_model_alias)}
|
||||
${payloadSection ? `${payloadSection}\n` : ''}
|
||||
`;
|
||||
|
||||
return config;
|
||||
@@ -895,6 +980,7 @@ export function regenerateConfig(
|
||||
let effectivePort = port;
|
||||
let userApiKeys: string[] = [];
|
||||
let existingAliases = '';
|
||||
let existingPayload = '';
|
||||
const preservedSections: PreservedYamlSection[] = [];
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
@@ -923,15 +1009,21 @@ export function regenerateConfig(
|
||||
|
||||
// Preserve user customizations while pruning legacy generated Gemini preview noise.
|
||||
const existingConfigVersion = getConfigVersionFromContent(content);
|
||||
const preservedAliases = extractPreservedAntigravityAliases(
|
||||
extractYamlSection(content, 'oauth-model-alias'),
|
||||
{
|
||||
enableLegacyGeminiStaleCleanup:
|
||||
existingConfigVersion === null ||
|
||||
existingConfigVersion < LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION,
|
||||
}
|
||||
);
|
||||
existingAliases = preservedAliases.yaml;
|
||||
const existingAliasBody = extractYamlSection(content, 'oauth-model-alias');
|
||||
const preservedAliases = extractPreservedAntigravityAliases(existingAliasBody, {
|
||||
enableLegacyGeminiStaleCleanup:
|
||||
existingConfigVersion === null ||
|
||||
existingConfigVersion < LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION,
|
||||
});
|
||||
const preservedAliasConfig = parseOAuthModelAliasSection(existingAliasBody);
|
||||
const preservedAntigravityConfig = parseOAuthModelAliasSection(preservedAliases.yaml);
|
||||
if (preservedAntigravityConfig.antigravity) {
|
||||
preservedAliasConfig.antigravity = preservedAntigravityConfig.antigravity;
|
||||
} else {
|
||||
delete preservedAliasConfig.antigravity;
|
||||
}
|
||||
existingAliases = serializeOAuthModelAliasBody(preservedAliasConfig);
|
||||
existingPayload = extractYamlSection(content, 'payload');
|
||||
if (preservedAliases.prunedLegacyAliasCount > 0) {
|
||||
writeLegacyGeminiAliasCleanupBackup(configPath, content);
|
||||
}
|
||||
@@ -947,7 +1039,12 @@ export function regenerateConfig(
|
||||
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Generate fresh config with preserved user API keys and aliases
|
||||
let configContent = generateUnifiedConfigContent(effectivePort, userApiKeys, existingAliases);
|
||||
let configContent = generateUnifiedConfigContent(
|
||||
effectivePort,
|
||||
userApiKeys,
|
||||
existingAliases,
|
||||
existingPayload
|
||||
);
|
||||
|
||||
// Re-append managed top-level sections that are not part of the generated defaults.
|
||||
for (const section of preservedSections) {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/** Parse, merge, and serialize user-owned CLIProxy OAuth model aliases. */
|
||||
|
||||
import * as yaml from 'js-yaml';
|
||||
import type {
|
||||
CLIProxyOAuthModelAliasConfig,
|
||||
CLIProxyOAuthModelAliasEntry,
|
||||
} from '../../config/schemas/cliproxy';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeAliasEntry(value: unknown): CLIProxyOAuthModelAliasEntry | null {
|
||||
if (!isRecord(value) || typeof value.name !== 'string' || typeof value.alias !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = value.name.trim();
|
||||
const alias = value.alias.trim();
|
||||
if (!name || !alias) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
alias,
|
||||
...(value.fork === true ? { fork: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function aliasKey(entry: CLIProxyOAuthModelAliasEntry): string {
|
||||
return entry.alias;
|
||||
}
|
||||
|
||||
export function parseOAuthModelAliasSection(body: string): CLIProxyOAuthModelAliasConfig {
|
||||
if (!body.trim()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(`oauth-model-alias:\n${body}`);
|
||||
if (!isRecord(parsed) || !isRecord(parsed['oauth-model-alias'])) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result = Object.create(null) as CLIProxyOAuthModelAliasConfig;
|
||||
for (const [provider, entries] of Object.entries(parsed['oauth-model-alias'])) {
|
||||
if (!provider.trim() || !Array.isArray(entries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = entries
|
||||
.map(normalizeAliasEntry)
|
||||
.filter((entry): entry is CLIProxyOAuthModelAliasEntry => entry !== null);
|
||||
if (normalized.length > 0) {
|
||||
result[provider] = normalized;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeOAuthModelAliases(
|
||||
existing: CLIProxyOAuthModelAliasConfig,
|
||||
configured?: CLIProxyOAuthModelAliasConfig
|
||||
): CLIProxyOAuthModelAliasConfig {
|
||||
const merged = Object.create(null) as CLIProxyOAuthModelAliasConfig;
|
||||
|
||||
for (const [sourceIndex, source] of [existing, configured].entries()) {
|
||||
if (!isRecord(source)) {
|
||||
continue;
|
||||
}
|
||||
const replaceMatching = sourceIndex === 1;
|
||||
for (const [provider, rawEntries] of Object.entries(source)) {
|
||||
if (!provider.trim() || !Array.isArray(rawEntries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries = merged[provider] ?? [];
|
||||
const indexByKey = new Map(entries.map((entry, index) => [aliasKey(entry), index]));
|
||||
for (const rawEntry of rawEntries) {
|
||||
const entry = normalizeAliasEntry(rawEntry);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = aliasKey(entry);
|
||||
const existingIndex = indexByKey.get(key);
|
||||
if (existingIndex !== undefined) {
|
||||
if (replaceMatching) {
|
||||
entries[existingIndex] = entry;
|
||||
} else if (entry.fork) {
|
||||
entries[existingIndex] = { ...entries[existingIndex], fork: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
indexByKey.set(key, entries.length);
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
merged[provider] = entries;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function serializeOAuthModelAliasBody(config: CLIProxyOAuthModelAliasConfig): string {
|
||||
if (Object.keys(config).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return yaml
|
||||
.dump(config, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
noCompatMode: true,
|
||||
quotingType: '"',
|
||||
})
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/** Parse, merge, and serialize user-owned CLIProxy payload rules. */
|
||||
|
||||
import * as yaml from 'js-yaml';
|
||||
import type {
|
||||
CLIProxyPayloadConfig,
|
||||
CLIProxyPayloadModelSelector,
|
||||
CLIProxyPayloadOverrideRule,
|
||||
} from '../../config/schemas/cliproxy';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isValidModelSelector(value: unknown): value is CLIProxyPayloadModelSelector {
|
||||
if (!isRecord(value) || typeof value.name !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.name.trim().length > 0;
|
||||
}
|
||||
|
||||
function cloneOverrideRule(value: unknown): CLIProxyPayloadOverrideRule | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.models) || !isRecord(value.params)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.models.length === 0 || !value.models.every(isValidModelSelector)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return structuredClone(value) as CLIProxyPayloadOverrideRule;
|
||||
}
|
||||
|
||||
function canonicalizeForKey(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalizeForKey);
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, canonicalizeForKey(value[key])])
|
||||
);
|
||||
}
|
||||
|
||||
function predicateKey(rule: CLIProxyPayloadOverrideRule): string {
|
||||
const predicate = Object.fromEntries(Object.entries(rule).filter(([key]) => key !== 'params'));
|
||||
return JSON.stringify(canonicalizeForKey(predicate));
|
||||
}
|
||||
|
||||
export function parsePayloadSection(body: string): CLIProxyPayloadConfig | undefined {
|
||||
if (!body.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(`payload:\n${body}`);
|
||||
if (!isRecord(parsed) || !isRecord(parsed.payload)) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.payload as CLIProxyPayloadConfig;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function mergePayloadConfig(
|
||||
existing?: CLIProxyPayloadConfig,
|
||||
configured?: CLIProxyPayloadConfig
|
||||
): CLIProxyPayloadConfig | undefined {
|
||||
const existingConfig = isRecord(existing) ? (existing as CLIProxyPayloadConfig) : undefined;
|
||||
const configuredConfig = isRecord(configured) ? (configured as CLIProxyPayloadConfig) : undefined;
|
||||
if (!existingConfig && !configuredConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const merged: CLIProxyPayloadConfig = {
|
||||
...(existingConfig ?? {}),
|
||||
...(configuredConfig ?? {}),
|
||||
};
|
||||
const rules: CLIProxyPayloadOverrideRule[] = [];
|
||||
const indexByPredicate = new Map<string, number>();
|
||||
|
||||
const addRules = (rawRules: unknown, replaceMatching: boolean) => {
|
||||
if (!Array.isArray(rawRules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rawRule of rawRules) {
|
||||
const rule = cloneOverrideRule(rawRule);
|
||||
if (!rule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = predicateKey(rule);
|
||||
const existingIndex = indexByPredicate.get(key);
|
||||
if (existingIndex !== undefined) {
|
||||
if (replaceMatching) {
|
||||
rules[existingIndex] = rule;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
indexByPredicate.set(key, rules.length);
|
||||
rules.push(rule);
|
||||
}
|
||||
};
|
||||
|
||||
addRules(existingConfig?.override, false);
|
||||
addRules(configuredConfig?.override, true);
|
||||
|
||||
if (rules.length > 0) {
|
||||
merged.override = rules;
|
||||
} else {
|
||||
delete merged.override;
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
export function serializePayloadSection(config?: CLIProxyPayloadConfig): string {
|
||||
if (!config || Object.keys(config).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return yaml
|
||||
.dump(
|
||||
{ payload: config },
|
||||
{
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
noCompatMode: true,
|
||||
quotingType: '"',
|
||||
}
|
||||
)
|
||||
.trimEnd();
|
||||
}
|
||||
@@ -47,7 +47,10 @@ mock.module('../../config/config-generator', () => ({
|
||||
}));
|
||||
|
||||
mock.module('../../../utils/websearch-manager', () => ({
|
||||
appendThirdPartyWebSearchToolArgs: (args: string[]) => args,
|
||||
appendThirdPartyWebSearchToolArgs: (args: string[], enabled = true) =>
|
||||
enabled
|
||||
? [...args, '--append-system-prompt', 'CCS WebSearch steering']
|
||||
: [...args, '--disallowedTools', 'WebSearch'],
|
||||
createWebSearchTraceContext: jest.fn().mockReturnValue({}),
|
||||
ensureWebSearchMcpOrThrow: jest.fn(),
|
||||
displayWebSearchStatus: jest.fn(),
|
||||
@@ -124,6 +127,11 @@ function baseContext(overrides: object = {}) {
|
||||
skipLocalAuth: true,
|
||||
sessionId: undefined,
|
||||
imageAnalysisMcpReady: false,
|
||||
webSearchLaunch: {
|
||||
config: { enabled: true },
|
||||
enabled: true,
|
||||
hookEnv: { CCS_WEBSEARCH_ENABLED: '1' },
|
||||
},
|
||||
browserRuntimeEnv: undefined,
|
||||
inheritedClaudeConfigDir: undefined,
|
||||
codexReasoningProxy: null,
|
||||
@@ -193,6 +201,22 @@ describe('launchClaude', () => {
|
||||
expect(spawnArgs).toContain('--browser');
|
||||
});
|
||||
|
||||
it('disallows native WebSearch without CCS steering when the launch snapshot is disabled', async () => {
|
||||
await launchClaude(
|
||||
baseContext({
|
||||
webSearchLaunch: {
|
||||
config: { enabled: false },
|
||||
enabled: false,
|
||||
hookEnv: { CCS_WEBSEARCH_ENABLED: '0', CCS_WEBSEARCH_SKIP: '1' },
|
||||
},
|
||||
})
|
||||
);
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).toContain('--disallowedTools');
|
||||
expect(spawnArgs).toContain('WebSearch');
|
||||
expect(spawnArgs).not.toContain('CCS WebSearch steering');
|
||||
});
|
||||
|
||||
it('registers cleanup handlers after spawn', async () => {
|
||||
await launchClaude(baseContext());
|
||||
expect(mockSetupCleanupHandlers).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('buildLaunchSettingsOverlay', () => {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_MODEL: 'gpt-5.5',
|
||||
ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
},
|
||||
});
|
||||
@@ -44,12 +45,14 @@ describe('buildLaunchSettingsOverlay', () => {
|
||||
const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex',
|
||||
ANTHROPIC_MODEL: 'gpt-5.5-high',
|
||||
ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini-medium',
|
||||
} as NodeJS.ProcessEnv);
|
||||
|
||||
expect(changed).toBe(true);
|
||||
const env = settings.env as Record<string, string>;
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5-high');
|
||||
expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
// Untouched keys are preserved.
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
createWebSearchTraceContext,
|
||||
} from '../../utils/websearch-manager';
|
||||
import type { WebSearchLaunchState } from '../../utils/websearch-manager';
|
||||
import { appendThirdPartyImageAnalysisToolArgs } from '../../utils/image-analysis';
|
||||
import { appendBrowserToolArgs } from '../../utils/browser';
|
||||
import { getDefaultAccount } from '../accounts/account-manager';
|
||||
@@ -51,6 +52,8 @@ export interface ClaudeLaunchContext {
|
||||
sessionId: string | undefined;
|
||||
/** Whether image analysis MCP is ready */
|
||||
imageAnalysisMcpReady: boolean;
|
||||
/** WebSearch state captured when launch provisioning ran */
|
||||
webSearchLaunch: WebSearchLaunchState;
|
||||
/** Browser runtime environment variables (undefined if browser not active) */
|
||||
browserRuntimeEnv: NodeJS.ProcessEnv | undefined;
|
||||
/** Inherited Claude config dir for continuity */
|
||||
@@ -82,6 +85,7 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
||||
skipLocalAuth,
|
||||
sessionId,
|
||||
imageAnalysisMcpReady,
|
||||
webSearchLaunch,
|
||||
browserRuntimeEnv,
|
||||
inheritedClaudeConfigDir,
|
||||
codexReasoningProxy,
|
||||
@@ -124,8 +128,12 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
const launchArgs = isSubcommand
|
||||
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
||||
: ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
|
||||
? appendThirdPartyWebSearchToolArgs(browserArgs, webSearchLaunch.enabled)
|
||||
: [
|
||||
'--settings',
|
||||
settingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs, webSearchLaunch.enabled),
|
||||
];
|
||||
|
||||
// Inject web search trace context into env
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
getRemoteEnvVars,
|
||||
getCompositeEnvVars,
|
||||
applyThinkingConfig,
|
||||
applyClaudeAutoCompactWindow,
|
||||
} from '../config/config-generator';
|
||||
import { applyExtendedContextConfig } from '../config/extended-context-config';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||
import {
|
||||
applyImageAnalysisRuntimeOverrides,
|
||||
getImageAnalysisHookEnv,
|
||||
@@ -78,6 +78,8 @@ export interface ProxyChainConfig {
|
||||
claudeConfigDir?: string;
|
||||
/** Execution-aware image analysis env prepared by the caller */
|
||||
imageAnalysisEnv?: Record<string, string>;
|
||||
/** WebSearch hook env captured from the launch configuration snapshot. */
|
||||
webSearchEnv?: Record<string, string>;
|
||||
/** Optional browser runtime env for Claude browser MCP reuse. */
|
||||
browserRuntimeEnv?: Record<string, string>;
|
||||
}
|
||||
@@ -246,6 +248,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
||||
compositeDefaultTier,
|
||||
claudeConfigDir,
|
||||
imageAnalysisEnv: resolvedImageAnalysisEnv,
|
||||
webSearchEnv = {},
|
||||
browserRuntimeEnv,
|
||||
} = config;
|
||||
|
||||
@@ -386,7 +389,6 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
||||
};
|
||||
|
||||
// Add hook environment variables
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const imageAnalysisEnv = resolvedImageAnalysisEnv ?? getImageAnalysisHookEnv(provider);
|
||||
|
||||
// Merge all environment variables (filter undefined values)
|
||||
@@ -413,8 +415,17 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
||||
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
|
||||
};
|
||||
|
||||
const effectiveProvider =
|
||||
isComposite && compositeTiers && compositeDefaultTier
|
||||
? compositeTiers[compositeDefaultTier].provider
|
||||
: provider;
|
||||
const autoCompactEnv = applyClaudeAutoCompactWindow(
|
||||
stripClaudeCodeEnv(mergedEnv),
|
||||
effectiveProvider
|
||||
);
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(stripClaudeCodeEnv(mergedEnv)).filter(([, v]) => v !== undefined)
|
||||
Object.entries(autoCompactEnv).filter(([, v]) => v !== undefined)
|
||||
) as Record<string, string>;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
resolveCliproxyImageAnalysisEnv,
|
||||
} from './env-resolver';
|
||||
import { checkOrJoinProxy, registerProxySession } from './session-bridge';
|
||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||
import { resolveWebSearchLaunchState } from '../../utils/websearch-manager';
|
||||
import {
|
||||
handleLogout,
|
||||
handleImport,
|
||||
@@ -276,10 +276,9 @@ export async function execClaudeWithCLIProxy(
|
||||
await handleImport(authCtx);
|
||||
|
||||
// Setup first-class CCS WebSearch runtime for non-strict user launches.
|
||||
const shouldDisplayWebSearchStatus = ensureWebSearchMcpForLaunch();
|
||||
if (shouldDisplayWebSearchStatus) {
|
||||
displayWebSearchStatus();
|
||||
}
|
||||
const webSearchLaunch = resolveWebSearchLaunchState();
|
||||
ensureWebSearchMcpForLaunch(webSearchLaunch.config);
|
||||
displayWebSearchStatus(webSearchLaunch.config);
|
||||
|
||||
// 3. Ensure OAuth completed (if provider requires it)
|
||||
const remoteAuthToken = proxyConfig.authToken?.trim();
|
||||
@@ -483,6 +482,7 @@ export async function execClaudeWithCLIProxy(
|
||||
compositeDefaultTier: cfg.compositeDefaultTier,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
imageAnalysisEnv,
|
||||
webSearchEnv: webSearchLaunch.hookEnv,
|
||||
});
|
||||
|
||||
// 9b. Build env-dependent proxy chain (tool-sanitization + codex-reasoning)
|
||||
@@ -527,6 +527,7 @@ export async function execClaudeWithCLIProxy(
|
||||
compositeDefaultTier: cfg.compositeDefaultTier,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
imageAnalysisEnv,
|
||||
webSearchEnv: webSearchLaunch.hookEnv,
|
||||
browserRuntimeEnv,
|
||||
});
|
||||
|
||||
@@ -542,7 +543,7 @@ export async function execClaudeWithCLIProxy(
|
||||
);
|
||||
}
|
||||
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const webSearchEnv = webSearchLaunch.hookEnv;
|
||||
if (process.env.CCS_DEBUG) {
|
||||
logger.info('browser-env-keys', 'CCS_BROWSER_* keys in environment', {
|
||||
keys: Object.keys(env)
|
||||
@@ -580,6 +581,7 @@ export async function execClaudeWithCLIProxy(
|
||||
skipLocalAuth,
|
||||
sessionId,
|
||||
imageAnalysisMcpReady,
|
||||
webSearchLaunch,
|
||||
browserRuntimeEnv,
|
||||
inheritedClaudeConfigDir,
|
||||
codexReasoningProxy,
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface ModelEntry {
|
||||
tier?: 'free' | 'pro' | 'ultra';
|
||||
/** Optional description for the model */
|
||||
description?: string;
|
||||
/** Total context window in tokens when authoritatively known */
|
||||
contextWindow?: number;
|
||||
/** Model has known issues - show warning when selected */
|
||||
broken?: boolean;
|
||||
/** Issue URL for broken models */
|
||||
@@ -95,6 +97,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-opus-4-6-thinking',
|
||||
name: 'Claude Opus 4.6 Thinking',
|
||||
description: 'Latest flagship, extended thinking',
|
||||
contextWindow: 200000,
|
||||
nativeImageInput: true,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
@@ -111,6 +114,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-sonnet-4-6',
|
||||
name: 'Claude Sonnet 4.6',
|
||||
description: 'Latest Sonnet with thinking budget support',
|
||||
contextWindow: 200000,
|
||||
nativeImageInput: true,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
@@ -194,6 +198,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'gpt-5.6-sol',
|
||||
name: 'GPT-5.6 Sol',
|
||||
description: 'Latest frontier agentic coding model.',
|
||||
contextWindow: 372000,
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
@@ -206,6 +211,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'gpt-5.6-terra',
|
||||
name: 'GPT-5.6 Terra',
|
||||
description: 'Balanced agentic coding model for everyday work.',
|
||||
contextWindow: 372000,
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
@@ -218,6 +224,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'gpt-5.6-luna',
|
||||
name: 'GPT-5.6 Luna',
|
||||
description: 'Fast and affordable agentic coding model.',
|
||||
contextWindow: 372000,
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
@@ -232,6 +239,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
tier: 'pro',
|
||||
description:
|
||||
'Newest Codex-released GPT-5 family model; falls back to GPT-5.4 on free plans',
|
||||
contextWindow: 272000,
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
@@ -244,6 +252,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
description: 'Recommended Codex default for most coding and agentic tasks',
|
||||
contextWindow: 1050000,
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
@@ -256,6 +265,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'gpt-5.4-mini',
|
||||
name: 'GPT-5.4 Mini',
|
||||
description: 'Fast, lower-cost Codex option for lighter tasks and haiku-tier routing',
|
||||
contextWindow: 400000,
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high'],
|
||||
@@ -281,6 +291,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
tier: 'pro',
|
||||
description:
|
||||
'Research preview model for ChatGPT Pro subscribers, optimized for near-instant coding iteration',
|
||||
contextWindow: 128000,
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
@@ -310,11 +321,13 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'grok-build-0.1',
|
||||
name: 'Grok Build 0.1',
|
||||
description: 'Fast coding model for agentic software engineering workflows',
|
||||
contextWindow: 256000,
|
||||
},
|
||||
{
|
||||
id: 'grok-4.5',
|
||||
name: 'Grok 4.5',
|
||||
description: 'Frontier model for coding, engineering, and agentic workflows',
|
||||
contextWindow: 500000,
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high'],
|
||||
@@ -325,6 +338,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'grok-4.3',
|
||||
name: 'Grok 4.3',
|
||||
description: 'General-purpose Grok model with a one-million-token context window',
|
||||
contextWindow: 1000000,
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['none', 'low', 'medium', 'high'],
|
||||
@@ -335,34 +349,40 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'grok-4.20-0309-reasoning',
|
||||
name: 'Grok 4.20 0309 Reasoning',
|
||||
description: 'Reasoning model with a two-million-token context window',
|
||||
contextWindow: 2000000,
|
||||
},
|
||||
{
|
||||
id: 'grok-4.20-0309-non-reasoning',
|
||||
name: 'Grok 4.20 0309 Non Reasoning',
|
||||
description: 'Non-reasoning model with a two-million-token context window',
|
||||
contextWindow: 2000000,
|
||||
},
|
||||
{
|
||||
id: 'grok-4.20-multi-agent-0309',
|
||||
name: 'Grok 4.20 Multi Agent 0309',
|
||||
description: 'Multi-agent model with a two-million-token context window',
|
||||
contextWindow: 2000000,
|
||||
thinking: { type: 'levels', levels: ['low', 'medium', 'high'] },
|
||||
},
|
||||
{
|
||||
id: 'grok-3-mini',
|
||||
name: 'Grok 3 Mini',
|
||||
description: 'Compact reasoning model',
|
||||
contextWindow: 131072,
|
||||
thinking: { type: 'levels', levels: ['low', 'medium', 'high'] },
|
||||
},
|
||||
{
|
||||
id: 'grok-3-mini-fast',
|
||||
name: 'Grok 3 Mini Fast',
|
||||
description: 'Faster compact reasoning model',
|
||||
contextWindow: 131072,
|
||||
thinking: { type: 'levels', levels: ['low', 'medium', 'high'] },
|
||||
},
|
||||
{
|
||||
id: 'grok-composer-2.5-fast',
|
||||
name: 'Grok Composer 2.5 Fast',
|
||||
description: 'Fast Composer model for lightweight coding tasks',
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -375,56 +395,67 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'qoder/auto',
|
||||
name: 'Qoder Auto',
|
||||
description: 'Auto selects the best Qoder model for your prompt',
|
||||
contextWindow: 180000,
|
||||
},
|
||||
{
|
||||
id: 'qoder/ultimate',
|
||||
name: 'Qoder Ultimate',
|
||||
description: 'Highest quality Qoder tier',
|
||||
contextWindow: 180000,
|
||||
},
|
||||
{
|
||||
id: 'qoder/performance',
|
||||
name: 'Qoder Performance',
|
||||
description: 'Balanced quality and speed',
|
||||
contextWindow: 272000,
|
||||
},
|
||||
{
|
||||
id: 'qoder/efficient',
|
||||
name: 'Qoder Efficient',
|
||||
description: 'Cost-efficient Qoder tier',
|
||||
contextWindow: 180000,
|
||||
},
|
||||
{
|
||||
id: 'qoder/lite',
|
||||
name: 'Qoder Lite',
|
||||
description: 'Fastest and most affordable Qoder tier',
|
||||
contextWindow: 180000,
|
||||
},
|
||||
{
|
||||
id: 'qoder/qmodel',
|
||||
name: 'Qwen 3.6 Plus (via Qoder)',
|
||||
description: 'Qwen 3.6 Plus frontier model',
|
||||
contextWindow: 180000,
|
||||
},
|
||||
{
|
||||
id: 'qoder/dmodel',
|
||||
name: 'DeepSeek V4 Pro (via Qoder)',
|
||||
description: 'DeepSeek V4 Pro frontier model',
|
||||
contextWindow: 180000,
|
||||
},
|
||||
{
|
||||
id: 'qoder/dfmodel',
|
||||
name: 'DeepSeek V4 Flash (via Qoder)',
|
||||
description: 'DeepSeek V4 Flash frontier model',
|
||||
contextWindow: 180000,
|
||||
},
|
||||
{
|
||||
id: 'qoder/gm51model',
|
||||
name: 'GLM 5.1 (via Qoder)',
|
||||
description: 'GLM 5.1 frontier model',
|
||||
contextWindow: 180000,
|
||||
},
|
||||
{
|
||||
id: 'qoder/kmodel',
|
||||
name: 'Kimi K2.6 (via Qoder)',
|
||||
description: 'Kimi K2.6 frontier model',
|
||||
contextWindow: 256000,
|
||||
},
|
||||
{
|
||||
id: 'qoder/mmodel',
|
||||
name: 'MiniMax M2.7 (via Qoder)',
|
||||
description: 'MiniMax M2.7 frontier model',
|
||||
contextWindow: 180000,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -437,6 +468,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'kimi-k2.5',
|
||||
name: 'Kimi K2.5',
|
||||
description: 'Latest multimodal model (262K context)',
|
||||
contextWindow: 262144,
|
||||
nativeImageInput: true,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
@@ -450,6 +482,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'kimi-k2-thinking',
|
||||
name: 'Kimi K2 Thinking',
|
||||
description: 'Extended reasoning model',
|
||||
contextWindow: 131072,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
min: 1024,
|
||||
@@ -462,6 +495,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'kimi-k2',
|
||||
name: 'Kimi K2',
|
||||
description: 'Flagship coding model',
|
||||
contextWindow: 131072,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -474,6 +508,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-sonnet-5',
|
||||
name: 'Claude Sonnet 5',
|
||||
description: 'Latest Sonnet model',
|
||||
contextWindow: 1000000,
|
||||
nativeImageInput: true,
|
||||
// Sonnet 5 uses adaptive thinking; manual budget_tokens is rejected with 400.
|
||||
thinking: {
|
||||
@@ -488,6 +523,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-fable-5',
|
||||
name: 'Claude Fable 5',
|
||||
description: 'Most powerful model',
|
||||
contextWindow: 1000000,
|
||||
nativeImageInput: true,
|
||||
// New tier above Opus. Same adaptive-thinking surface as Opus 4.8:
|
||||
// Anthropic accepts only effort levels; manual budget_tokens is rejected with 400.
|
||||
@@ -499,10 +535,28 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
},
|
||||
extendedContext: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-5',
|
||||
name: 'Claude Opus 5',
|
||||
description: 'Latest premium model',
|
||||
nativeImageInput: true,
|
||||
// Opus 5 (released 2026-07-24) uses adaptive thinking, matching Sonnet 5
|
||||
// and Opus 4.8: Anthropic accepts only effort levels; manual budget_tokens
|
||||
// is rejected with 400. Proxy metadata reports zero + dynamic allowed.
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
maxLevel: 'max',
|
||||
zeroAllowed: true,
|
||||
dynamicAllowed: true,
|
||||
},
|
||||
extendedContext: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-8',
|
||||
name: 'Claude Opus 4.8',
|
||||
description: 'Latest flagship model',
|
||||
contextWindow: 1000000,
|
||||
nativeImageInput: true,
|
||||
// Mirrors 4.7: Anthropic accepts only adaptive thinking levels on the
|
||||
// current Opus generation; manual budget_tokens is rejected with 400.
|
||||
@@ -518,6 +572,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-opus-4-7',
|
||||
name: 'Claude Opus 4.7',
|
||||
description: 'Previous flagship model',
|
||||
contextWindow: 1000000,
|
||||
nativeImageInput: true,
|
||||
// Opus 4.7 only supports adaptive thinking on the Anthropic API; manual
|
||||
// thinking.type: "enabled" with budget_tokens is rejected with 400.
|
||||
@@ -535,6 +590,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-opus-4-6',
|
||||
name: 'Claude Opus 4.6',
|
||||
description: 'Older flagship model',
|
||||
contextWindow: 1000000,
|
||||
nativeImageInput: true,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
@@ -549,6 +605,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-sonnet-4-6',
|
||||
name: 'Claude Sonnet 4.6',
|
||||
description: 'Balanced performance and speed',
|
||||
contextWindow: 200000,
|
||||
nativeImageInput: true,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
@@ -563,6 +620,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-opus-4-5-20251101',
|
||||
name: 'Claude Opus 4.5',
|
||||
description: 'Most capable Claude model',
|
||||
contextWindow: 200000,
|
||||
nativeImageInput: true,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
@@ -577,6 +635,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
description: 'Balanced performance and speed',
|
||||
contextWindow: 200000,
|
||||
nativeImageInput: true,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
@@ -591,6 +650,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
name: 'Claude Sonnet 4',
|
||||
description: 'Previous generation Sonnet',
|
||||
contextWindow: 200000,
|
||||
nativeImageInput: true,
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
@@ -605,6 +665,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'claude-haiku-4-5-20251001',
|
||||
name: 'Claude Haiku 4.5',
|
||||
description: 'Fast and efficient',
|
||||
contextWindow: 200000,
|
||||
nativeImageInput: true,
|
||||
thinking: { type: 'none' },
|
||||
},
|
||||
@@ -658,6 +719,16 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const lookupCandidates = new Set([normalizedId, providerNormalizedId]);
|
||||
if (provider === 'codex') {
|
||||
for (const candidate of [...lookupCandidates]) {
|
||||
const tuningMatch = candidate.match(
|
||||
/^(.*?)(?:-(?:minimal|low|medium|high|xhigh)(?:-fast)?|-fast(?:-(?:minimal|low|medium|high|xhigh))?)$/i
|
||||
);
|
||||
if (tuningMatch?.[1]) {
|
||||
lookupCandidates.add(tuningMatch[1].trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isAntigravityProvider(provider)) {
|
||||
const migratedRaw = migrateDeniedAntigravityModelAliases(normalizedId).trim().toLowerCase();
|
||||
const migratedProvider = migrateDeniedAntigravityModelAliases(providerNormalizedId)
|
||||
|
||||
@@ -622,7 +622,7 @@ describe('Claude Quota Fetcher', () => {
|
||||
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60);
|
||||
});
|
||||
|
||||
it('does NOT inner-retry on 429; returns retryable single-attempt result honoring Retry-After', async () => {
|
||||
it('classifies 429 as usage-probe unavailability without implying inference is limited', async () => {
|
||||
// Safety intent: 429 must NOT trigger an immediate, delay-free inner retry.
|
||||
// The outer 10-min cache + circuit breaker honor Retry-After and bound total
|
||||
// volume, so a single attempt is made and the retryable signal is surfaced.
|
||||
@@ -636,18 +636,66 @@ describe('Claude Quota Fetcher', () => {
|
||||
global.fetch = mock(() => {
|
||||
attempt += 1;
|
||||
return Promise.resolve(
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '120' } })
|
||||
new Response(JSON.stringify({ message: 'Rate limited. Please try again later.' }), {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '0', 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-429@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Single attempt — no inner retry burned on the 429.
|
||||
expect(attempt).toBe(1);
|
||||
expect(result.error).toBe('Claude usage status temporarily unavailable');
|
||||
expect(result.error).not.toContain('Rate limited');
|
||||
expect(result.errorCode).toBe('usage_probe_unavailable');
|
||||
expect(result.actionHint).toContain('Inference may still be available');
|
||||
expect(result.httpStatus).toBe(429);
|
||||
expect(result.retryable).toBe(true);
|
||||
expect(result.errorDetail).toBe('retry-after:120');
|
||||
expect(result.errorDetail).toBe('retry-after:0');
|
||||
expect(result.needsReauth).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps 403 OAuth usage responses on the authorization-failure path', async () => {
|
||||
createClaudeAccount('claude-usage-403@example.com', {
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('', { status: 403 }))) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-usage-403@example.com');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Not authorized for Claude OAuth usage');
|
||||
expect(result.errorCode).toBeUndefined();
|
||||
expect(result.httpStatus).toBeUndefined();
|
||||
expect(result.retryable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps terminal 5xx responses on the generic retryable server-error path', async () => {
|
||||
createClaudeAccount('claude-usage-503@example.com', {
|
||||
access_token: 'oauth-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
type: 'claude',
|
||||
});
|
||||
|
||||
let attempt = 0;
|
||||
global.fetch = mock(() => {
|
||||
attempt += 1;
|
||||
return Promise.resolve(new Response('Service unavailable', { status: 503 }));
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchClaudeQuota('claude-usage-503@example.com');
|
||||
|
||||
expect(attempt).toBe(2);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Service unavailable');
|
||||
expect(result.errorCode).toBeUndefined();
|
||||
expect(result.httpStatus).toBe(503);
|
||||
expect(result.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it('clears the request timeout before retrying a retryable HTTP error', async () => {
|
||||
|
||||
@@ -295,10 +295,21 @@ async function runClaudeUsageFetch(
|
||||
continue;
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
...buildEmptyResult('Claude usage status temporarily unavailable', accountId),
|
||||
httpStatus: response.status,
|
||||
errorCode: 'usage_probe_unavailable',
|
||||
actionHint:
|
||||
'Inference may still be available. Retry the Claude quota status check later.',
|
||||
retryable: true,
|
||||
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...buildEmptyResult(lastError, accountId),
|
||||
httpStatus: response.status,
|
||||
retryable: response.status === 429 || response.status >= 500,
|
||||
retryable: response.status >= 500,
|
||||
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import type { ProxyTarget } from '../../proxy/proxy-target-resolver';
|
||||
|
||||
const localTarget: ProxyTarget = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
};
|
||||
const remoteTarget: ProxyTarget = {
|
||||
host: 'proxy.example.com',
|
||||
port: 443,
|
||||
protocol: 'https',
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
describe('CLIProxy retry settings service', () => {
|
||||
let target: ProxyTarget;
|
||||
let config: { cliproxy?: { retry?: { request_retry?: number; max_retry_interval?: number } } };
|
||||
let regenerateMock: ReturnType<typeof mock>;
|
||||
let fetchRetryMock: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
target = localTarget;
|
||||
config = { cliproxy: { retry: { request_retry: 1, max_retry_interval: 10 } } };
|
||||
regenerateMock = mock(() => '/tmp/config.yaml');
|
||||
fetchRetryMock = mock();
|
||||
|
||||
mock.module('../../config/generator', () => ({ regenerateConfig: regenerateMock }));
|
||||
mock.module('../../config/path-resolver', () => ({
|
||||
getAuthDir: () => '/tmp/auth',
|
||||
getConfigPathForPort: () => '/tmp/config.yaml',
|
||||
}));
|
||||
mock.module('../../../config/config-loader-facade', () => ({
|
||||
loadOrCreateUnifiedConfig: () => structuredClone(config),
|
||||
mutateConfig: (mutator: (value: typeof config) => void) => {
|
||||
mutator(config);
|
||||
return structuredClone(config);
|
||||
},
|
||||
}));
|
||||
mock.module('../routing-strategy-http', () => ({
|
||||
getCliproxyRoutingTarget: () => target,
|
||||
fetchCliproxyRetryResponse: fetchRetryMock,
|
||||
getRoutingErrorMessage: async (response: Response, fallback: string) => {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return body?.error ?? fallback;
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => mock.restore());
|
||||
|
||||
async function loadService() {
|
||||
return import(`../retry-settings?test=${Date.now()}-${Math.random()}`) as Promise<
|
||||
typeof import('../retry-settings')
|
||||
>;
|
||||
}
|
||||
|
||||
it('reads both official management endpoints as one live state', async () => {
|
||||
fetchRetryMock.mockImplementation(
|
||||
async (_target: ProxyTarget, setting: string) =>
|
||||
new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 3 : 30 }))
|
||||
);
|
||||
const { readCliproxyRetryState } = await loadService();
|
||||
|
||||
await expect(readCliproxyRetryState()).resolves.toEqual({
|
||||
request_retry: 3,
|
||||
max_retry_interval: 30,
|
||||
source: 'live',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
});
|
||||
expect(fetchRetryMock.mock.calls.map((call) => call[1])).toEqual([
|
||||
'request-retry',
|
||||
'max-retry-interval',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps remote updates live-only', async () => {
|
||||
target = remoteTarget;
|
||||
fetchRetryMock.mockImplementation(
|
||||
async (_target: ProxyTarget, setting: string, method: string) =>
|
||||
method === 'GET'
|
||||
? new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 1 : 10 }))
|
||||
: new Response('{}')
|
||||
);
|
||||
const { applyCliproxyRetrySettings } = await loadService();
|
||||
|
||||
const result = await applyCliproxyRetrySettings({
|
||||
request_retry: 4,
|
||||
max_retry_interval: 40,
|
||||
});
|
||||
|
||||
expect(result.applied).toBe('live');
|
||||
expect(config.cliproxy?.retry).toEqual({ request_retry: 1, max_retry_interval: 10 });
|
||||
expect(regenerateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back the first upstream value when the second PUT fails', async () => {
|
||||
target = remoteTarget;
|
||||
const puts: Array<[string, number]> = [];
|
||||
fetchRetryMock.mockImplementation(
|
||||
async (_target: ProxyTarget, setting: string, method: string, value?: number) => {
|
||||
if (method === 'GET') {
|
||||
return new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 2 : 20 }));
|
||||
}
|
||||
puts.push([setting, value as number]);
|
||||
if (setting === 'max-retry-interval') {
|
||||
return new Response(JSON.stringify({ error: 'second write failed' }), { status: 500 });
|
||||
}
|
||||
return new Response('{}');
|
||||
}
|
||||
);
|
||||
const { applyCliproxyRetrySettings } = await loadService();
|
||||
|
||||
await expect(
|
||||
applyCliproxyRetrySettings({ request_retry: 5, max_retry_interval: 50 })
|
||||
).rejects.toThrow('second write failed');
|
||||
expect(puts).toEqual([
|
||||
['request-retry', 5],
|
||||
['max-retry-interval', 50],
|
||||
['request-retry', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores local persisted settings when regeneration fails', async () => {
|
||||
fetchRetryMock.mockRejectedValue(new Error('offline'));
|
||||
regenerateMock
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('write failed');
|
||||
})
|
||||
.mockImplementationOnce(() => '/tmp/config.yaml');
|
||||
const { applyCliproxyRetrySettings } = await loadService();
|
||||
|
||||
await expect(
|
||||
applyCliproxyRetrySettings({ request_retry: 9, max_retry_interval: 90 })
|
||||
).rejects.toThrow('Saved retry settings were rolled back');
|
||||
expect(config.cliproxy?.retry).toEqual({ request_retry: 1, max_retry_interval: 10 });
|
||||
expect(regenerateMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('serializes complete pair operations', async () => {
|
||||
target = remoteTarget;
|
||||
const events: string[] = [];
|
||||
let releaseFirstRead: (() => void) | undefined;
|
||||
const firstReadGate = new Promise<void>((resolve) => {
|
||||
releaseFirstRead = resolve;
|
||||
});
|
||||
let requestReadCount = 0;
|
||||
fetchRetryMock.mockImplementation(
|
||||
async (_target: ProxyTarget, setting: string, method: string, value?: number) => {
|
||||
events.push(`${method}:${setting}:${value ?? ''}`);
|
||||
if (method === 'GET' && setting === 'request-retry' && requestReadCount++ === 0) {
|
||||
await firstReadGate;
|
||||
}
|
||||
return method === 'GET'
|
||||
? new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 1 : 10 }))
|
||||
: new Response('{}');
|
||||
}
|
||||
);
|
||||
const { applyCliproxyRetrySettings } = await loadService();
|
||||
|
||||
const first = applyCliproxyRetrySettings({ request_retry: 2, max_retry_interval: 20 });
|
||||
const second = applyCliproxyRetrySettings({ request_retry: 3, max_retry_interval: 30 });
|
||||
await Promise.resolve();
|
||||
expect(events).toEqual(['GET:request-retry:']);
|
||||
releaseFirstRead?.();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(events).toEqual([
|
||||
'GET:request-retry:',
|
||||
'GET:max-retry-interval:',
|
||||
'PUT:request-retry:2',
|
||||
'PUT:max-retry-interval:20',
|
||||
'GET:request-retry:',
|
||||
'GET:max-retry-interval:',
|
||||
'PUT:request-retry:3',
|
||||
'PUT:max-retry-interval:30',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -104,4 +104,44 @@ describe('routing-strategy-http', () => {
|
||||
'https://proxy.example.com:443/v0/management/routing/strategy'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds target-aware retry management URLs', async () => {
|
||||
const { getCliproxyRetryManagementUrl } = await loadRoutingHttpModule();
|
||||
const target: ProxyTarget = {
|
||||
host: 'proxy.example.com',
|
||||
port: 443,
|
||||
protocol: 'https',
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
expect(getCliproxyRetryManagementUrl(target, 'request-retry')).toBe(
|
||||
'https://proxy.example.com:443/v0/management/request-retry'
|
||||
);
|
||||
expect(getCliproxyRetryManagementUrl(target, 'max-retry-interval')).toBe(
|
||||
'https://proxy.example.com:443/v0/management/max-retry-interval'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends retry updates as an integer value payload', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = mock(async () => new Response('{}'));
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
const target: ProxyTarget = {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const { fetchCliproxyRetryResponse } = await loadRoutingHttpModule();
|
||||
await fetchCliproxyRetryResponse(target, 'request-retry', 'PUT', 4);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:8317/v0/management/request-retry',
|
||||
expect.objectContaining({ method: 'PUT', body: JSON.stringify({ value: 4 }) })
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { regenerateConfig } from '../config/generator';
|
||||
import { getAuthDir, getConfigPathForPort } from '../config/path-resolver';
|
||||
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
|
||||
import type { ProxyTarget } from '../proxy/proxy-target-resolver';
|
||||
import { ConfigError, NetworkError } from '../../errors/error-types';
|
||||
import {
|
||||
fetchCliproxyRetryResponse,
|
||||
getCliproxyRoutingTarget,
|
||||
getRoutingErrorMessage,
|
||||
type CliproxyRetryManagementSetting,
|
||||
} from './routing-strategy-http';
|
||||
|
||||
export interface CliproxyRetryValues {
|
||||
request_retry: number;
|
||||
max_retry_interval: number;
|
||||
}
|
||||
|
||||
export interface CliproxyRetryState extends CliproxyRetryValues {
|
||||
source: 'live' | 'config';
|
||||
target: 'local' | 'remote';
|
||||
reachable: boolean;
|
||||
manageable: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CliproxyRetryApplyResult extends CliproxyRetryState {
|
||||
applied: 'live' | 'live-and-config' | 'config-only';
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_VALUES: CliproxyRetryValues = {
|
||||
request_retry: 0,
|
||||
max_retry_interval: 0,
|
||||
};
|
||||
|
||||
let retryOperationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export function normalizeCliproxyRetryValue(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
function serializeRetryOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = retryOperationQueue.then(operation, operation);
|
||||
retryOperationQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function getConfiguredRetryValues(): CliproxyRetryValues {
|
||||
const retry = loadOrCreateUnifiedConfig().cliproxy?.retry;
|
||||
return {
|
||||
request_retry:
|
||||
normalizeCliproxyRetryValue(retry?.request_retry) ?? DEFAULT_RETRY_VALUES.request_retry,
|
||||
max_retry_interval:
|
||||
normalizeCliproxyRetryValue(retry?.max_retry_interval) ??
|
||||
DEFAULT_RETRY_VALUES.max_retry_interval,
|
||||
};
|
||||
}
|
||||
|
||||
async function readLiveRetryValue(
|
||||
target: ProxyTarget,
|
||||
setting: CliproxyRetryManagementSetting
|
||||
): Promise<number> {
|
||||
const response = await fetchCliproxyRetryResponse(target, setting, 'GET');
|
||||
if (!response.ok) {
|
||||
throw new NetworkError(
|
||||
await getRoutingErrorMessage(
|
||||
response,
|
||||
`Failed to read CLIProxy ${setting} (${response.status})`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
const value = normalizeCliproxyRetryValue(data[setting] ?? data.value);
|
||||
if (value === null) {
|
||||
throw new NetworkError(`CLIProxy returned an invalid ${setting} value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readLiveRetryValues(target: ProxyTarget): Promise<CliproxyRetryValues> {
|
||||
const requestRetry = await readLiveRetryValue(target, 'request-retry');
|
||||
const maxRetryInterval = await readLiveRetryValue(target, 'max-retry-interval');
|
||||
return {
|
||||
request_retry: requestRetry,
|
||||
max_retry_interval: maxRetryInterval,
|
||||
};
|
||||
}
|
||||
|
||||
async function putLiveRetryValue(
|
||||
target: ProxyTarget,
|
||||
setting: CliproxyRetryManagementSetting,
|
||||
value: number
|
||||
): Promise<void> {
|
||||
const response = await fetchCliproxyRetryResponse(target, setting, 'PUT', value);
|
||||
if (!response.ok) {
|
||||
throw new NetworkError(
|
||||
await getRoutingErrorMessage(
|
||||
response,
|
||||
`Failed to update CLIProxy ${setting} (${response.status})`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLiveRetryValues(
|
||||
target: ProxyTarget,
|
||||
values: CliproxyRetryValues,
|
||||
previousRequestRetry: number
|
||||
): Promise<void> {
|
||||
await putLiveRetryValue(target, 'request-retry', values.request_retry);
|
||||
try {
|
||||
await putLiveRetryValue(target, 'max-retry-interval', values.max_retry_interval);
|
||||
} catch (error) {
|
||||
try {
|
||||
await putLiveRetryValue(target, 'request-retry', previousRequestRetry);
|
||||
} catch (rollbackError) {
|
||||
throw new NetworkError(
|
||||
`${(error as Error).message}. Failed to roll back request-retry: ${(rollbackError as Error).message}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function persistLocalRetryValues(target: ProxyTarget, values: CliproxyRetryValues): void {
|
||||
const previousRetry = loadOrCreateUnifiedConfig().cliproxy?.retry;
|
||||
const previous = previousRetry ? { ...previousRetry } : undefined;
|
||||
const configPath = getConfigPathForPort(target.port);
|
||||
const authDir = getAuthDir();
|
||||
|
||||
mutateConfig((config) => {
|
||||
config.cliproxy = config.cliproxy ?? {};
|
||||
config.cliproxy.retry = { ...values };
|
||||
});
|
||||
|
||||
try {
|
||||
regenerateConfig(target.port, { configPath, authDir });
|
||||
} catch (error) {
|
||||
mutateConfig((config) => {
|
||||
config.cliproxy = config.cliproxy ?? {};
|
||||
if (previous) {
|
||||
config.cliproxy.retry = previous;
|
||||
} else {
|
||||
delete config.cliproxy.retry;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
regenerateConfig(target.port, { configPath, authDir });
|
||||
} catch (rollbackError) {
|
||||
throw new ConfigError(
|
||||
`Failed to regenerate CLIProxy config: ${(error as Error).message}. Rollback regeneration also failed: ${(rollbackError as Error).message}`
|
||||
);
|
||||
}
|
||||
throw new ConfigError(
|
||||
`Failed to regenerate CLIProxy config: ${(error as Error).message}. Saved retry settings were rolled back.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function readCliproxyRetryState(): Promise<CliproxyRetryState> {
|
||||
return serializeRetryOperation(async () => {
|
||||
const target = getCliproxyRoutingTarget();
|
||||
try {
|
||||
const values = await readLiveRetryValues(target);
|
||||
return {
|
||||
...values,
|
||||
source: 'live',
|
||||
target: target.isRemote ? 'remote' : 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (target.isRemote) throw error;
|
||||
return {
|
||||
...getConfiguredRetryValues(),
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: false,
|
||||
manageable: true,
|
||||
message: 'Local CLIProxy is not reachable. Showing the saved startup defaults.',
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applyCliproxyRetrySettings(
|
||||
values: CliproxyRetryValues
|
||||
): Promise<CliproxyRetryApplyResult> {
|
||||
return serializeRetryOperation(async () => {
|
||||
const target = getCliproxyRoutingTarget();
|
||||
let previousLive: CliproxyRetryValues;
|
||||
|
||||
try {
|
||||
previousLive = await readLiveRetryValues(target);
|
||||
} catch (error) {
|
||||
if (target.isRemote) throw error;
|
||||
persistLocalRetryValues(target, values);
|
||||
return {
|
||||
...values,
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: false,
|
||||
manageable: true,
|
||||
applied: 'config-only',
|
||||
message: 'Saved the local startup defaults. They will apply the next time CLIProxy starts.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!target.isRemote) {
|
||||
persistLocalRetryValues(target, values);
|
||||
}
|
||||
|
||||
try {
|
||||
await updateLiveRetryValues(target, values, previousLive.request_retry);
|
||||
} catch (error) {
|
||||
if (target.isRemote) throw error;
|
||||
return {
|
||||
...values,
|
||||
source: 'config',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
applied: 'config-only',
|
||||
message: `Saved the local startup defaults, but the running proxy rejected the live update: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...values,
|
||||
source: 'live',
|
||||
target: target.isRemote ? 'remote' : 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
applied: target.isRemote ? 'live' : 'live-and-config',
|
||||
message: target.isRemote
|
||||
? 'Updated the running remote CLIProxy. Local CCS config was not changed.'
|
||||
: 'Updated the running local CLIProxy and saved the startup defaults.',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -9,16 +9,38 @@ import {
|
||||
const ROUTING_TIMEOUT_MS = 5000;
|
||||
const CLIPROXY_ROUTING_MANAGEMENT_PATH = '/v0/management/routing/strategy';
|
||||
|
||||
export type CliproxyRetryManagementSetting = 'request-retry' | 'max-retry-interval';
|
||||
|
||||
export function getCliproxyRoutingManagementUrl(target: ProxyTarget): string {
|
||||
return buildProxyUrl(target, CLIPROXY_ROUTING_MANAGEMENT_PATH);
|
||||
}
|
||||
|
||||
export function getCliproxyRetryManagementUrl(
|
||||
target: ProxyTarget,
|
||||
setting: CliproxyRetryManagementSetting
|
||||
): string {
|
||||
return buildProxyUrl(target, `/v0/management/${setting}`);
|
||||
}
|
||||
|
||||
export async function fetchCliproxyRoutingResponse(
|
||||
target: ProxyTarget,
|
||||
method: 'GET' | 'PUT',
|
||||
body?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const url = getCliproxyRoutingManagementUrl(target);
|
||||
return fetchCliproxyManagementResponse(
|
||||
target,
|
||||
getCliproxyRoutingManagementUrl(target),
|
||||
method,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchCliproxyManagementResponse(
|
||||
target: ProxyTarget,
|
||||
url: string,
|
||||
method: 'GET' | 'PUT',
|
||||
body?: Record<string, string | number>
|
||||
): Promise<Response> {
|
||||
const headers = buildManagementHeaders(
|
||||
target,
|
||||
body ? { 'Content-Type': 'application/json' } : {}
|
||||
@@ -115,6 +137,20 @@ export async function fetchCliproxyRoutingResponse(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCliproxyRetryResponse(
|
||||
target: ProxyTarget,
|
||||
setting: CliproxyRetryManagementSetting,
|
||||
method: 'GET' | 'PUT',
|
||||
value?: number
|
||||
): Promise<Response> {
|
||||
return fetchCliproxyManagementResponse(
|
||||
target,
|
||||
getCliproxyRetryManagementUrl(target, setting),
|
||||
method,
|
||||
value === undefined ? undefined : { value }
|
||||
);
|
||||
}
|
||||
|
||||
export function getCliproxyRoutingTarget(): ProxyTarget {
|
||||
return getProxyTarget();
|
||||
}
|
||||
|
||||
@@ -221,6 +221,13 @@ function mapRemoteToModelEntry(provider: CLIProxyProvider, remote: RemoteModelIn
|
||||
name: remote.display_name || remote.id,
|
||||
};
|
||||
if (remote.description) entry.description = remote.description;
|
||||
if (
|
||||
typeof remote.context_length === 'number' &&
|
||||
Number.isInteger(remote.context_length) &&
|
||||
remote.context_length > 0
|
||||
) {
|
||||
entry.contextWindow = remote.context_length;
|
||||
}
|
||||
// xAI context length is inherent to the model ID; its API does not accept
|
||||
// Claude's [1m] model suffix.
|
||||
if (provider !== 'xai' && remote.context_length && remote.context_length >= 1_000_000) {
|
||||
@@ -286,6 +293,7 @@ export function mergeCatalog(
|
||||
issueUrl: staticEntry.issueUrl,
|
||||
deprecated: staticEntry.deprecated,
|
||||
deprecationReason: staticEntry.deprecationReason,
|
||||
contextWindow: remoteEntry.contextWindow ?? staticEntry.contextWindow,
|
||||
});
|
||||
} else {
|
||||
mergedModels.push(remoteEntry);
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { getCliproxyDir } from './config/config-generator';
|
||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import { CLIPROXY_DEFAULT_PORT } from './config/config-generator';
|
||||
import { withSyncLockRetry } from '../utils/sync-lock-retry';
|
||||
|
||||
/** Session lock file structure */
|
||||
interface SessionLock {
|
||||
@@ -61,22 +61,9 @@ function ensureCliproxyDir(): string {
|
||||
|
||||
function withSessionTrackerLock<T>(fn: () => T): T {
|
||||
const dir = ensureCliproxyDir();
|
||||
let release: (() => void) | undefined;
|
||||
|
||||
try {
|
||||
release = lockfile.lockSync(dir, {
|
||||
stale: 10000,
|
||||
}) as () => void;
|
||||
return fn();
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
release();
|
||||
} catch {
|
||||
// Best-effort release
|
||||
}
|
||||
}
|
||||
}
|
||||
return withSyncLockRetry(dir, fn, {
|
||||
description: 'CLIProxy session tracker lock',
|
||||
});
|
||||
}
|
||||
|
||||
/** Get path to session lock file (default port) - kept for future use */
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
import type {
|
||||
CLIProxyOAuthModelAliasConfig,
|
||||
CLIProxyPayloadConfig,
|
||||
} from '../../config/schemas/cliproxy';
|
||||
import type { CliproxyRoutingStrategy } from './provider-types';
|
||||
|
||||
/** CLIProxy config.yaml structure (minimal) */
|
||||
@@ -11,6 +15,8 @@ export interface CLIProxyConfig {
|
||||
'api-keys': string[];
|
||||
'auth-dir': string;
|
||||
debug: boolean;
|
||||
'oauth-model-alias'?: CLIProxyOAuthModelAliasConfig;
|
||||
payload?: CLIProxyPayloadConfig;
|
||||
routing?: {
|
||||
strategy?: CliproxyRoutingStrategy;
|
||||
'session-affinity'?: boolean;
|
||||
|
||||
@@ -95,3 +95,17 @@ export function formatExport(shell: Shell, key: string, value: string): string {
|
||||
return `export ${key}=${posixSingleQuote(value)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format removal of one environment variable for the target shell. */
|
||||
export function formatUnset(shell: Shell, key: string): string {
|
||||
switch (shell) {
|
||||
case 'fish':
|
||||
return `set -e ${key};`;
|
||||
case 'pwsh':
|
||||
return `Remove-Item Env:${key} -ErrorAction SilentlyContinue`;
|
||||
case 'cmd':
|
||||
return `set "${key}="`;
|
||||
default:
|
||||
return `unset ${key}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import { ConfigError } from '../../errors/error-types';
|
||||
import { getServerPidPath } from './bar-paths';
|
||||
|
||||
export interface BarServerProcessRecord {
|
||||
pid: number;
|
||||
birthIdentity: string;
|
||||
}
|
||||
|
||||
export type BarServerStopResult =
|
||||
| 'stopped'
|
||||
| 'stale'
|
||||
| 'legacy-record'
|
||||
| 'invalid-record'
|
||||
| 'identity-mismatch'
|
||||
| 'permission-denied'
|
||||
| 'signal-failed'
|
||||
| 'timeout';
|
||||
|
||||
export interface BarServerStopDeps {
|
||||
getProcessBirthIdentity: (pid: number) => string | null;
|
||||
killProcess: (pid: number, signal: 'SIGTERM') => void;
|
||||
waitForProcessExit: (
|
||||
pid: number,
|
||||
birthIdentity: string,
|
||||
timeoutMs: number
|
||||
) => Promise<'exited' | 'identity-mismatch' | 'timeout'>;
|
||||
}
|
||||
|
||||
export function parseBarServerProcessRecord(raw: string): BarServerProcessRecord | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<BarServerProcessRecord>;
|
||||
const pid = parsed.pid;
|
||||
if (!Number.isSafeInteger(pid) || (pid ?? 0) <= 0) return null;
|
||||
if (typeof parsed.birthIdentity !== 'string' || parsed.birthIdentity.trim() === '') return null;
|
||||
return { pid: pid as number, birthIdentity: parsed.birthIdentity };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLegacyServerPid(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!/^[1-9]\d*$/.test(trimmed)) return null;
|
||||
const pid = Number(trimmed);
|
||||
return Number.isSafeInteger(pid) ? pid : null;
|
||||
}
|
||||
|
||||
export function serializeBarServerProcessRecord(record: BarServerProcessRecord): string {
|
||||
return JSON.stringify(record, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the OS process birth marker for PID reuse protection. CCS Bar is a
|
||||
* macOS app, where `ps lstart` is stable for a process lifetime. Linux uses the
|
||||
* same portable ps surface in development and CI.
|
||||
*/
|
||||
export function getProcessBirthIdentity(pid: number): string | null {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
||||
try {
|
||||
const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
return output || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForProcessExit(
|
||||
pid: number,
|
||||
birthIdentity: string,
|
||||
timeoutMs: number
|
||||
): Promise<'exited' | 'identity-mismatch' | 'timeout'> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const currentIdentity = getProcessBirthIdentity(pid);
|
||||
if (currentIdentity === null) return 'exited';
|
||||
if (currentIdentity !== birthIdentity) return 'identity-mismatch';
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
export async function stopRecordedBarServer(
|
||||
rawRecord: string,
|
||||
deps: Partial<BarServerStopDeps> = {}
|
||||
): Promise<{ result: BarServerStopResult; record: BarServerProcessRecord | null; error?: Error }> {
|
||||
const record = parseBarServerProcessRecord(rawRecord);
|
||||
if (record === null) {
|
||||
return {
|
||||
result: parseLegacyServerPid(rawRecord) === null ? 'invalid-record' : 'legacy-record',
|
||||
record: null,
|
||||
};
|
||||
}
|
||||
|
||||
const getIdentity = deps.getProcessBirthIdentity ?? getProcessBirthIdentity;
|
||||
const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal));
|
||||
const waitForExit = deps.waitForProcessExit ?? waitForProcessExit;
|
||||
|
||||
// Revalidate immediately before SIGTERM. A PID alone is unsafe because the OS
|
||||
// can reuse it after the recorded CCS Bar process exits.
|
||||
const currentIdentity = getIdentity(record.pid);
|
||||
if (currentIdentity === null) return { result: 'stale', record };
|
||||
if (currentIdentity !== record.birthIdentity) return { result: 'identity-mismatch', record };
|
||||
|
||||
try {
|
||||
killProcess(record.pid, 'SIGTERM');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ESRCH') return { result: 'stale', record };
|
||||
if (code === 'EPERM') return { result: 'permission-denied', record, error };
|
||||
return { result: 'signal-failed', record, error };
|
||||
}
|
||||
|
||||
const waitResult = await waitForExit(record.pid, record.birthIdentity, 3_000);
|
||||
if (waitResult === 'exited') return { result: 'stopped', record };
|
||||
if (waitResult === 'identity-mismatch') return { result: 'identity-mismatch', record };
|
||||
return { result: 'timeout', record };
|
||||
}
|
||||
|
||||
export interface ClaimedBarServerStopOutcome {
|
||||
result: BarServerStopResult | 'no-record';
|
||||
record: BarServerProcessRecord | null;
|
||||
legacyPid?: number;
|
||||
error?: Error;
|
||||
recoveryPath?: string;
|
||||
}
|
||||
|
||||
function isErrno(err: unknown, code: string): boolean {
|
||||
return (err as NodeJS.ErrnoException).code === code;
|
||||
}
|
||||
|
||||
function unlinkIfPresent(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (err) {
|
||||
if (!isErrno(err, 'ENOENT')) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreClaimWithoutOverwrite(
|
||||
claimPath: string,
|
||||
canonicalPath: string
|
||||
): string | undefined {
|
||||
try {
|
||||
fs.linkSync(claimPath, canonicalPath);
|
||||
unlinkIfPresent(claimPath);
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
if (isErrno(err, 'EEXIST')) return claimPath;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim server.pid before signaling. The serve process therefore
|
||||
* sees ENOENT during its signal handler and cannot unlink a new owner's record.
|
||||
*/
|
||||
export async function stopBarServerProcessFile(
|
||||
pidPath: string,
|
||||
deps: Partial<BarServerStopDeps> = {}
|
||||
): Promise<ClaimedBarServerStopOutcome> {
|
||||
const claimPath = `${pidPath}.claim-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
try {
|
||||
fs.renameSync(pidPath, claimPath);
|
||||
} catch (err) {
|
||||
if (isErrno(err, 'ENOENT')) return { result: 'no-record', record: null };
|
||||
throw err;
|
||||
}
|
||||
|
||||
let rawRecord: string;
|
||||
try {
|
||||
rawRecord = fs.readFileSync(claimPath, 'utf8').trim();
|
||||
} catch (err) {
|
||||
const recoveryPath = restoreClaimWithoutOverwrite(claimPath, pidPath);
|
||||
return {
|
||||
result: 'invalid-record',
|
||||
record: null,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
recoveryPath,
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = await stopRecordedBarServer(rawRecord, deps);
|
||||
if (outcome.result === 'stopped' || outcome.result === 'stale') {
|
||||
unlinkIfPresent(claimPath);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
const recoveryPath = restoreClaimWithoutOverwrite(claimPath, pidPath);
|
||||
return {
|
||||
...outcome,
|
||||
legacyPid:
|
||||
outcome.result === 'legacy-record'
|
||||
? (parseLegacyServerPid(rawRecord) ?? undefined)
|
||||
: undefined,
|
||||
recoveryPath,
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove server.pid only when it still belongs to this exact serve process. */
|
||||
export function removeBarServerProcessRecordIfOwned(
|
||||
pidPath: string,
|
||||
expectedRecord: BarServerProcessRecord
|
||||
): void {
|
||||
const claimPath = `${pidPath}.cleanup-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
try {
|
||||
fs.renameSync(pidPath, claimPath);
|
||||
} catch (err) {
|
||||
if (isErrno(err, 'ENOENT')) return;
|
||||
throw err;
|
||||
}
|
||||
|
||||
let claimedRecord: BarServerProcessRecord | null = null;
|
||||
try {
|
||||
claimedRecord = parseBarServerProcessRecord(fs.readFileSync(claimPath, 'utf8'));
|
||||
} catch {
|
||||
// Preserve unreadable state below.
|
||||
}
|
||||
|
||||
if (
|
||||
claimedRecord?.pid === expectedRecord.pid &&
|
||||
claimedRecord.birthIdentity === expectedRecord.birthIdentity
|
||||
) {
|
||||
unlinkIfPresent(claimPath);
|
||||
return;
|
||||
}
|
||||
restoreClaimWithoutOverwrite(claimPath, pidPath);
|
||||
}
|
||||
|
||||
/** Claim stale discovery state so a replacement write can never be unlinked. */
|
||||
export function removeBarDiscoveryIfNoProcess(barJsonPath: string, pidPath: string): boolean {
|
||||
const claimPath = `${barJsonPath}.cleanup-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
try {
|
||||
fs.renameSync(barJsonPath, claimPath);
|
||||
} catch (err) {
|
||||
if (isErrno(err, 'ENOENT')) return true;
|
||||
throw err;
|
||||
}
|
||||
if (fs.existsSync(pidPath)) {
|
||||
restoreClaimWithoutOverwrite(claimPath, barJsonPath);
|
||||
return false;
|
||||
}
|
||||
unlinkIfPresent(claimPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function stopDetachedBarServer(ccsDir: string): Promise<void> {
|
||||
const pidPath = getServerPidPath(ccsDir);
|
||||
try {
|
||||
const outcome = await stopBarServerProcessFile(pidPath);
|
||||
if (outcome.result !== 'stopped' && outcome.result !== 'stale') {
|
||||
throw new ConfigError(
|
||||
`Safe CCS Bar stop aborted (${outcome.result}); recovery state was preserved`,
|
||||
outcome.recoveryPath ?? pidPath
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigError) throw err;
|
||||
throw new ConfigError(
|
||||
`Cannot safely stop CCS Bar process: ${err instanceof Error ? err.message : String(err)}`,
|
||||
pidPath
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,16 @@ export interface DashboardInfo {
|
||||
authRequired?: boolean;
|
||||
}
|
||||
|
||||
function isValidPort(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the port recorded in an existing bar.json.
|
||||
* Returns null when the file is absent or malformed.
|
||||
* Read the port recorded in an existing bar.json, falling back to the --port
|
||||
* in launch.json's args. `ccs bar stop` deletes bar.json but leaves
|
||||
* launch.json, so the fallback is what keeps the sticky port (and the probe's
|
||||
* ability to find a server on a non-default port) across a stop/start cycle.
|
||||
* Returns null when neither file records a port.
|
||||
*/
|
||||
|
||||
export function resolveBarPort(ccsDir: string): number | null {
|
||||
@@ -35,10 +42,28 @@ export function resolveBarPort(ccsDir: string): number | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(barJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<{ port: number }>;
|
||||
return typeof parsed.port === 'number' ? parsed.port : null;
|
||||
if (isValidPort(parsed.port)) return parsed.port;
|
||||
} catch {
|
||||
return null;
|
||||
/* fall through to launch.json */
|
||||
}
|
||||
|
||||
const launchJsonPath = path.join(ccsDir, 'bar', 'launch.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(launchJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<{ args: unknown[] }>;
|
||||
const args = Array.isArray(parsed.args) ? parsed.args : [];
|
||||
const idx = args.indexOf('--port');
|
||||
if (idx !== -1 && idx + 1 < args.length) {
|
||||
const rawPort = args[idx + 1];
|
||||
if (typeof rawPort === 'string' && /^[1-9]\d{0,4}$/.test(rawPort)) {
|
||||
const n = Number(rawPort);
|
||||
if (isValidPort(n)) return n;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* absent or malformed -> null */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,18 +109,18 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
|
||||
// loopback service that streams forever cannot block discovery from
|
||||
// returning a higher-priority hit.
|
||||
socket.destroy();
|
||||
const authRequired = statusCode === 401 || statusCode === 403;
|
||||
const proofMatch = headerSection.match(
|
||||
new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i')
|
||||
);
|
||||
const proof = proofMatch ? proofMatch[1].trim() : '';
|
||||
const tokenMatched = isMatchingBarAuthProof(token, nonce, proof);
|
||||
const authRequired = (statusCode === 401 || statusCode === 403) && tokenMatched;
|
||||
if (authRequired) {
|
||||
resolve({ ok: true, authRequired: true });
|
||||
return;
|
||||
}
|
||||
if (statusCode === 200) {
|
||||
// Accept only when the server includes a correct nonce-bound proof.
|
||||
const echoMatch = headerSection.match(
|
||||
new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i')
|
||||
);
|
||||
const proof = echoMatch ? echoMatch[1].trim() : '';
|
||||
resolve({ ok: isMatchingBarAuthProof(token, nonce, proof), authRequired: false });
|
||||
resolve({ ok: tokenMatched, authRequired: false });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: false, authRequired: false });
|
||||
@@ -117,12 +142,12 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
|
||||
const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
||||
if (statusMatch) {
|
||||
const code = Number(statusMatch[1]);
|
||||
// For non-200 we can finish on the status line alone.
|
||||
if (code !== 200) {
|
||||
// CCS-authenticated 401/403 responses also carry the nonce proof, so
|
||||
// wait for their complete headers before deciding identity.
|
||||
if (code !== 200 && code !== 401 && code !== 403) {
|
||||
finish(code, rawResponse);
|
||||
return;
|
||||
}
|
||||
// For 200 we need the headers section to extract the token.
|
||||
if (rawResponse.includes('\r\n\r\n')) {
|
||||
finish(code, rawResponse.split('\r\n\r\n')[0]);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
'Options:',
|
||||
[
|
||||
['--port <n>', 'Run the server on this port (persists; later launches keep the same port)'],
|
||||
['--help, -h', 'Show this help message'],
|
||||
['--version', 'Show CLI and installed app versions'],
|
||||
],
|
||||
@@ -44,6 +45,7 @@ export async function showHelp(): Promise<void> {
|
||||
'Examples:',
|
||||
[
|
||||
['ccs bar', 'Start the server detached and open CCS Bar'],
|
||||
['ccs bar --port 3999', 'Start (or move) the server on port 3999 instead of 3000'],
|
||||
['ccs bar stop', 'Stop the detached CCS Bar server'],
|
||||
['ccs bar status', 'Show server running state and PID'],
|
||||
['ccs bar install', 'Download and install CCS Bar, then prompt to launch'],
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { hasAnyFlag } from '../arg-extractor';
|
||||
import { validatePortArgs } from './port-arg';
|
||||
|
||||
export async function handleBarCommand(args: string[]): Promise<void> {
|
||||
const subcommand = args[0];
|
||||
@@ -56,9 +57,18 @@ export async function handleBarCommand(args: string[]): Promise<void> {
|
||||
},
|
||||
};
|
||||
|
||||
// Bare `ccs bar` → launch
|
||||
if (!subcommand || subcommand === 'launch') {
|
||||
await commandHandlers.launch(subcommand ? args.slice(1) : []);
|
||||
// Bare `ccs bar` → launch. Bare flags (e.g. `ccs bar --port 3999`) also go to
|
||||
// launch with the full arg list preserved (--help/--version were handled above).
|
||||
if (!subcommand || subcommand === 'launch' || subcommand.startsWith('-')) {
|
||||
const launchArgs = subcommand === 'launch' ? args.slice(1) : args;
|
||||
const argError = validatePortArgs(launchArgs);
|
||||
if (argError !== null) {
|
||||
console.error(`[X] ${argError}`);
|
||||
console.error('[i] Usage: ccs bar [--port N]');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await commandHandlers.launch(launchArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface LaunchDescriptorOptions {
|
||||
runtime?: string;
|
||||
home?: string;
|
||||
ccsHome?: string;
|
||||
/** Server port; recorded in args so the Swift app self-starts on the same port. */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export function getLaunchShimPath(home: string = os.homedir()): string {
|
||||
@@ -84,7 +86,12 @@ export function createBarLaunchDescriptor(options: LaunchDescriptorOptions = {})
|
||||
return {
|
||||
schema: LAUNCH_JSON_SCHEMA,
|
||||
runtime: options.runtime ?? process.execPath,
|
||||
args: [entrypoint, 'bar', 'serve'],
|
||||
args: [
|
||||
entrypoint,
|
||||
'bar',
|
||||
'serve',
|
||||
...(options.port !== undefined ? ['--port', String(options.port)] : []),
|
||||
],
|
||||
home,
|
||||
...(ccsHome ? { ccsHome } : {}),
|
||||
};
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
* Detached model (replaces the old in-process model):
|
||||
* 1. Probe candidate ports (bar.json port first, then 3000/3001/3002/8000/8080).
|
||||
* 2. If a live server is found → reuse it, write bar.json, open app, return.
|
||||
* 3. Else → refresh launch.json, getPort to pick a free port, spawn
|
||||
* With an explicit --port that differs from the running server's port,
|
||||
* stop that server first and fall through to a fresh start instead.
|
||||
* 3. Else → pick a port (--port exactly when given; otherwise bar.json's
|
||||
* recorded port first, then the default candidates), refresh launch.json
|
||||
* (including --port so the Swift app self-starts on the same port), spawn
|
||||
* `ccs bar serve --port N` detached with stdio → serve.log, poll
|
||||
* /api/bar/summary until 200 (timeout ~10 s), write bar.json, open app,
|
||||
* return. The CLI process exits; the server continues as a detached child.
|
||||
@@ -31,11 +35,13 @@ import {
|
||||
import { getBarDir, getBarJsonPath, getLaunchJsonPath, getServeLogPath } from './bar-paths';
|
||||
import type { LaunchJson } from './bar-paths';
|
||||
import { createBarLaunchDescriptor } from './launch-descriptor';
|
||||
import { parsePortFlag, validatePortArgs } from './port-arg';
|
||||
import {
|
||||
defaultFindRunningServer as _defaultFindRunningServer,
|
||||
resolveBarPort as _resolveBarPort,
|
||||
} from './bar-server-probe';
|
||||
import type { DashboardInfo as _DashboardInfo } from './bar-server-probe';
|
||||
import { stopDetachedBarServer } from './bar-process-control';
|
||||
|
||||
const BAR_PROBE_TIMEOUT_MS = 1500;
|
||||
const MAX_BAR_PROBE_RESPONSE_BYTES = 8192;
|
||||
@@ -83,10 +89,21 @@ export interface LaunchDeps {
|
||||
* Returns the live baseUrl on success, throws on timeout.
|
||||
*/
|
||||
waitForServerLive: (baseUrl: string) => Promise<void>;
|
||||
/**
|
||||
* Build the launch.json descriptor (includes the chosen --port so the Swift
|
||||
* app self-starts the server on the same port).
|
||||
*/
|
||||
createLaunchDescriptor: (opts?: { port?: number }) => LaunchJson;
|
||||
/**
|
||||
* Write launch.json so the Swift app can spawn the server independently.
|
||||
*/
|
||||
writeLaunchDescriptor: (jsonPath: string, descriptor: LaunchJson) => void;
|
||||
/**
|
||||
* Stop the detached CCS Bar server recorded in server.pid and wait briefly
|
||||
* for the port to free. Used when an explicit --port differs from the port
|
||||
* the running server occupies.
|
||||
*/
|
||||
stopDetachedServer: (ccsDir: string) => Promise<void>;
|
||||
/** Open the installed .app bundle. Throws if the app is not found. */
|
||||
openApp: (appPath: string) => Promise<void>;
|
||||
/** Returns path to ~/.ccs (respects CCS_HOME for test isolation). */
|
||||
@@ -169,15 +186,11 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
||||
settled = true;
|
||||
clearTimeout(absoluteDeadline);
|
||||
socket.destroy();
|
||||
if (statusCode === 200) {
|
||||
const echoMatch = headerSection.match(
|
||||
new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i')
|
||||
);
|
||||
const proof = echoMatch ? echoMatch[1].trim() : '';
|
||||
resolve({ statusCode, tokenMatched: isMatchingBarAuthProof(token, nonce, proof) });
|
||||
return;
|
||||
}
|
||||
resolve({ statusCode, tokenMatched: false });
|
||||
const echoMatch = headerSection.match(
|
||||
new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i')
|
||||
);
|
||||
const proof = echoMatch ? echoMatch[1].trim() : '';
|
||||
resolve({ statusCode, tokenMatched: isMatchingBarAuthProof(token, nonce, proof) });
|
||||
};
|
||||
const socket = net.connect(
|
||||
{ host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) },
|
||||
@@ -199,7 +212,7 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
||||
const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
||||
if (statusMatch) {
|
||||
const code = Number(statusMatch[1]);
|
||||
if (code !== 200) {
|
||||
if (code !== 200 && code !== 401 && code !== 403) {
|
||||
finish(code, rawResponse);
|
||||
return;
|
||||
}
|
||||
@@ -221,7 +234,7 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
||||
const { statusCode, tokenMatched } = await probe();
|
||||
|
||||
if (statusCode === 200 && tokenMatched) return;
|
||||
if (statusCode !== null && isAuthRequiredStatus(statusCode)) {
|
||||
if (statusCode !== null && isAuthRequiredStatus(statusCode) && tokenMatched) {
|
||||
throw new BarServerAuthRequiredError(baseUrl, statusCode);
|
||||
}
|
||||
|
||||
@@ -258,13 +271,21 @@ export async function handleBarLaunch(
|
||||
_args: string[],
|
||||
deps: Partial<LaunchDeps> = {}
|
||||
): Promise<void> {
|
||||
const argError = validatePortArgs(_args);
|
||||
if (argError !== null) {
|
||||
console.error(`[X] ${argError}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
||||
const openApp = deps.openApp ?? defaultOpenApp;
|
||||
const appInstallPath = deps.appInstallPath ?? DEFAULT_APP_INSTALL_PATH;
|
||||
const getPortFn = deps.getPort ?? defaultGetPort;
|
||||
const spawnDetachedServer = deps.spawnDetachedServer ?? defaultSpawnDetachedServer;
|
||||
const waitForServerLive = deps.waitForServerLive ?? defaultWaitForServerLive;
|
||||
const createLaunchDescriptor = deps.createLaunchDescriptor ?? createBarLaunchDescriptor;
|
||||
const writeLaunchDescriptor = deps.writeLaunchDescriptor ?? defaultWriteLaunchDescriptor;
|
||||
const stopDetachedServer = deps.stopDetachedServer ?? stopDetachedBarServer;
|
||||
|
||||
// Wire findRunningServer after ccsDir is resolved.
|
||||
const findRunningServer = deps.findRunningServer ?? (() => _defaultFindRunningServer(ccsDir));
|
||||
@@ -272,6 +293,16 @@ export async function handleBarLaunch(
|
||||
const barJsonPath = getBarJsonPath(ccsDir);
|
||||
const launchJsonPath = getLaunchJsonPath(ccsDir);
|
||||
|
||||
// 0. Parse --port. A present-but-invalid value is a hard error (silently
|
||||
// launching on a different port than the user asked for is worse).
|
||||
const portFlag = parsePortFlag(_args);
|
||||
if (portFlag.present && portFlag.port === null) {
|
||||
console.error('[X] Invalid --port value. Use a number between 1 and 65535.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const requestedPort = portFlag.port;
|
||||
|
||||
// 1. Probe for an already-running server.
|
||||
let running: DashboardInfo | null = null;
|
||||
try {
|
||||
@@ -280,6 +311,9 @@ export async function handleBarLaunch(
|
||||
/* any probe error counts as null */
|
||||
}
|
||||
|
||||
let movingFrom: DashboardInfo | null = null;
|
||||
let port: number | null = null;
|
||||
|
||||
if (running !== null) {
|
||||
if (running.authRequired) {
|
||||
console.error(
|
||||
@@ -291,59 +325,127 @@ export async function handleBarLaunch(
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse the live server — write bar.json and open the app.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl: running.baseUrl,
|
||||
port: running.port,
|
||||
authMode: 'loopback',
|
||||
};
|
||||
try {
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
||||
if (requestedPort === null || running.port === requestedPort) {
|
||||
// Reuse the live server — write bar.json and open the app.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl: running.baseUrl,
|
||||
port: running.port,
|
||||
authMode: 'loopback',
|
||||
};
|
||||
try {
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(barJsonPath, JSON.stringify(barJson, null, 2));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[OK] Reusing running CCS web-server at ${running.baseUrl}`);
|
||||
console.log(`[i] Discovery file written: ${barJsonPath}`);
|
||||
await _openAppWithFallback(appInstallPath, openApp);
|
||||
return;
|
||||
}
|
||||
console.log(`[OK] Reusing running CCS web-server at ${running.baseUrl}`);
|
||||
console.log(`[i] Discovery file written: ${barJsonPath}`);
|
||||
await _openAppWithFallback(appInstallPath, openApp);
|
||||
return;
|
||||
|
||||
// Explicit --port that differs from the running server: preflight the
|
||||
// destination before disrupting the healthy current service.
|
||||
console.log(
|
||||
`[i] CCS Bar server is running on port ${running.port}; moving to port ${requestedPort}...`
|
||||
);
|
||||
if (requestedPort === null) return;
|
||||
try {
|
||||
const availablePort = await getPortFn({ port: [requestedPort], host: '127.0.0.1' });
|
||||
if (availablePort !== requestedPort) {
|
||||
console.error(`[X] Port ${requestedPort} is already in use by another process.`);
|
||||
console.error('[i] The existing CCS Bar server was left running.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
port = requestedPort;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not preflight port ${requestedPort}: ${msg}`);
|
||||
console.error('[i] The existing CCS Bar server was left running.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await stopDetachedServer(ccsDir);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not safely stop the running server: ${msg}`);
|
||||
console.error('[i] Recovery state was preserved; resolve the stop error, then retry.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
movingFrom = running;
|
||||
// Fall through to the fresh-start path below.
|
||||
}
|
||||
|
||||
// 2. No live server — pick a port, write/refresh launch.json, spawn detached.
|
||||
// 2. No live server (or moving ports) — pick a port, write/refresh
|
||||
// launch.json, spawn detached.
|
||||
|
||||
// 2a. Pick a free port.
|
||||
let port: number;
|
||||
// 2a. Pick a free port. An explicit --port must be honored exactly; without
|
||||
// it, the port recorded in bar.json is preferred so the server keeps
|
||||
// coming back on the port the user last chose (sticky port).
|
||||
try {
|
||||
port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' });
|
||||
if (port !== null) {
|
||||
// Destination was already preflighted before stopping the prior server.
|
||||
} else if (requestedPort !== null) {
|
||||
const got = await getPortFn({ port: [requestedPort], host: '127.0.0.1' });
|
||||
if (got !== requestedPort) {
|
||||
console.error(`[X] Port ${requestedPort} is already in use by another process.`);
|
||||
console.error('[i] Choose a different port or free it, then retry.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
port = requestedPort;
|
||||
} else {
|
||||
const stickyPort = _resolveBarPort(ccsDir);
|
||||
const base = [3000, 3001, 3002, 8000, 8080];
|
||||
const candidates =
|
||||
stickyPort !== null ? [stickyPort, ...base.filter((p) => p !== stickyPort)] : base;
|
||||
port = await getPortFn({ port: candidates, host: '127.0.0.1' });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not find a free port: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2b. Write/refresh launch.json so the Swift app can self-start next time.
|
||||
try {
|
||||
const launchDescriptor = createBarLaunchDescriptor();
|
||||
writeLaunchDescriptor(launchJsonPath, launchDescriptor);
|
||||
} catch (err) {
|
||||
// Non-fatal — the Swift app falls back to resolving `ccs` via PATH.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[!] Could not write launch.json: ${msg}`);
|
||||
if (port === null) {
|
||||
console.error('[X] Could not resolve a valid CCS Bar port.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const selectedPort = port;
|
||||
|
||||
// 2c. Spawn the detached server.
|
||||
const rollbackPriorServer = async (): Promise<void> => {
|
||||
if (movingFrom === null) return;
|
||||
try {
|
||||
spawnDetachedServer(movingFrom.port, serveLogPath);
|
||||
await waitForServerLive(movingFrom.baseUrl);
|
||||
console.log(`[OK] Restored CCS Bar server at ${movingFrom.baseUrl}.`);
|
||||
} catch (rollbackErr) {
|
||||
const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
||||
console.error(`[X] Failed to restore CCS Bar at ${movingFrom.baseUrl}: ${message}`);
|
||||
console.error('[i] Existing discovery and launch state was preserved for manual recovery.');
|
||||
}
|
||||
};
|
||||
|
||||
// 2b. Spawn the detached server. launch.json is not replaced until the new
|
||||
// server is proven live, preserving the prior recovery path during a move.
|
||||
const serveLogPath = getServeLogPath(ccsDir);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseUrl = `http://127.0.0.1:${selectedPort}`;
|
||||
let spawnedChild: ChildProcess | void;
|
||||
try {
|
||||
fs.mkdirSync(getBarDir(ccsDir), { recursive: true });
|
||||
spawnedChild = spawnDetachedServer(port, serveLogPath);
|
||||
spawnedChild = spawnDetachedServer(selectedPort, serveLogPath);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not start CCS web-server: ${msg}`);
|
||||
console.error('[i] Run `ccs config` to start the dashboard manually.');
|
||||
await rollbackPriorServer();
|
||||
if (movingFrom === null) console.error('[i] Run `ccs config` to start the dashboard manually.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -361,18 +463,30 @@ export async function handleBarLaunch(
|
||||
console.error(
|
||||
'[i] Disable dashboard authentication for CCS Bar or start the dashboard manually.'
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not connect to CCS web-server: ${msg}`);
|
||||
console.error(`[i] Check logs at ${serveLogPath}`);
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not connect to CCS web-server: ${msg}`);
|
||||
console.error(`[i] Check logs at ${serveLogPath}`);
|
||||
spawnedChild?.kill();
|
||||
await rollbackPriorServer();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2d. Persist the proven-good recovery descriptor.
|
||||
try {
|
||||
const launchDescriptor = createLaunchDescriptor({ port: selectedPort });
|
||||
writeLaunchDescriptor(launchJsonPath, launchDescriptor);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[!] Could not write launch.json: ${msg}`);
|
||||
}
|
||||
|
||||
// 2e. Write bar.json.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl,
|
||||
port,
|
||||
port: selectedPort,
|
||||
authMode: 'loopback',
|
||||
};
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared `--port N` flag parsing for the `ccs bar` command family.
|
||||
*
|
||||
* `present` distinguishes "flag not given" from "flag given with a bad value"
|
||||
* so launch can reject typos loudly instead of silently falling back to the
|
||||
* default port list.
|
||||
*/
|
||||
|
||||
export interface PortFlag {
|
||||
/** True when `--port` appears in args at all. */
|
||||
present: boolean;
|
||||
/** The parsed port (1-65535), or null when absent or invalid. */
|
||||
port: number | null;
|
||||
}
|
||||
|
||||
export function validatePortArgs(args: string[]): string | null {
|
||||
let foundPort = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg !== '--port') return `Unknown option: ${arg}`;
|
||||
if (foundPort) return 'Duplicate option: --port';
|
||||
foundPort = true;
|
||||
const raw = args[index + 1];
|
||||
if (raw === undefined) return 'Missing value for --port';
|
||||
index += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parsePortFlag(args: string[]): PortFlag {
|
||||
const idx = args.indexOf('--port');
|
||||
if (idx === -1) return { present: false, port: null };
|
||||
const raw = args[idx + 1];
|
||||
if (raw === undefined || !/^[1-9]\d{0,4}$/.test(raw)) {
|
||||
return { present: true, port: null };
|
||||
}
|
||||
const n = Number(raw);
|
||||
const valid = Number.isSafeInteger(n) && n <= 65535;
|
||||
return { present: true, port: valid ? n : null };
|
||||
}
|
||||
@@ -16,9 +16,16 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import { getBarJsonPath, getServerPidPath } from './bar-paths';
|
||||
import { defaultFindRunningServer } from './bar-server-probe';
|
||||
import { defaultFindRunningServer, resolveBarPort } from './bar-server-probe';
|
||||
import { parsePortFlag, validatePortArgs } from './port-arg';
|
||||
import type { DashboardInfo } from './bar-server-probe';
|
||||
import type { BarDiscoveryJson } from './launch-subcommand';
|
||||
import {
|
||||
getProcessBirthIdentity,
|
||||
removeBarServerProcessRecordIfOwned,
|
||||
serializeBarServerProcessRecord,
|
||||
} from './bar-process-control';
|
||||
import type { BarServerProcessRecord } from './bar-process-control';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — injectable deps for testability
|
||||
@@ -44,10 +51,14 @@ export interface ServeDeps {
|
||||
writeFile: (filePath: string, content: string) => void;
|
||||
/** Remove a file if it exists (for cleanup on exit). */
|
||||
removeFile: (filePath: string) => void;
|
||||
/** Remove server.pid only if it still identifies this process. */
|
||||
removeProcessRecordIfOwned: (filePath: string, record: BarServerProcessRecord) => void;
|
||||
/** Register process signal handlers. */
|
||||
onSignal: (signal: 'SIGINT' | 'SIGTERM', handler: () => void) => void;
|
||||
/** Exit the process. */
|
||||
exit: (code: number) => never;
|
||||
/** Read a stable OS birth marker so later stop commands cannot signal a reused PID. */
|
||||
getProcessBirthIdentity: (pid: number) => string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,14 +84,6 @@ function defaultWriteFile(filePath: string, content: string): void {
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function defaultRemoveFile(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
/* ignore — file may already be gone */
|
||||
}
|
||||
}
|
||||
|
||||
function defaultOnSignal(signal: 'SIGINT' | 'SIGTERM', handler: () => void): void {
|
||||
process.on(signal, handler);
|
||||
}
|
||||
@@ -93,32 +96,27 @@ function defaultGetCcsDir(): string {
|
||||
return getCcsDir();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argument parsing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parsePortArg(args: string[]): number | null {
|
||||
const idx = args.indexOf('--port');
|
||||
if (idx !== -1 && idx + 1 < args.length) {
|
||||
const n = parseInt(args[idx + 1], 10);
|
||||
return Number.isFinite(n) && n > 0 && n < 65536 ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleBarServe(args: string[], deps: Partial<ServeDeps> = {}): Promise<void> {
|
||||
const argError = validatePortArgs(args);
|
||||
if (argError !== null) {
|
||||
console.error(`[X] ${argError}`);
|
||||
(deps.exit ?? defaultExit)(1);
|
||||
return;
|
||||
}
|
||||
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
||||
const findRunningServer = deps.findRunningServer ?? (() => defaultFindRunningServer(ccsDir));
|
||||
const startServerFn = deps.startServer ?? defaultStartServer;
|
||||
const getPortFn = deps.getPort ?? defaultGetPort;
|
||||
const writeFile = deps.writeFile ?? defaultWriteFile;
|
||||
const removeFile = deps.removeFile ?? defaultRemoveFile;
|
||||
const removeProcessRecordIfOwned =
|
||||
deps.removeProcessRecordIfOwned ?? removeBarServerProcessRecordIfOwned;
|
||||
const onSignal = deps.onSignal ?? defaultOnSignal;
|
||||
const exit = deps.exit ?? defaultExit;
|
||||
const readProcessBirthIdentity = deps.getProcessBirthIdentity ?? getProcessBirthIdentity;
|
||||
|
||||
const barJsonPath = getBarJsonPath(ccsDir);
|
||||
const serverPidPath = getServerPidPath(ccsDir);
|
||||
@@ -151,12 +149,24 @@ export async function handleBarServe(args: string[], deps: Partial<ServeDeps> =
|
||||
|
||||
// 2. No live server found — start one.
|
||||
// Honor --port N from the launcher (it pre-selected via getPort to avoid races).
|
||||
const requestedPort = parsePortArg(args);
|
||||
// Without it, prefer the port recorded in bar.json so the server keeps coming
|
||||
// back on the port the user last chose (sticky port).
|
||||
const portFlag = parsePortFlag(args);
|
||||
if (portFlag.present && portFlag.port === null) {
|
||||
console.error('[X] Invalid --port value. Use an integer between 1 and 65535.');
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
const requestedPort = portFlag.port;
|
||||
let port: number;
|
||||
if (requestedPort !== null) {
|
||||
port = requestedPort;
|
||||
} else {
|
||||
port = await getPortFn({ port: [3000, 3001, 3002, 8000, 8080], host: '127.0.0.1' });
|
||||
const stickyPort = resolveBarPort(ccsDir);
|
||||
const base = [3000, 3001, 3002, 8000, 8080];
|
||||
const candidates =
|
||||
stickyPort !== null ? [stickyPort, ...base.filter((p) => p !== stickyPort)] : base;
|
||||
port = await getPortFn({ port: candidates, host: '127.0.0.1' });
|
||||
}
|
||||
|
||||
// TypeScript cannot infer that exit(1) is `never` when it is injected as a dep,
|
||||
@@ -171,26 +181,43 @@ export async function handleBarServe(args: string[], deps: Partial<ServeDeps> =
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 3. Write bar.json and server.pid.
|
||||
// 3. Publish the owned process record before discovery. Stop cleanup uses
|
||||
// server.pid as the ownership barrier, so discovery must never appear first.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl: dashboardInfo.baseUrl,
|
||||
port: dashboardInfo.port,
|
||||
authMode: 'loopback',
|
||||
};
|
||||
const birthIdentity = readProcessBirthIdentity(process.pid);
|
||||
if (birthIdentity === null) {
|
||||
console.error('[X] Failed to record CCS Bar process identity; stopping unmanaged server.');
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const processRecord = { pid: process.pid, birthIdentity };
|
||||
try {
|
||||
writeFile(barJsonPath, JSON.stringify(barJson, null, 2));
|
||||
writeFile(serverPidPath, serializeBarServerProcessRecord(processRecord));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
||||
console.error(`[X] Failed to write server.pid: ${msg}`);
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
writeFile(serverPidPath, String(process.pid));
|
||||
writeFile(barJsonPath, JSON.stringify(barJson, null, 2));
|
||||
} catch (err) {
|
||||
// Non-fatal — stop/status will just degrade gracefully.
|
||||
try {
|
||||
removeProcessRecordIfOwned(serverPidPath, processRecord);
|
||||
} catch (cleanupErr) {
|
||||
const cleanupMessage = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
||||
console.error(`[!] Failed to roll back server.pid: ${cleanupMessage}`);
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[!] Failed to write server.pid: ${msg}`);
|
||||
console.error(`[X] Failed to write bar.json: ${msg}`);
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[OK] CCS Bar server started at ${dashboardInfo.baseUrl}`);
|
||||
@@ -198,7 +225,7 @@ export async function handleBarServe(args: string[], deps: Partial<ServeDeps> =
|
||||
|
||||
// 4. Clean shutdown on SIGINT / SIGTERM.
|
||||
const shutdown = (): void => {
|
||||
removeFile(serverPidPath);
|
||||
removeProcessRecordIfOwned(serverPidPath, processRecord);
|
||||
// bar.json is intentionally left in place on clean shutdown so
|
||||
// the Swift app self-heal poll can detect the server is gone via
|
||||
// the liveness check, not a stale discovery file.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import * as fs from 'fs';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import { getBarJsonPath, getServerPidPath } from './bar-paths';
|
||||
import { parseBarServerProcessRecord, parseLegacyServerPid } from './bar-process-control';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — injectable deps
|
||||
@@ -115,11 +116,21 @@ export async function handleBarStatus(
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = parseInt(pidRaw, 10);
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
const processRecord = parseBarServerProcessRecord(pidRaw);
|
||||
if (processRecord === null) {
|
||||
const legacyPid = parseLegacyServerPid(pidRaw);
|
||||
if (legacyPid !== null) {
|
||||
console.log(
|
||||
`[!] CCS Bar server: legacy server.pid has unverified PID ${legacyPid}; status cannot safely identify it.`
|
||||
);
|
||||
console.log(`[i] Verify manually with: ps -p ${legacyPid} -o command=`);
|
||||
console.log('[i] If it is CCS Bar, stop it manually, remove server.pid, then restart.');
|
||||
return;
|
||||
}
|
||||
console.log(`[!] CCS Bar server: server.pid is invalid ("${pidRaw}")`);
|
||||
return;
|
||||
}
|
||||
const { pid } = processRecord;
|
||||
|
||||
// 2. Check process liveness.
|
||||
const alive = isProcessAlive(pid);
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
import * as fs from 'fs';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import { getBarJsonPath, getServerPidPath } from './bar-paths';
|
||||
import {
|
||||
getProcessBirthIdentity,
|
||||
parseLegacyServerPid,
|
||||
removeBarDiscoveryIfNoProcess,
|
||||
stopBarServerProcessFile,
|
||||
stopRecordedBarServer,
|
||||
waitForProcessExit,
|
||||
} from './bar-process-control';
|
||||
import type { ClaimedBarServerStopOutcome } from './bar-process-control';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — injectable deps
|
||||
@@ -26,6 +35,9 @@ export interface StopDeps {
|
||||
* Throws if the signal cannot be delivered (e.g. ESRCH — no such process).
|
||||
*/
|
||||
killProcess: (pid: number, signal: 'SIGTERM') => void;
|
||||
getProcessBirthIdentity: (pid: number) => string | null;
|
||||
waitForProcessExit: typeof waitForProcessExit;
|
||||
stopProcessFile: (pidPath: string) => Promise<ClaimedBarServerStopOutcome>;
|
||||
/** Remove a file, ignoring errors if absent. */
|
||||
removeFile: (filePath: string) => void;
|
||||
}
|
||||
@@ -67,43 +79,92 @@ export async function handleBarStop(_args: string[], deps: Partial<StopDeps> = {
|
||||
const readPidFile = deps.readPidFile ?? defaultReadPidFile;
|
||||
const killProcess = deps.killProcess ?? defaultKillProcess;
|
||||
const removeFile = deps.removeFile ?? defaultRemoveFile;
|
||||
const readProcessBirthIdentity = deps.getProcessBirthIdentity ?? getProcessBirthIdentity;
|
||||
const waitForExit = deps.waitForProcessExit ?? waitForProcessExit;
|
||||
|
||||
const pidPath = getServerPidPath(ccsDir);
|
||||
const barJsonPath = getBarJsonPath(ccsDir);
|
||||
|
||||
// 1. Read the PID file.
|
||||
const pidRaw = readPidFile(pidPath);
|
||||
if (pidRaw === null) {
|
||||
console.log('[i] CCS Bar server is not running (no server.pid found).');
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = parseInt(pidRaw, 10);
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
console.error(`[X] server.pid contains an invalid PID: "${pidRaw}"`);
|
||||
// Clean up the corrupted file so subsequent runs start fresh.
|
||||
removeFile(pidPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Send SIGTERM.
|
||||
try {
|
||||
killProcess(pid, 'SIGTERM');
|
||||
console.log(`[OK] Sent SIGTERM to CCS Bar server (PID ${pid}).`);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ESRCH') {
|
||||
// Process no longer exists — stale PID file, clean up silently.
|
||||
console.log(`[i] Server PID ${pid} is no longer running. Cleaning up stale files.`);
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Failed to stop server (PID ${pid}): ${msg}`);
|
||||
// Still remove the pid file so the user is not blocked.
|
||||
let outcome: ClaimedBarServerStopOutcome;
|
||||
if (deps.stopProcessFile !== undefined || deps.readPidFile === undefined) {
|
||||
const stopProcessFile =
|
||||
deps.stopProcessFile ??
|
||||
((filePath: string) =>
|
||||
stopBarServerProcessFile(filePath, {
|
||||
getProcessBirthIdentity: readProcessBirthIdentity,
|
||||
killProcess,
|
||||
waitForProcessExit: waitForExit,
|
||||
}));
|
||||
outcome = await stopProcessFile(pidPath);
|
||||
if (outcome.result === 'no-record') {
|
||||
console.log('[i] CCS Bar server is not running (no server.pid found).');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Injected record reads remain available for isolated unit tests. Production
|
||||
// always uses the atomic file claim above.
|
||||
const pidRaw = readPidFile(pidPath);
|
||||
if (pidRaw === null) {
|
||||
console.log('[i] CCS Bar server is not running (no server.pid found).');
|
||||
return;
|
||||
}
|
||||
const recordedOutcome = await stopRecordedBarServer(pidRaw, {
|
||||
getProcessBirthIdentity: readProcessBirthIdentity,
|
||||
killProcess,
|
||||
waitForProcessExit: waitForExit,
|
||||
});
|
||||
outcome = {
|
||||
...recordedOutcome,
|
||||
legacyPid:
|
||||
recordedOutcome.result === 'legacy-record'
|
||||
? (parseLegacyServerPid(pidRaw) ?? undefined)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
const pid = outcome.record?.pid;
|
||||
|
||||
if (outcome.result === 'stopped' || outcome.result === 'stale') {
|
||||
if (outcome.result === 'stopped') {
|
||||
console.log(`[OK] CCS Bar server stopped (PID ${pid}).`);
|
||||
} else {
|
||||
console.log(`[i] Server PID ${pid} is no longer running. Cleaning up stale files.`);
|
||||
}
|
||||
if (deps.readPidFile !== undefined) {
|
||||
removeFile(pidPath);
|
||||
removeFile(barJsonPath);
|
||||
console.log('[i] Removed server.pid and bar.json.');
|
||||
} else {
|
||||
const removedDiscovery = removeBarDiscoveryIfNoProcess(barJsonPath, pidPath);
|
||||
if (removedDiscovery) console.log('[i] Removed server.pid and bar.json.');
|
||||
else
|
||||
console.log('[i] A replacement server record appeared; its recovery state was preserved.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Remove pid + bar.json regardless of kill result.
|
||||
removeFile(pidPath);
|
||||
removeFile(barJsonPath);
|
||||
console.log('[i] Removed server.pid and bar.json.');
|
||||
if (outcome.result === 'legacy-record') {
|
||||
const legacyPid = outcome.legacyPid;
|
||||
console.error(
|
||||
`[X] Legacy server.pid records PID ${legacyPid} without process identity; no signal was sent.`
|
||||
);
|
||||
console.error(`[i] Verify manually with: ps -p ${legacyPid} -o command=`);
|
||||
console.error(
|
||||
`[i] If it is CCS Bar, stop it manually, then remove ${outcome.recoveryPath ?? pidPath} and ${barJsonPath}.`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const reason =
|
||||
outcome.result === 'invalid-record'
|
||||
? 'server.pid does not contain a verified process record'
|
||||
: outcome.result === 'identity-mismatch'
|
||||
? `PID ${pid} belongs to a different process`
|
||||
: outcome.result === 'permission-denied'
|
||||
? `permission denied while signaling PID ${pid}`
|
||||
: outcome.result === 'timeout'
|
||||
? `PID ${pid} did not exit within 3 seconds`
|
||||
: `failed to signal PID ${pid}: ${outcome.error?.message ?? 'unknown error'}`;
|
||||
console.error(`[X] Refusing to remove CCS Bar recovery state: ${reason}.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -181,6 +181,7 @@ interface CatalogJsonModel {
|
||||
name: string;
|
||||
tier?: 'free' | 'pro' | 'ultra';
|
||||
description?: string;
|
||||
contextWindow?: number;
|
||||
deprecated?: boolean;
|
||||
deprecationReason?: string;
|
||||
broken?: boolean;
|
||||
@@ -206,6 +207,7 @@ export function handleCatalogJson(): void {
|
||||
const entry: CatalogJsonModel = { id: m.id, name: m.name };
|
||||
if (m.tier !== undefined) entry.tier = m.tier;
|
||||
if (m.description !== undefined) entry.description = m.description;
|
||||
if (m.contextWindow !== undefined) entry.contextWindow = m.contextWindow;
|
||||
if (m.deprecated !== undefined) entry.deprecated = m.deprecated;
|
||||
if (m.deprecationReason !== undefined) entry.deprecationReason = m.deprecationReason;
|
||||
if (m.broken !== undefined) entry.broken = m.broken;
|
||||
|
||||
@@ -34,7 +34,9 @@ export function displayClaudeQuotaSection(
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
const statusIcon =
|
||||
quota.errorCode === 'usage_probe_unavailable' ? warn(accountLabel) : fail(accountLabel);
|
||||
console.log(` ${statusIcon}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
|
||||
@@ -283,7 +283,7 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Welcome to CCS (Claude Code Switch)!');
|
||||
console.log('Welcome to CCS (Claude Codex Switch)!');
|
||||
console.log('This wizard will help you configure CCS for first-time use.');
|
||||
console.log('');
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getCcsDir } from '../config/config-loader-facade';
|
||||
*/
|
||||
export async function handleVersionCommand(): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header(`CCS (Claude Code Switch) v${getVersion()}`));
|
||||
console.log(header(`CCS (Claude Codex Switch) v${getVersion()}`));
|
||||
console.log('');
|
||||
|
||||
console.log(subheader('Installation:'));
|
||||
|
||||
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user