Merge pull request #1048 from kaitranntt/dev

chore: promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-19 17:35:37 -04:00
committed by GitHub
41 changed files with 1776 additions and 796 deletions
+120 -11
View File
@@ -4,35 +4,144 @@ on:
pull_request:
branches: [main, dev]
# Design notes:
# - Matrix parallelism cuts wall time from ~3-4min to ~60-90s (cache warm).
# - Concurrency group cancels superseded runs on the same ref (saves runner time on rapid pushes).
# - Build leg produces dist/ artifact; test leg downloads it instead of rebuilding (DRY).
# - fail-fast: false so every failure is visible in one run (no re-pushing to see the next failure).
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
runs-on: [self-hosted, linux, x64]
strategy:
fail-fast: false
matrix:
check:
- { name: typecheck, cmd: 'bun run typecheck' }
- { name: lint, cmd: 'bun run lint' }
- { name: format, cmd: 'bun run format:check' }
name: ${{ matrix.check.name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Clean stale artifacts
run: rm -rf node_modules ui/node_modules dist
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.9'
no-cache: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd ui && bun install --frozen-lockfile
- name: Restore bun + node_modules cache
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
ui/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Build package
- name: Ensure dependencies
run: |
[ -d node_modules ] || bun install --frozen-lockfile
[ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile)
- name: Run ${{ matrix.check.name }}
run: ${{ matrix.check.cmd }}
build:
runs-on: [self-hosted, linux, x64]
name: build
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.9'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore bun + node_modules cache
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
ui/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Ensure dependencies
run: |
[ -d node_modules ] || bun install --frozen-lockfile
[ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile)
- name: Build
run: bun run build:all
- name: Validate (typecheck + lint + format + tests)
run: bun run validate
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 1
if-no-files-found: error
test:
runs-on: [self-hosted, linux, x64]
name: test
needs: [build]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.9'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore bun + node_modules cache
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
ui/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Ensure dependencies
run: |
[ -d node_modules ] || bun install --frozen-lockfile
[ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile)
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Test
run: bun run test:all
+13
View File
@@ -5,6 +5,18 @@ set -euo pipefail
# Feature branches use a faster gate and let GitHub CI run the full suite.
# Override in emergencies only: CCS_SKIP_PREPUSH_GATE=1 git push --no-verify
# Skip gate entirely for delete-only pushes. Git passes refs on stdin as
# "<local-ref> <local-sha> <remote-ref> <remote-sha>"; deletes have local-sha = 40 zeros.
# Running a full test suite just to delete a merged branch is pure waste.
STDIN_CONTENT="$(cat || true)"
if [[ -n "$STDIN_CONTENT" ]]; then
NON_DELETE_COUNT="$(printf '%s\n' "$STDIN_CONTENT" | awk 'NF >= 2 && $2 !~ /^0{40}$/' | wc -l | tr -d ' ')"
if [[ "$NON_DELETE_COUNT" == "0" ]]; then
echo "[i] Delete-only push, skipping pre-push gate."
exit 0
fi
fi
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
BASE_BRANCH="${CCS_PR_BASE:-}"
@@ -29,6 +41,7 @@ echo " base: $BASE_BRANCH"
bun run typecheck
bun run lint:fix
bun run format:check
bun run build:all
git fetch origin "$BASE_BRANCH" --quiet || true
DIFF_RANGE="HEAD"
+45 -14
View File
@@ -15,6 +15,32 @@ AI-facing guidance for agent tooling when working with this repository.
Tests set `process.env.CCS_HOME` to a temp directory. Code using `os.homedir()` directly will modify the user's real files.
## CI-First Protocol (MANDATORY)
**A task is NOT complete until CI is green. After every `git push`, the AI agent MUST block on CI until it passes.**
### Required Sequence
1. `git push`
2. **Immediately** run `gh pr checks --watch` (or `gh run watch`) and block until all checks complete.
3. If **green** → task may proceed to next step / be declared done.
4. If **red**:
- Pull failing logs: `gh run view --log-failed` (or `gh pr checks <n>` to identify the failing job, then `gh run view <run-id> --log-failed`).
- Fix the root cause locally. Do NOT retry blindly.
- Commit and push again. Re-watch CI.
5. Applies to initial `gh pr create` AND every subsequent push on an open PR.
### Fallback (when `--watch` is unavailable or flaky)
Poll with short sleep until no check is `pending` / `in_progress`:
```bash
until [ "$(gh pr checks <n> --json state -q '[.[] | select(.state == "IN_PROGRESS" or .state == "PENDING" or .state == "QUEUED")] | length')" = "0" ]; do
sleep 10
done
gh pr checks <n>
```
### Absolute rule
AI MUST NOT declare a task done, close a session, or move to the next task while CI is red or still running. Leaving a PR red and moving on is the primary failure mode this protocol prevents.
## Core Function
Multi-provider profile and runtime manager for Claude Code, Factory Droid,
@@ -201,9 +227,11 @@ bun run validate # Step 3: Final check (must pass)
| Project | Command | Runs |
|---------|---------|------|
| Main | `bun run validate` | typecheck + lint:fix + format:check + maintainability:check + test:all |
| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all |
| UI | `bun run validate` | typecheck + lint:fix + format:check |
**Note:** `maintainability:check` is a SEPARATE gate — not part of `validate`. Run it explicitly via `bun run maintainability:check[:strict|:warn]` when touching debt-sensitive code or before merging to protected branches.
### ESLint Rules (ALL errors)
| Rule | Level | Notes |
@@ -238,7 +266,7 @@ bun run validate # Step 3: Final check (must pass)
- Baseline file: `docs/metrics/maintainability-baseline.json`
- Metric collector/check script: `scripts/maintainability-baseline.js`
- Branch-aware gate wrapper: `scripts/maintainability-check.js`
- Enforcement path: `bun run maintainability:check` (included in `bun run validate`)
- Enforcement path: `bun run maintainability:check` (run separately — NOT part of `bun run validate`; invoked by `validate:ci-parity` on protected branches)
- Gate modes:
- `strict`: protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) and equivalent CI refs
- `warn`: pull request CI and non-protected local branches (non-blocking for parallel PR workflow)
@@ -499,27 +527,30 @@ rm -rf ~/.ccs # Clean environment
**IMPORTANT:** Use `bun run dev` at CCS root for always up-to-date code. Do NOT use `ccs config` during development as it uses the globally installed version.
## Pre-Commit Checklist
## Two-Tier Pre-Push Checklist
**Quality (BLOCKERS):**
- [ ] `bun run format` — formatting fixed
- [ ] `bun run validate` — all checks pass
- [ ] `bun run validate:ci-parity` — CI parity passed (required before protected-branch pushes; recommended before PRs)
- [ ] `cd ui && bun run format && bun run validate` — if UI changed
- [ ] If touching debt-sensitive code, run `bun run maintainability:check:strict` before opening/merging PR
Optimized for iterative push-then-review workflow. Do NOT run the full gate on every push — CI is the safety net. Run the full gate once before asking for review / merge.
### Tier 1 — Iterative push (feature branch)
Husky `pre-push` auto-runs: `typecheck + lint:fix + format:check + build:all` plus targeted tests based on changed files. AI does **nothing extra** at push time.
**After push (MANDATORY):** follow the [CI-First Protocol](#ci-first-protocol-mandatory) — watch CI until green. Do not move on while CI is red.
### Tier 2 — Before requesting review / merge
Run ONCE, not per push:
- [ ] `bun run validate:ci-parity` — full build + validate matches CI
- [ ] `gh pr checks <n>` — all checks green
- [ ] If touching debt-sensitive code: `bun run maintainability:check:strict`
- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json`
- [ ] If UI changed: `cd ui && bun run format && bun run validate`
**Code:**
### Code / Docs / Standards (verify before merge)
- [ ] Conventional commit format (`feat:`, `fix:`, etc.)
- [ ] Respective `--help` updated (see Help Location Reference) — if CLI changed
- [ ] Tests added/updated — if behavior changed
- [ ] README.md updated — if user-facing
**Documentation:**
- [ ] CCS docs updated (owner: `~/CloudPersonal/ccs/docs/`) — if CLI/config changed
- [ ] Local `docs/` updated — if architecture changed
**Standards:**
- [ ] CLI output ASCII only (NO emojis in terminal output), NO_COLOR respected
- [ ] YAGNI/KISS/DRY alignment verified
- [ ] No manual version bump or tags
+10
View File
@@ -129,6 +129,10 @@ chrome.exe --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.ccs\\br
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
@@ -144,6 +148,9 @@ The configured Chrome user-data directory does not exist yet.
2. Start Chrome in attach mode with `--remote-debugging-port`
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.
@@ -152,6 +159,9 @@ CCS could not find usable DevTools attach metadata for the configured user-data
2. Make sure it is using the same `user_data_dir` configured in CCS
3. 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.
+2 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-04-14
Last Updated: 2026-04-18
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.72.1",
"version": "7.72.1-dev.7",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
@@ -1,127 +0,0 @@
---
phase: 1
title: "CLI Routing & Namespacing"
status: complete
effort: "6h"
---
# Phase 1: CLI Routing & Namespacing
## Context Links
- `plan.md`
- `src/ccs.ts`
- `src/auth/profile-detector.ts`
- `src/cursor/constants.ts`
- `src/commands/root-command-router.ts`
- `src/commands/command-catalog.ts`
- `src/commands/help-command.ts`
- `src/commands/cursor-command.ts`
- `src/commands/cursor-command-display.ts`
- `src/types/profile.ts`
- `src/config/reserved-names.ts`
## Overview
- Priority: P1
- Owner scope: CLI entry, help, profile detection, command naming
- Goal: make `cursor` provider-first and move the deprecated bridge under `legacy cursor`
## Key Insights
- The current collision is structural, not cosmetic. `ccs cursor` means "legacy bridge" in `src/ccs.ts` and `src/auth/profile-detector.ts`, but `cursor` is also listed as a built-in CLIProxy provider.
- `shouldUseCursorCliproxyShortcut()` is only a heuristic escape hatch. It does not fix bare `ccs cursor`, quoted prompts, or help routing.
- Help is currently inconsistent: provider help exists generically, but `cursor` is excluded and routed to bridge help instead.
## Requirements
- Reserve `cursor` for CLIProxy runtime and CLIProxy admin flags.
- Introduce explicit legacy syntax: `ccs legacy cursor ...`.
- Keep a release-N alias for old legacy admin subcommands only.
- Rename internal bridge-only profile typing from ambiguous `cursor` to explicit `legacy-cursor`.
- Keep file ownership isolated to CLI/router/help files in this phase.
## Data Flow
- Provider path:
`argv -> root command resolution -> provider shortcut/help path -> ProfileDetector(type=cliproxy, provider=cursor) -> CLIProxy runtime`
- Legacy path:
`argv -> legacy root command -> legacy cursor subrouter -> ProfileDetector(type=legacy-cursor) or direct handler -> local bridge runtime`
- Deprecated alias path, release N only:
`argv=ccs cursor auth|status|... -> alias shim -> warning -> dispatch to legacy cursor handler`
## Architecture
- Add a new root command namespace: `ccs legacy`.
- Add nested routing under `legacy` with `cursor` as the first migrated leaf. Do not overload `cursor` itself any longer.
- Remove provider exceptions for `cursor` from the generic provider help/routing logic. `ccs cursor --help` should now use provider shortcut help.
- Convert bridge-only type checks from `profileInfo.type === 'cursor'` to `profileInfo.type === 'legacy-cursor'`.
- Keep `ccs cursor help` only as a release-N compatibility shim that prints:
- `Use "ccs cursor --help" for CLIProxy Cursor`
- `Use "ccs legacy cursor help" for the deprecated bridge`
## Related Code Files
- Modify:
- `src/ccs.ts`
- `src/auth/profile-detector.ts`
- `src/cursor/constants.ts`
- `src/commands/root-command-router.ts`
- `src/commands/command-catalog.ts`
- `src/commands/help-command.ts`
- `src/commands/cursor-command.ts`
- `src/commands/cursor-command-display.ts`
- `src/types/profile.ts`
- `src/config/reserved-names.ts`
- `src/shared/claude-extension-setup.ts`
- `src/targets/target-runtime-compatibility.ts`
- Create:
- `src/commands/legacy-command.ts` or `src/commands/legacy/index.ts`
- `src/commands/legacy/cursor-command.ts` if the team wants physical separation immediately
## Implementation Steps
1. Add the `legacy` root command route and its help surface.
2. Flip `src/ccs.ts` so `cursor` goes through normal CLIProxy provider routing; remove the special-case that gives the bridge ownership of the name.
3. Replace the `shouldUseCursorCliproxyShortcut()` hack with provider-first dispatch plus a compatibility alias table for the old legacy subcommands.
4. Update `ProfileDetector` priority order so `cursor` resolves as `cliproxy`, while `legacy cursor` resolves as `legacy-cursor`.
5. Rename bridge-only help text, summaries, and status text to say "legacy Cursor bridge" explicitly.
6. Audit all `profileType === 'cursor'` checks and convert only the bridge-specific ones to `legacy-cursor`.
## Todo List
- [x] Add `legacy cursor` routing
- [x] Make `ccs cursor` provider-first for bare, prompt, and `--help` usage
- [x] Add deprecated alias forwarding for old admin subcommands
- [x] Rename internal bridge profile path to `legacy-cursor`
- [x] Update provider help, completion, and command catalog summaries
## Success Criteria
- `ccs cursor "task"` resolves to CLIProxy Cursor.
- `ccs legacy cursor "task"` resolves to the old bridge.
- `ccs cursor --help` shows provider shortcut help.
- `ccs cursor auth` still works in release N, but prints an exact replacement warning.
- No CLI path depends on `shouldUseCursorCliproxyShortcut()` to disambiguate runtime meaning.
## Risk Assessment
- High likelihood / high impact: users with scripts calling `ccs cursor "task"` will hit the provider path immediately.
Mitigation: call this out in release notes, keep admin aliases, add explicit warning when legacy files/config are detected and the user invokes `ccs cursor` with no flags.
- Medium likelihood / medium impact: bridge-only type renames may break target compatibility checks or extension setup.
Mitigation: grep audit every `profileType === 'cursor'` branch before tests.
## Rollback Plan
- Re-enable the old `cursor` special-case in `src/ccs.ts` and `ProfileDetector`.
- Keep the new `legacy` namespace in place even if dormant; it is additive and safe to leave.
- Do not roll back migrated files in this phase; routing rollback alone is enough.
## Security Considerations
- No auth material moves in this phase.
- Preserve existing `CCS_HOME`-aware path resolution. Do not introduce `os.homedir()` shortcuts while adding the new namespace.
## Next Steps
- Phase 2 depends on the new command contract from this phase.
@@ -1,140 +0,0 @@
---
phase: 2
title: "Storage & API Boundaries"
status: partial
effort: "6h"
---
# Phase 2: Storage & API Boundaries
## Context Links
- `plan.md`
- `src/config/unified-config-types.ts`
- `src/config/unified-config-loader.ts`
- `src/cursor/cursor-auth.ts`
- `src/cursor/cursor-daemon-pid.ts`
- `src/cliproxy/config/path-resolver.ts`
- `src/cliproxy/config/env-builder.ts`
- `src/web-server/routes/index.ts`
- `src/web-server/routes/cursor-routes.ts`
- `src/web-server/routes/cursor-settings-routes.ts`
- `src/web-server/routes/cliproxy-stats-routes.ts`
- `src/api/services/profile-lifecycle-service.ts`
## Overview
- Priority: P1
- Owner scope: config schema, path resolution, backend APIs, migration readers
- Goal: make legacy bridge storage explicit and guarantee CLIProxy Cursor never writes the legacy raw settings file
## Key Insights
- Top-level `config.cursor` is bridge-only configuration today and must move.
- The legacy bridge owns `~/.ccs/cursor.settings.json`, `~/.ccs/cursor/credentials.json`, and `~/.ccs/cursor/daemon.pid`.
- CLIProxy provider settings currently resolve through generic provider settings helpers and can still collide with the legacy file for provider `cursor`.
- `~/.ccs/cursor.settings.json` is historically documented as legacy-owned, so it is unsafe to auto-import it into provider storage by default.
## Requirements
- Canonical legacy config key: `legacy.cursor`
- Canonical legacy files:
- `~/.ccs/legacy/cursor.settings.json`
- `~/.ccs/legacy/cursor/credentials.json`
- `~/.ccs/legacy/cursor/daemon.pid`
- Canonical provider file for CLIProxy Cursor only:
- `~/.ccs/cliproxy/cursor.settings.json`
- Canonical legacy API namespace:
- `/api/legacy/cursor/*`
- Compatibility reads:
- read old `config.cursor`
- read old `~/.ccs/cursor.settings.json`
- read old `~/.ccs/cursor/*`
- Compatibility writes:
- write only the new `legacy.*` and `cliproxy/*` paths
## Data Flow
- Legacy config:
`load config -> prefer legacy.cursor -> fallback config.cursor -> normalize -> write legacy.cursor only`
- Legacy raw settings:
`load /api/legacy/cursor/settings/raw -> prefer ~/.ccs/legacy/cursor.settings.json -> fallback ~/.ccs/cursor.settings.json -> write new legacy path`
- Provider settings:
`CLIProxy env builder/stats updater -> read ~/.ccs/cliproxy/cursor.settings.json -> if absent use defaults -> never read/write ~/.ccs/cursor.settings.json`
## Architecture
- Add a `legacy` section to unified config types and loader. Keep old `cursor` as read-only migration input during the compatibility window.
- Move legacy bridge filesystem helpers under a `legacy/cursor` path prefix.
- Split API routing:
- new canonical mount: `/api/legacy/cursor`
- release-N alias: `/api/cursor` -> same handlers + deprecation header
- Special-case CLIProxy provider settings for `cursor` only in the provider path resolver. Do not expand this migration to every provider in this issue.
- Treat existing `~/.ccs/cursor.settings.json` as legacy-owned. Do not auto-copy it into provider storage unless a future explicit provider migration is added.
## Related Code Files
- Modify:
- `src/config/unified-config-types.ts`
- `src/config/unified-config-loader.ts`
- `src/cursor/cursor-auth.ts`
- `src/cursor/cursor-daemon-pid.ts`
- `src/cliproxy/config/path-resolver.ts`
- `src/cliproxy/config/env-builder.ts`
- `src/web-server/routes/index.ts`
- `src/web-server/routes/cursor-routes.ts`
- `src/web-server/routes/cursor-settings-routes.ts`
- `src/web-server/routes/cliproxy-stats-routes.ts`
- `src/api/services/profile-lifecycle-service.ts`
- Create:
- `src/web-server/routes/legacy-cursor-routes.ts`
- `src/web-server/routes/legacy-cursor-settings-routes.ts`
- `src/config/migrations/cursor-legacy-migration.ts` if migration logic should stay out of the loader
## Implementation Steps
1. Extend config types and loader to support `legacy.cursor`, with `legacy.cursor` taking precedence over old `cursor`.
2. Update legacy bridge credential and pid helpers to use `~/.ccs/legacy/cursor/`.
3. Update the raw settings route to use `~/.ccs/legacy/cursor.settings.json` as canonical and old root path as read fallback only.
4. Move legacy API mounts to `/api/legacy/cursor/*` and keep `/api/cursor/*` as a warned alias for release N.
5. Change CLIProxy Cursor provider settings resolution to `~/.ccs/cliproxy/cursor.settings.json`.
6. Update orphan detection and cleanup logic so old `cursor.settings.json` is treated as a migration target, not a permanent provider-owned file.
## Todo List
- [ ] Add `legacy.cursor` config schema and loader precedence
- [ ] Move bridge credentials/pid/raw settings under `~/.ccs/legacy/`
- [x] Add canonical `/api/legacy/cursor/*` routes
- [x] Keep release-N `/api/cursor/*` alias
- [x] Isolate CLIProxy Cursor settings away from `~/.ccs/cursor.settings.json`
- [ ] Update cleanup/orphan handling
## Success Criteria
- Saving legacy bridge settings writes only to `legacy.cursor` and `~/.ccs/legacy/*`.
- CLIProxy Cursor model/env updates write only to `~/.ccs/cliproxy/cursor.settings.json`.
- Existing legacy users can still read old config/files during the compatibility window.
- No backend route that serves the provider path references `~/.ccs/cursor.settings.json`.
## Risk Assessment
- High likelihood / high impact: old `~/.ccs/cursor.settings.json` contents are ambiguous between bridge and provider expectations.
Mitigation: treat the file as legacy-owned and do not auto-import it into provider storage.
- Medium likelihood / medium impact: route aliasing may mask which API is canonical.
Mitigation: add explicit response headers or payload flags marking `/api/cursor/*` as deprecated.
## Rollback Plan
- Keep read fallback from old paths even if the canonical write path changes back.
- If the new legacy API namespace causes regressions, remount `/api/cursor/*` as canonical temporarily and keep the new namespace dormant.
- Do not delete old files during release N; cleanup stays opt-in until release N+2.
## Security Considerations
- Preserve `0600` for migrated credentials and `0700` for directories.
- Use atomic temp-file writes exactly as current routes do.
- Never copy provider tokens into the legacy namespace or legacy tokens into provider storage automatically.
## Next Steps
- Phase 3 depends on the canonical API and path names from this phase.
@@ -1,121 +0,0 @@
---
phase: 3
title: "Dashboard & Deprecation UX"
status: partial
effort: "4h"
---
# Phase 3: Dashboard & Deprecation UX
## Context Links
- `plan.md`
- `ui/src/App.tsx`
- `ui/src/components/layout/app-sidebar.tsx`
- `ui/src/pages/cursor.tsx`
- `ui/src/hooks/use-cursor.ts`
- `ui/src/lib/i18n.ts`
- `src/web-server/routes/index.ts`
- `src/commands/cursor-command-display.ts`
## Overview
- Priority: P1
- Owner scope: dashboard route ownership, labels, user-facing deprecation messaging
- Goal: align dashboard semantics with CLI semantics so `/cursor` means provider and legacy UI is clearly marked and isolated
## Key Insights
- The current dashboard already admits the bridge is deprecated, but the route `/cursor` still belongs to it.
- The page includes direct navigation to CLIProxy Cursor, which means the UX already wants a split; the route layer just has not caught up.
- Keeping `/cursor` for legacy while CLI uses `cursor` for provider would create the same ambiguity in a different surface.
## Requirements
- `/cursor` must become the provider-owned dashboard surface.
- The legacy bridge page must move to `/legacy/cursor`.
- Legacy bridge API hooks must move to `/api/legacy/cursor/*`.
- The deprecated UX must contain exact replacements, not generic warnings.
- Sidebar grouping must reflect support level:
- provider view under provider/cliproxy navigation
- legacy bridge under deprecated navigation
## Data Flow
- Provider dashboard:
`browser /cursor -> provider view or redirect wrapper -> /cliproxy?provider=cursor -> existing CLIProxy provider APIs`
- Legacy dashboard:
`browser /legacy/cursor -> legacy bridge page -> useLegacyCursor hook -> /api/legacy/cursor/*`
- Compatibility API path, release N only:
`old UI/tests -> /api/cursor/* -> alias handler -> same legacy payload + deprecation signal`
## Architecture
- Keep provider UI DRY by making `/cursor` a thin redirect or preselected wrapper around the existing CLIProxy provider page instead of building a second Cursor-provider page.
- Move the current `ui/src/pages/cursor.tsx` implementation to a new `legacy-cursor` page and rename its hook to `useLegacyCursor`.
- Change nav labels from generic "Cursor IDE" to explicit "Cursor Bridge (Legacy)" in the deprecated section.
- Update CLI and dashboard warnings to show both paths side-by-side:
- `ccs cursor --auth` / `/cursor`
- `ccs legacy cursor auth` / `/legacy/cursor`
## Related Code Files
- Modify:
- `ui/src/App.tsx`
- `ui/src/components/layout/app-sidebar.tsx`
- `ui/src/lib/i18n.ts`
- `src/commands/cursor-command-display.ts`
- Move or rename:
- `ui/src/pages/cursor.tsx` -> `ui/src/pages/legacy-cursor.tsx`
- `ui/src/hooks/use-cursor.ts` -> `ui/src/hooks/use-legacy-cursor.ts`
- Create:
- `ui/src/pages/cursor-provider-redirect.tsx` if a wrapper is preferred over direct router config
## Implementation Steps
1. Move the legacy page and hook to `legacy-*` names and update all imports.
2. Reassign `/cursor` to the provider path and add `/legacy/cursor` for the bridge page.
3. Update sidebar grouping and labels so the provider path is no longer listed under Deprecated.
4. Replace vague deprecated copy with concrete migration copy:
- old command
- new command
- old route
- new route
5. Keep the legacy page banner persistent until release N+2, not dismissible per session.
## Todo List
- [ ] Move legacy page/hook module names to `legacy-*`
- [x] Reassign `/cursor` and add `/legacy/cursor`
- [x] Update deprecated nav group and labels
- [x] Rewrite key banners, button copy, and path labels with exact replacements
- [x] Keep provider and legacy links visible from both surfaces during release N
## Success Criteria
- Opening `/cursor` lands on the CLIProxy Cursor provider surface.
- Opening `/legacy/cursor` lands on the bridge page with a persistent deprecation banner.
- No dashboard component serving the provider route uses the legacy API hook.
- Every warning banner shows the exact before/after command and route.
## Risk Assessment
- Medium likelihood / medium impact: users with bookmarked `/cursor` expect the legacy page.
Mitigation: provider page shows a top-level "Looking for the old bridge?" callout linking to `/legacy/cursor`.
- Low likelihood / medium impact: UI rename churn breaks lazy imports or tests.
Mitigation: do route and hook rename in one phase and leave compatibility API alias in place until tests pass.
## Rollback Plan
- Point `/cursor` back to the legacy page if the provider redirect breaks.
- Keep `/legacy/cursor` additive; it does not block rollback.
- Do not remove the deprecation banner on rollback; it still communicates future intent.
## Security Considerations
- No auth secrets should be exposed in UI copy or route params.
- Keep manual auth dialogs scoped to the legacy page only. Provider auth remains in CLIProxy flows.
## Next Steps
- Phase 4 owns test rewrites, docs updates, and release gating for these UI changes.
@@ -1,148 +0,0 @@
---
phase: 4
title: "Tests Docs & Rollout"
status: complete
effort: "4h"
---
# Phase 4: Tests Docs & Rollout
## Context Links
- `plan.md`
- `docs/cursor-integration.md`
- `README.md`
- `docs/system-architecture/provider-flows.md`
- `docs/system-architecture/index.md`
- `tests/unit/cursor/cursor-shortcut-routing.test.ts`
- `tests/unit/web-server/cursor-settings-routes.test.ts`
- `tests/unit/web-server/cursor-routes.test.ts`
- `ui/tests/unit/hooks/use-cursor.test.tsx`
- `ui/tests/unit/ui/pages/cursor-page.test.tsx`
## Overview
- Priority: P1
- Owner scope: compatibility rollout, validation, docs/help updates, release notes
- Goal: ship the namespace split without surprising existing bridge users or leaving docs/help inconsistent
## Key Insights
- This change has one intentional breaking behavior: positional `ccs cursor` stops being the legacy bridge.
- Everything else can use a compatibility window: admin subcommands, API aliases, old config reads, old file-path reads.
- Tests must lock both meanings so the ambiguity does not regress later.
## Requirements
- Document exact before/after commands and routes.
- Add a concrete migration path for three user groups:
- legacy bridge users
- CLIProxy Cursor users
- dashboard bookmark users
- Define removal windows for aliases and old path fallbacks.
- Run repo quality gates after implementation:
- root: `bun run format && bun run lint:fix && bun run validate && bun run validate:ci-parity`
- UI: `cd ui && bun run format && bun run lint:fix && bun run validate`
## Test Matrix
- Unit:
- provider-first cursor routing
- legacy alias forwarding
- `legacy.cursor` loader precedence
- path resolvers for legacy vs provider files
- deprecation help text snapshots
- Integration:
- `ccs cursor "task"` -> provider
- `ccs legacy cursor "task"` -> bridge
- `/api/legacy/cursor/*` canonical behavior
- `/api/cursor/*` alias behavior during release N
- UI:
- `/cursor` route ownership
- `/legacy/cursor` banner and actions
- hook path changes and raw settings save targets
- Manual release validation:
- migrate old config/files in a temp `CCS_HOME`
- verify provider path never writes `~/.ccs/cursor.settings.json`
## User Migration Plan
1. Legacy bridge users:
- replace `ccs cursor ...` with `ccs legacy cursor ...`
- run `ccs legacy cursor status`
- update scripts and dashboard bookmarks to `/legacy/cursor`
2. CLIProxy Cursor users:
- keep using `ccs cursor ...`
- if provider-specific settings are needed, re-save them under the new provider-owned path instead of relying on `~/.ccs/cursor.settings.json`
3. Mixed/unclear state:
- `ccs migrate` should move `config.cursor` and legacy files into the new legacy namespace
- do not auto-copy the old raw settings file into provider storage
## Deprecation UX Plan
- CLI warning text, release N:
- `ccs cursor auth` is deprecated. Use `ccs legacy cursor auth` for the old bridge or `ccs cursor --auth` for CLIProxy Cursor.
- Dashboard banner:
- visible on `/legacy/cursor`
- provider route links back to legacy route with "Looking for the old bridge?"
- Docs banner:
- top callout in `docs/cursor-integration.md` pointing users to CLIProxy Cursor as the supported path
## Related Code Files
- Modify tests:
- `tests/unit/cursor/cursor-shortcut-routing.test.ts`
- `tests/unit/web-server/cursor-settings-routes.test.ts`
- `tests/unit/web-server/cursor-routes.test.ts`
- `ui/tests/unit/hooks/use-cursor.test.tsx`
- `ui/tests/unit/ui/pages/cursor-page.test.tsx`
- Modify docs:
- `docs/cursor-integration.md`
- `README.md` if root command examples mention Cursor
- `docs/system-architecture/provider-flows.md`
- `docs/system-architecture/index.md`
- CLI help snapshots or generated references if present
## Implementation Steps
1. Rewrite tests around the new command contract and route ownership before removing aliases in later releases.
2. Update docs/help text in the same PR as code changes so the new syntax ships atomically.
3. Add migration notes to changelog/release notes with a bold callout that `ccs cursor "task"` now means CLIProxy Cursor.
4. Keep a removal checklist for release N+1 and N+2 in the plan or roadmap so the compatibility window does not become permanent.
## Todo List
- [x] Update unit, integration, and selected UI tests
- [x] Update docs and CLI help text
- [x] Add migration note and deprecation wording
- [x] Run root and UI quality gates
- [x] Record alias-removal follow-up for N+1 and old-path-removal follow-up for N+2
## Success Criteria
- Test suite covers both provider and legacy cursor paths explicitly.
- Docs and help text match the shipped command contract exactly.
- Release notes include the migration table and deprecation window.
- Quality gates pass in both root and `ui/`.
## Risk Assessment
- High likelihood / medium impact: docs or tests lag behind the command flip and users keep invoking the wrong surface.
Mitigation: block merge until help text, docs, and tests all match the new contract.
- Medium likelihood / medium impact: compatibility shims never get removed.
Mitigation: create follow-up issues or roadmap entries for N+1 and N+2 removal work before merge.
## Rollback Plan
- If rollout messaging is incomplete, revert the command flip before removing aliases.
- If only docs/help are wrong, fix docs first and keep aliases until corrected.
- Old-path readers stay in place through N+1, so rollback does not strand migrated users.
## Security Considerations
- Use temp `CCS_HOME` in tests and manual verification. Never touch the real `~/.ccs`.
- Sanitize any migration logs or warnings so they mention paths, not token contents.
## Next Steps
- Implementation is complete when all four phases land together; do not ship phase 1 without phases 2-4.
@@ -1,93 +0,0 @@
---
title: "Separate legacy Cursor bridge from CLIProxy Cursor provider"
description: "Reserve `cursor` for the CLIProxy provider, move the reverse-engineered bridge under `legacy`, and split storage/UI with a staged migration."
status: in_progress
priority: P1
effort: 2d
branch: kai/feat/1016-missing-provider-integration
tags: [cursor, cliproxy, migration, dashboard, deprecation]
created: 2026-04-15
blockedBy: []
blocks: []
---
# Separate legacy Cursor bridge from CLIProxy Cursor provider
## Goal
Make `cursor` mean one thing everywhere: the CLIProxy-backed provider. Move the deprecated local bridge to `legacy`, stop provider writes to `~/.ccs/cursor.settings.json`, and ship a low-risk migration window.
## Current Collision Points
- `src/ccs.ts` hardcodes `cursor` as a legacy command/profile, then reclaims only `--auth|--logout|--config|--accounts` for CLIProxy.
- `src/auth/profile-detector.ts` resolves `cursor` to the legacy runtime before CLIProxy provider detection.
- `src/commands/command-catalog.ts` and `src/commands/help-command.ts` advertise `cursor` as both bridge and provider.
- `src/config/unified-config-types.ts` + `src/config/unified-config-loader.ts` store bridge config under top-level `cursor`.
- `src/cliproxy/config/path-resolver.ts`, `src/cliproxy/config/env-builder.ts`, and `src/web-server/routes/cliproxy-stats-routes.ts` still use provider settings paths that collide with the legacy raw file.
- `src/web-server/routes/cursor-*.ts`, `ui/src/pages/cursor.tsx`, `ui/src/hooks/use-cursor.ts`, `ui/src/App.tsx`, and `ui/src/components/layout/app-sidebar.tsx` dedicate `/cursor` and `/api/cursor/*` to the legacy bridge.
- `docs/cursor-integration.md` documents `ccs cursor` as the bridge even though CLIProxy already exposes a `cursor` provider shortcut.
## Command Contract
Before:
```text
ccs cursor -> legacy bridge runtime
ccs cursor "task" -> legacy bridge runtime
ccs cursor auth|status|... -> legacy bridge admin
ccs cursor --auth|--config -> CLIProxy Cursor shortcut
```
After release N:
```text
ccs cursor -> CLIProxy Cursor runtime
ccs cursor "task" -> CLIProxy Cursor runtime
ccs cursor --auth|--config -> CLIProxy Cursor admin
ccs legacy cursor -> legacy bridge runtime
ccs legacy cursor "task" -> legacy bridge runtime
ccs legacy cursor auth|... -> legacy bridge admin
```
Compatibility window, release N only:
- `ccs cursor auth|status|probe|models|start|stop|enable|disable|help` forwards to `ccs legacy cursor ...` with a deprecation warning.
- Bare and positional `ccs cursor` switch immediately to the provider path; no silent legacy fallback.
## Phase Plan
| Phase | Scope | Output |
| --- | --- | --- |
| 1 | [CLI Routing & Namespacing](./phase-01-cli-routing-namespacing.md) | Provider-first `cursor`, explicit `legacy cursor`, updated help/catalog/type names |
| 2 | [Storage & API Boundaries](./phase-02-storage-api-boundaries.md) | `legacy.cursor` config, split file paths, `/api/legacy/cursor/*`, provider path isolation |
| 3 | [Dashboard & Deprecation UX](./phase-03-dashboard-deprecation-ux.md) | `/cursor` -> provider view, `/legacy/cursor` -> bridge view, clear migration UX |
| 4 | [Tests Docs & Rollout](./phase-04-tests-docs-rollout.md) | Compatibility plan, migration steps, test matrix, docs updates, rollback gates |
## Rollout Sequence
1. Release N: add new legacy namespace, flip `ccs cursor` to provider, keep old admin subcommands and `/api/cursor/*` as warned aliases, and split provider settings away from `~/.ccs/cursor.settings.json`.
2. Release N+1: move the remaining legacy backend/config namespaces fully under `legacy.cursor`, keep old file-path fallback and `/api/cursor/*` alias for one more release.
3. Release N+2: remove old `config.cursor` and root-level `~/.ccs/cursor*` fallback reads, delete stale alias docs/help, and let cleanup/migrate remove leftovers.
## Current Implementation Status
- Completed in this branch:
- `ccs cursor` is provider-first for runtime and `--help`
- `ccs legacy cursor` works as the explicit legacy bridge namespace
- old legacy admin subcommands under `ccs cursor ...` forward with deprecation warnings
- CLIProxy Cursor settings no longer collide with `~/.ccs/cursor.settings.json`
- `/cursor` redirects to the provider surface while `/legacy/cursor` serves the deprecated bridge page
- `/api/legacy/cursor/*` is mounted and the legacy page uses that namespace
- docs, completion, and core regression tests were updated
- Intentionally deferred follow-up:
- move top-level `config.cursor` to `legacy.cursor`
- move legacy credentials/pid/raw settings fully under `~/.ccs/legacy/cursor/*`
- rename `use-cursor` and `CursorPage` modules to explicit `legacy-*`
## Success Criteria
- `cursor` is provider-owned in CLI help, routing, dashboard nav, and docs.
- Legacy bridge is reachable only through `legacy cursor` and `legacy.cursor` storage.
- CLIProxy Cursor never reads or writes `~/.ccs/cursor.settings.json`.
- Existing legacy users have an explicit migration path, warning UX, and rollback-safe compatibility window.
## Docs Impact
Major. CLI reference, Cursor docs, dashboard tour, provider docs, and migration notes all change in the same release.
+11
View File
@@ -15,6 +15,17 @@ if [[ ! -f AGENTS.md ]]; then
exit 1
fi
TRACKED_PLANS="$(git ls-files -- plans)"
if [[ -n "$TRACKED_PLANS" ]]; then
echo "[X] Tracked files found under plans/."
echo " plans/ is workspace-only and must stay ignored."
while IFS= read -r tracked_path; do
echo " $tracked_path"
done <<< "$TRACKED_PLANS"
echo " Remove them from the index with: git rm -r --cached plans"
exit 1
fi
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [[ -z "$CURRENT_BRANCH" || "$CURRENT_BRANCH" == "HEAD" ]]; then
echo "[i] Detached HEAD detected. Skipping pre-push CI parity gate."
+6 -2
View File
@@ -7,7 +7,11 @@
import { spawn, ChildProcess } from 'child_process';
import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../../utils/ui';
import { getClaudeCliInfo } from '../../utils/claude-detector';
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
import {
escapeShellArg,
getWindowsEscapedCommandShell,
stripClaudeCodeEnv,
} from '../../utils/shell-executor';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { ProfileMetadata } from '../../types';
import {
@@ -249,7 +253,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
shell: getWindowsEscapedCommandShell(),
env: childEnv,
});
} else {
+2 -2
View File
@@ -1067,7 +1067,7 @@ async function main(): Promise<void> {
: undefined;
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
if (browserAttachRuntime?.warning) {
console.error(warn(browserAttachRuntime.warning));
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
}
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
@@ -1477,7 +1477,7 @@ async function main(): Promise<void> {
: undefined;
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
if (browserAttachRuntime?.warning) {
console.error(warn(browserAttachRuntime.warning));
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
}
if (resolvedTarget === 'claude') {
+20 -6
View File
@@ -8,6 +8,7 @@ import * as path from 'path';
import type { CLIProxyProvider, ProviderConfig } from '../types';
import { getProviderDisplayName } from '../provider-capabilities';
import { getModelMappingFromConfig } from '../base-config-loader';
import { AI_PROVIDER_FAMILY_IDS } from '../ai-providers/types';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager';
import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer';
@@ -47,6 +48,11 @@ interface RegenerateConfigOptions {
authDir?: string;
}
interface PreservedYamlSection {
key: string;
body: string;
}
interface OAuthModelAliasEntry {
name: string;
alias: string;
@@ -754,8 +760,8 @@ export function regenerateConfig(
// Preserve user settings from existing config
let effectivePort = port;
let userApiKeys: string[] = [];
let claudeApiKeySection = '';
let existingAliases = '';
const preservedSections: PreservedYamlSection[] = [];
if (fs.existsSync(configPath)) {
try {
@@ -770,8 +776,16 @@ export function regenerateConfig(
// Preserve user-added API keys (fix for issue #200)
userApiKeys = parseUserApiKeys(content);
// Preserve claude-api-key section (managed via dashboard/API)
claudeApiKeySection = extractYamlSection(content, 'claude-api-key');
// Preserve AI provider sections managed outside the generated defaults.
for (const familyId of AI_PROVIDER_FAMILY_IDS) {
const sectionBody = extractYamlSection(content, familyId);
if (sectionBody) {
preservedSections.push({
key: familyId,
body: sectionBody,
});
}
}
// Preserve user customizations while pruning legacy generated Gemini preview noise.
const existingConfigVersion = getConfigVersionFromContent(content);
@@ -801,9 +815,9 @@ export function regenerateConfig(
// Generate fresh config with preserved user API keys and aliases
let configContent = generateUnifiedConfigContent(effectivePort, userApiKeys, existingAliases);
// Re-append claude-api-key section if it existed
if (claudeApiKeySection) {
configContent += `claude-api-key:\n${claudeApiKeySection}\n`;
// Re-append managed top-level sections that are not part of the generated defaults.
for (const section of preservedSections) {
configContent += `${section.key}:\n${section.body}\n`;
}
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
+3 -3
View File
@@ -17,7 +17,7 @@ import * as path from 'path';
import { ProgressIndicator } from '../../utils/progress-indicator';
import { ok, fail, info, warn } from '../../utils/ui';
import { getCcsDir } from '../../utils/config-manager';
import { escapeShellArg } from '../../utils/shell-executor';
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
import { ensureCLIProxyBinary } from '../binary-manager';
import {
generateConfig,
@@ -270,7 +270,7 @@ export async function execClaudeWithCLIProxy(
: undefined;
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
if (browserAttachRuntime?.warning) {
console.error(warn(browserAttachRuntime.warning));
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
}
if (browserRuntimeEnv) {
ensureBrowserMcpOrThrow();
@@ -1316,7 +1316,7 @@ export async function execClaudeWithCLIProxy(
claude = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
shell: getWindowsEscapedCommandShell(),
env: tracedEnv,
});
} else {
+27 -8
View File
@@ -7,7 +7,8 @@
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { getCliproxyConfigPath } from './config-generator';
import { configExists, getCliproxyConfigPath, regenerateConfig } from './config-generator';
import { rewriteTopLevelYamlSection } from './ai-providers/config-yaml-sections';
/** Model alias configuration */
export interface OpenAICompatModel {
@@ -62,17 +63,35 @@ function loadConfig(): ConfigYaml {
}
/**
* Save config.yaml with proper formatting
* Persist only the openai-compatibility section so the generated config header
* and unrelated user-managed sections survive legacy writes.
*/
function saveConfig(config: ConfigYaml): void {
const configPath = getCliproxyConfigPath();
const content = yaml.dump(config, {
lineWidth: -1, // Disable line wrapping
quotingType: '"',
forceQuotes: false,
});
if (!configExists()) {
regenerateConfig();
}
fs.writeFileSync(configPath, content, { mode: 0o600 });
const currentContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : '';
const sectionContent =
config['openai-compatibility'] && config['openai-compatibility'].length > 0
? yaml.dump(
{ 'openai-compatibility': config['openai-compatibility'] },
{
lineWidth: -1,
quotingType: '"',
forceQuotes: false,
}
)
: null;
const nextContent = rewriteTopLevelYamlSection(
currentContent,
'openai-compatibility',
sectionContent
);
const tempPath = `${configPath}.tmp`;
fs.writeFileSync(tempPath, nextContent, { mode: 0o600 });
fs.renameSync(tempPath, configPath);
}
/**
+369 -82
View File
@@ -1,5 +1,5 @@
interface AnthropicThinking {
type?: 'enabled' | 'disabled' | string;
type?: 'enabled' | 'disabled' | 'adaptive' | string;
budget_tokens?: number;
}
@@ -14,6 +14,7 @@ interface AnthropicImageBlock {
type?: string;
media_type?: string;
data?: string;
url?: string;
};
}
@@ -28,6 +29,7 @@ interface AnthropicToolResultBlock {
type: 'tool_result';
tool_use_id?: string;
content?: unknown;
is_error?: boolean;
}
type AnthropicContentBlock =
@@ -42,6 +44,16 @@ interface AnthropicMessage {
content?: string | AnthropicContentBlock[];
}
interface AnthropicOutputConfig {
effort?: 'low' | 'medium' | 'high' | 'max' | string;
}
interface AnthropicToolChoice {
type?: 'auto' | 'any' | 'tool' | 'none' | string;
name?: string;
disable_parallel_tool_use?: boolean;
}
interface AnthropicProxyRequestShape {
model?: unknown;
system?: unknown;
@@ -52,8 +64,10 @@ interface AnthropicProxyRequestShape {
stop_sequences?: unknown;
metadata?: unknown;
tools?: unknown;
tool_choice?: AnthropicToolChoice;
stream?: unknown;
thinking?: AnthropicThinking;
output_config?: AnthropicOutputConfig;
}
interface OpenAITextPart {
@@ -100,6 +114,17 @@ export interface ProxyOpenAIRequest {
parameters: Record<string, unknown>;
};
}>;
tool_choice?:
| 'auto'
| 'none'
| 'required'
| {
type: 'function';
function: {
name: string;
};
};
parallel_tool_calls?: boolean;
messages: OpenAIMessage[];
max_tokens?: number;
temperature?: number;
@@ -108,7 +133,6 @@ export interface ProxyOpenAIRequest {
metadata?: Record<string, unknown>;
}
const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]';
const TOOL_USE_ARGUMENTS_FALLBACK = '{}';
function assertObject(value: unknown, label: string): Record<string, unknown> {
@@ -168,17 +192,49 @@ function flattenTextContent(content: unknown, label: string): string {
.join('\n');
}
function toToolResultContent(content: unknown, label: string): string {
/**
* Convert tool_result content to OpenAI-compatible format.
* Handles strings, arrays with text/image blocks, and error prefixing.
* Ported from openclaude's convertToolResultContent.
*/
function convertToolResultContent(content: unknown, isError: boolean, label: string): string {
if (content === undefined) {
return '';
}
if (typeof content === 'string') {
return content;
return isError ? `Error: ${content}` : content;
}
if (Array.isArray(content)) {
return flattenTextContent(content, label);
if (!Array.isArray(content)) {
const text = safeJsonStringify(content, '[unserializable content]');
return isError ? `Error: ${text}` : text;
}
return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
const parts: string[] = [];
for (const [index, block] of content.entries()) {
const parsed = assertObject(block, `${label}[${index}]`);
if (parsed.type === 'text' && typeof parsed.text === 'string') {
parts.push(parsed.text);
continue;
}
if (parsed.type === 'image') {
throw new Error(`${label}[${index}].type "image" is not supported in tool_result content`);
}
if (typeof parsed.text === 'string') {
parts.push(parsed.text);
continue;
}
throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`);
}
const text = parts.join('\n');
if (!text) {
return isError ? 'Error:' : '';
}
return isError ? `Error: ${text}` : text;
}
function createFallbackToolId(messageIndex: number, blockIndex: number): string {
@@ -187,16 +243,27 @@ function createFallbackToolId(messageIndex: number, blockIndex: number): string
function toImagePart(block: AnthropicImageBlock, label: string): OpenAIImagePart {
const source = block.source;
if (!source || source.type !== 'base64' || !source.media_type || !source.data) {
throw new Error(`${label}.source must be a base64 image payload`);
if (!source) {
throw new Error(`${label}.source is missing`);
}
return {
type: 'image_url',
image_url: {
url: `data:${source.media_type};base64,${source.data}`,
},
};
if (source.type === 'url' && source.url) {
return {
type: 'image_url',
image_url: { url: source.url },
};
}
if (source.type === 'base64' && source.media_type && source.data) {
return {
type: 'image_url',
image_url: {
url: `data:${source.media_type};base64,${source.data}`,
},
};
}
throw new Error(`${label}.source must be a base64 or url image payload`);
}
function isImageBlock(block: AnthropicContentBlock): block is AnthropicImageBlock {
@@ -234,30 +301,85 @@ function transformTools(value: unknown): ProxyOpenAIRequest['tools'] {
(entry): entry is { name?: unknown; description?: unknown; input_schema?: unknown } =>
typeof entry === 'object' && entry !== null
)
.map((entry) => ({
type: 'function' as const,
function: {
name: typeof entry.name === 'string' ? entry.name : 'tool',
...(typeof entry.description === 'string' ? { description: entry.description } : {}),
parameters:
typeof entry.input_schema === 'object' && entry.input_schema !== null
? (entry.input_schema as Record<string, unknown>)
: { type: 'object', properties: {} },
},
}));
.map((entry) => {
const rawSchema =
typeof entry.input_schema === 'object' && entry.input_schema !== null
? (entry.input_schema as Record<string, unknown>)
: { type: 'object', properties: {} };
return {
type: 'function' as const,
function: {
name: typeof entry.name === 'string' ? entry.name : 'tool',
...(typeof entry.description === 'string' ? { description: entry.description } : {}),
parameters: rawSchema,
},
};
});
return tools.length > 0 ? tools : undefined;
}
function transformToolChoice(
value: AnthropicToolChoice | undefined,
hasTools: boolean
): Pick<ProxyOpenAIRequest, 'tool_choice' | 'parallel_tool_calls'> {
if (!value) {
return hasTools ? { tool_choice: 'auto' } : {};
}
if (!hasTools) {
throw new Error('tool_choice requires tools');
}
const parallelToolCalls =
value.disable_parallel_tool_use === true ? { parallel_tool_calls: false } : {};
switch (value.type) {
case undefined:
case 'auto':
return { tool_choice: 'auto', ...parallelToolCalls };
case 'none':
return { tool_choice: 'none' };
case 'any':
return { tool_choice: 'required', ...parallelToolCalls };
case 'tool':
if (typeof value.name !== 'string' || value.name.trim().length === 0) {
throw new Error('tool_choice.name must be a non-empty string when type is "tool"');
}
return {
tool_choice: {
type: 'function',
function: { name: value.name.trim() },
},
...parallelToolCalls,
};
default:
throw new Error('tool_choice.type must be "auto", "any", "tool", or "none"');
}
}
function mapThinkingToReasoning(
thinking: AnthropicThinking | undefined
thinking: AnthropicThinking | undefined,
outputConfig: AnthropicOutputConfig | undefined
): Pick<ProxyOpenAIRequest, 'reasoning' | 'reasoning_effort'> {
if (!thinking || thinking.type === 'disabled') {
return {};
}
if (thinking.type === 'adaptive') {
const effort = toOpenAIEffort(resolveOutputConfigEffort(outputConfig) ?? 'high');
return {
reasoning_effort: effort,
reasoning: {
enabled: true,
effort,
},
};
}
if (thinking.type !== 'enabled') {
throw new Error('thinking.type must be "enabled" or "disabled"');
throw new Error('thinking.type must be "enabled", "adaptive", or "disabled"');
}
const effort =
@@ -274,12 +396,37 @@ function mapThinkingToReasoning(
};
}
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']);
function resolveOutputConfigEffort(
outputConfig: AnthropicOutputConfig | undefined
): string | undefined {
if (!outputConfig || typeof outputConfig.effort !== 'string') {
return undefined;
}
const normalized = outputConfig.effort.trim().toLowerCase();
return VALID_EFFORT_LEVELS.has(normalized) ? normalized : undefined;
}
/**
* Map Anthropic effort levels to OpenAI-compatible reasoning_effort.
* Anthropic's `max` has no standard OpenAI equivalent — most providers
* only accept low/medium/high and reject unknown values with a 400.
* Ported from openclaude's standardEffortToOpenAI() which maps max -> xhigh
* for Codex; for generic OpenAI-compat providers we clamp to high.
*/
function toOpenAIEffort(effort: string): string {
return effort === 'max' ? 'high' : effort;
}
function transformMessages(messagesValue: unknown): OpenAIMessage[] {
if (!Array.isArray(messagesValue)) {
throw new Error('messages must be an array');
}
const translatedMessages: OpenAIMessage[] = [];
let pendingToolUseIds: Set<string> | null = null;
let hasPendingToolUseIds = false;
messagesValue.forEach((message, messageIndex) => {
const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage;
@@ -288,8 +435,19 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`);
}
if (pendingToolUseIds && pendingToolUseIds.size > 0 && role !== 'user') {
throw new Error(
`messages[${messageIndex}].role must be "user" with tool_result blocks after assistant tool_use`
);
}
const content = parsedMessage.content;
if (typeof content === 'string') {
if (pendingToolUseIds && pendingToolUseIds.size > 0) {
throw new Error(
`messages[${messageIndex}].content must start with tool_result blocks for pending tool_use ids`
);
}
translatedMessages.push({ role, content });
return;
}
@@ -298,10 +456,119 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
throw new Error(`messages[${messageIndex}].content must be a string or array`);
}
const userParts: OpenAIContentPart[] = [];
if (role === 'user') {
const userParts: OpenAIContentPart[] = [];
let sawToolResult = false;
const resolvedToolUseIds = new Set<string>();
content.forEach((block, blockIndex) => {
const parsed = assertObject(
block,
`messages[${messageIndex}].content[${blockIndex}]`
) as AnthropicContentBlock;
if (parsed.type === 'thinking' || parsed.type === 'redacted_thinking') {
return;
}
if (parsed.type === 'text') {
if (sawToolResult) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] text is not allowed after tool_result blocks`
);
}
const text = typeof parsed.text === 'string' ? parsed.text : '';
userParts.push({ type: 'text', text });
return;
}
if (isImageBlock(parsed)) {
if (sawToolResult) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] image is not allowed after tool_result blocks`
);
}
userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`));
return;
}
if (isToolResultBlock(parsed)) {
if (!pendingToolUseIds || pendingToolUseIds.size === 0) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] tool_result requires a preceding assistant tool_use`
);
}
if (userParts.length > 0) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] tool_result blocks must come before other user content`
);
}
if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string`
);
}
if (!pendingToolUseIds.has(parsed.tool_use_id)) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}].tool_use_id "${parsed.tool_use_id}" does not match a pending tool_use`
);
}
if (resolvedToolUseIds.has(parsed.tool_use_id)) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}].tool_use_id "${parsed.tool_use_id}" is duplicated`
);
}
sawToolResult = true;
resolvedToolUseIds.add(parsed.tool_use_id);
translatedMessages.push({
role: 'tool',
tool_call_id: parsed.tool_use_id,
content: convertToolResultContent(
parsed.content,
parsed.is_error === true,
`messages[${messageIndex}].content[${blockIndex}].content`
),
});
return;
}
if (isToolUseBlock(parsed)) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role`
);
}
throw new Error(
`messages[${messageIndex}].content[${blockIndex}].type "${String(parsed.type)}" is not supported`
);
});
if (sawToolResult) {
if (resolvedToolUseIds.size !== pendingToolUseIds?.size) {
throw new Error(
`messages[${messageIndex}].content must provide tool_result blocks for all pending tool_use ids`
);
}
pendingToolUseIds = null;
hasPendingToolUseIds = false;
return;
}
if (pendingToolUseIds && pendingToolUseIds.size > 0) {
throw new Error(
`messages[${messageIndex}].content must start with tool_result blocks for pending tool_use ids`
);
}
if (userParts.length > 0) {
flushUserContent(translatedMessages, userParts);
}
return;
}
// Assistant role
const assistantTextParts: string[] = [];
const toolCalls: NonNullable<OpenAIMessage['tool_calls']> = [];
let sawToolResult = false;
content.forEach((block, blockIndex) => {
const parsed = assertObject(
@@ -309,32 +576,17 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
`messages[${messageIndex}].content[${blockIndex}]`
) as AnthropicContentBlock;
if (parsed.type === 'text') {
const text = typeof parsed.text === 'string' ? parsed.text : '';
if (role === 'user') {
userParts.push({ type: 'text', text });
} else {
assistantTextParts.push(text);
}
if (parsed.type === 'thinking' || parsed.type === 'redacted_thinking') {
return;
}
if (isImageBlock(parsed)) {
if (role !== 'user') {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] image requires user role`
);
}
userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`));
if (parsed.type === 'text') {
const text = typeof parsed.text === 'string' ? parsed.text : '';
assistantTextParts.push(text);
return;
}
if (isToolUseBlock(parsed)) {
if (role !== 'assistant') {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role`
);
}
toolCalls.push({
id:
typeof parsed.id === 'string' && parsed.id.length > 0
@@ -349,28 +601,16 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
return;
}
if (isImageBlock(parsed)) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] image requires user role`
);
}
if (isToolResultBlock(parsed)) {
if (role !== 'user') {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] tool_result requires user role`
);
}
if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string`
);
}
sawToolResult = true;
flushUserContent(translatedMessages, userParts);
translatedMessages.push({
role: 'tool',
tool_call_id: parsed.tool_use_id,
content: toToolResultContent(
parsed.content,
`messages[${messageIndex}].content[${blockIndex}].content`
),
});
return;
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] tool_result requires user role`
);
}
throw new Error(
@@ -378,26 +618,72 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
);
});
if (role === 'assistant') {
translatedMessages.push({
role: 'assistant',
content: assistantTextParts.join('\n'),
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
});
if (assistantTextParts.length === 0 && toolCalls.length === 0) {
return;
}
if (userParts.length > 0 || !sawToolResult) {
flushUserContent(translatedMessages, userParts);
}
pendingToolUseIds =
toolCalls.length > 0 ? new Set(toolCalls.map((toolCall) => toolCall.id)) : null;
hasPendingToolUseIds = toolCalls.length > 0;
translatedMessages.push({
role: 'assistant',
content: assistantTextParts.join('\n'),
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
});
});
if (hasPendingToolUseIds) {
throw new Error('messages must provide tool_result blocks for the latest assistant tool_use');
}
return translatedMessages;
}
/**
* Coalesce consecutive messages of the same role.
* OpenAI/vLLM/Ollama/Mistral require strict user<->assistant alternation.
* Multiple consecutive tool messages are allowed (assistant -> tool* -> user).
* Ported from openclaude's coalescing pass.
*/
function coalesceMessages(messages: OpenAIMessage[]): OpenAIMessage[] {
const coalesced: OpenAIMessage[] = [];
for (const msg of messages) {
const prev = coalesced[coalesced.length - 1];
if (prev && prev.role === msg.role && msg.role !== 'tool' && msg.role !== 'system') {
const prevContent = prev.content;
const curContent = msg.content;
if (typeof prevContent === 'string' && typeof curContent === 'string') {
prev.content = prevContent + (prevContent && curContent ? '\n' : '') + curContent;
} else {
const toArray = (
c: string | OpenAIContentPart[] | null | undefined
): OpenAIContentPart[] => {
if (!c) return [];
if (typeof c === 'string') return c ? [{ type: 'text', text: c }] : [];
return c;
};
prev.content = [...toArray(prevContent), ...toArray(curContent)];
}
if (msg.tool_calls?.length) {
prev.tool_calls = [...(prev.tool_calls ?? []), ...msg.tool_calls];
}
} else {
coalesced.push({ ...msg });
}
}
return coalesced;
}
export class ProxyRequestTransformer {
transform(raw: unknown): ProxyOpenAIRequest {
const source = assertObject(raw || {}, 'request') as AnthropicProxyRequestShape;
const tools = transformTools(source.tools);
const messages = transformMessages(source.messages);
const system = source.system;
const allMessages =
@@ -414,14 +700,15 @@ export class ProxyRequestTransformer {
? source.model.trim()
: undefined,
stream: source.stream === true,
messages: allMessages,
messages: coalesceMessages(allMessages),
max_tokens: asNumber(source.max_tokens),
temperature: asNumber(source.temperature),
top_p: asNumber(source.top_p),
stop: asStringArray(source.stop_sequences),
metadata: asMetadata(source.metadata),
tools: transformTools(source.tools),
...mapThinkingToReasoning(source.thinking),
tools,
...transformToolChoice(source.tool_choice, tools !== undefined),
...mapThinkingToReasoning(source.thinking, source.output_config),
};
}
}
+7 -2
View File
@@ -9,7 +9,12 @@ import { spawn, ChildProcess } from 'child_process';
import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter';
import { detectClaudeCli, getClaudeCliInfo } from '../utils/claude-detector';
import type { ProfileType } from '../types/profile';
import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from '../utils/shell-executor';
import {
escapeShellArg,
getWindowsEscapedCommandShell,
stripAnthropicEnv,
stripClaudeCodeEnv,
} from '../utils/shell-executor';
import { ErrorManager } from '../utils/error-manager';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { appendBrowserToolArgs } from '../utils/browser';
@@ -111,7 +116,7 @@ export class ClaudeAdapter implements TargetAdapter {
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
shell: getWindowsEscapedCommandShell(),
env,
});
} else {
+7 -2
View File
@@ -4,7 +4,12 @@ import type { ProfileType } from '../types/profile';
import { runCleanup } from '../errors';
import { expandPath } from '../utils/helpers';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { escapeShellArg, stripAnthropicEnv, stripCodexSessionEnv } from '../utils/shell-executor';
import {
escapeShellArg,
getWindowsEscapedCommandShell,
stripAnthropicEnv,
stripCodexSessionEnv,
} from '../utils/shell-executor';
import type {
TargetAdapter,
TargetBinaryInfo,
@@ -316,7 +321,7 @@ export class CodexAdapter implements TargetAdapter {
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
shell: getWindowsEscapedCommandShell(),
env: launchEnv,
});
} else {
+4 -2
View File
@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as childProcess from 'child_process';
import { expandPath } from '../utils/helpers';
import { escapeShellArg } from '../utils/shell-executor';
import { escapeShellArg, getWindowsEscapedCommandShell } from '../utils/shell-executor';
import type { TargetBinaryInfo } from './target-adapter';
const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides';
@@ -53,12 +53,14 @@ function runCodexProbe(codexPath: string, args: string[]): string | undefined {
if (needsShell) {
const cmdString = [codexPath, ...args].map(escapeShellArg).join(' ');
return childProcess.execFileSync('cmd.exe', ['/d', '/s', '/c', cmdString], {
const result = childProcess.spawnSync(cmdString, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
windowsHide: true,
shell: getWindowsEscapedCommandShell(),
});
return result.status === 0 ? result.stdout : undefined;
}
return childProcess.execFileSync(codexPath, args, {
+6 -2
View File
@@ -12,7 +12,11 @@ import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-d
import type { ProfileType } from '../types/profile';
import { upsertCcsModel } from './droid-config-manager';
import { resolveDroidProvider } from './droid-provider';
import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor';
import {
escapeShellArg,
getWindowsEscapedCommandShell,
stripAnthropicEnv,
} from '../utils/shell-executor';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { runCleanup } from '../errors';
@@ -134,7 +138,7 @@ export class DroidAdapter implements TargetAdapter {
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
shell: getWindowsEscapedCommandShell(),
env,
});
} else {
+149 -6
View File
@@ -1,7 +1,9 @@
import * as fs from 'fs';
import * as path from 'path';
import type { BrowserConfig } from '../../config/unified-config-types';
import { getCcsDir } from '../config-manager';
import { expandPath } from '../helpers';
import { getNodePlatformKey } from './platform';
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
@@ -24,10 +26,141 @@ export interface BrowserAttachRuntimeResolution {
warning?: string;
}
export interface ManagedBrowserAttachBootstrap {
usesManagedDefaultDir: boolean;
createdProfileDir: boolean;
}
export interface ManagedBrowserAttachNotReadyMessage {
state: 'path_missing' | 'browser_not_running' | 'endpoint_unreachable';
title: string;
detail: string;
nextStep: string;
warning: string;
}
function isManagedDefaultBrowserAttach(config: EffectiveClaudeBrowserAttachConfig): boolean {
return (
config.source === 'config' &&
path.resolve(config.userDataDir) === path.resolve(getRecommendedBrowserUserDataDir())
);
}
function buildCurrentPlatformLaunchCommand(userDataDir: string, devtoolsPort: number): string {
const quotedPath = JSON.stringify(userDataDir);
switch (getNodePlatformKey()) {
case 'darwin':
return `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
case 'win32':
return `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
default:
return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`;
}
}
export function resolveBrowserUserDataDir(value?: string): string | undefined {
return value?.trim() ? expandPath(value) : undefined;
}
export function ensureManagedBrowserUserDataDir(
config: EffectiveClaudeBrowserAttachConfig
): ManagedBrowserAttachBootstrap {
if (!isManagedDefaultBrowserAttach(config)) {
return {
usesManagedDefaultDir: false,
createdProfileDir: false,
};
}
try {
fs.statSync(config.userDataDir);
return {
usesManagedDefaultDir: true,
createdProfileDir: false,
};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code && code !== 'ENOENT') {
return {
usesManagedDefaultDir: true,
createdProfileDir: false,
};
}
}
try {
fs.mkdirSync(config.userDataDir, { recursive: true, mode: 0o700 });
return {
usesManagedDefaultDir: true,
createdProfileDir: true,
};
} catch {
return {
usesManagedDefaultDir: true,
createdProfileDir: false,
};
}
}
export function describeManagedBrowserAttachNotReady(
config: EffectiveClaudeBrowserAttachConfig,
errorMessage: string,
options: {
createdProfileDir?: boolean;
launchCommand?: string;
} = {}
): ManagedBrowserAttachNotReadyMessage | undefined {
if (!isManagedDefaultBrowserAttach(config)) {
return undefined;
}
const launchCommand =
options.launchCommand ??
buildCurrentPlatformLaunchCommand(config.userDataDir, config.devtoolsPort);
const continueWithoutTools =
'CCS will continue without browser tools until the attach session is ready.';
if (errorMessage.includes('Chrome reuse metadata')) {
const summary = options.createdProfileDir
? `CCS created the managed browser profile at ${config.userDataDir}, but no running attach-mode Chrome session is using it yet`
: `No running attach-mode Chrome session is using the managed browser profile at ${config.userDataDir}`;
const nextStep = `Start Chrome with remote debugging and the managed user-data dir. Example: ${launchCommand}`;
return {
state: 'browser_not_running',
title: 'Claude Browser Attach is waiting for a managed Chrome session.',
detail: `${summary}. Diagnostic: ${errorMessage}`,
nextStep,
warning: `${summary}. ${nextStep} ${continueWithoutTools}`,
};
}
if (errorMessage.includes('Chrome DevTools endpoint')) {
const summary = `CCS could not reach the attach-mode DevTools endpoint for the managed browser profile at ${config.userDataDir}`;
const nextStep = `Restart Chrome in attach mode and retry. Example: ${launchCommand}`;
return {
state: 'endpoint_unreachable',
title: 'Claude Browser Attach could not reach the managed Chrome session.',
detail: `${summary}. Diagnostic: ${errorMessage}`,
nextStep,
warning: `${summary}. ${nextStep} ${continueWithoutTools}`,
};
}
if (errorMessage.includes('Chrome profile directory is invalid')) {
const summary = `CCS could not initialize the managed browser profile at ${config.userDataDir}`;
const nextStep = `Confirm the path is writable or reset it to the CCS-managed default, then launch Chrome in attach mode. Example: ${launchCommand}`;
return {
state: 'path_missing',
title: 'Claude Browser Attach could not initialize the managed profile.',
detail: `${summary}. Diagnostic: ${errorMessage}`,
nextStep,
warning: `${summary}. ${nextStep} ${continueWithoutTools}`,
};
}
return undefined;
}
export function getBrowserAttachOverride(env: NodeJS.ProcessEnv = process.env): {
userDataDir?: string;
devtoolsPort?: number;
@@ -94,6 +227,17 @@ export async function resolveOptionalBrowserAttachRuntime(
return {};
}
const bootstrap = ensureManagedBrowserUserDataDir(config);
if (bootstrap.createdProfileDir) {
return {
warning: describeManagedBrowserAttachNotReady(
config,
`Chrome reuse metadata not found: ${path.join(config.userDataDir, 'DevToolsActivePort')}`,
{ createdProfileDir: true }
)?.warning,
};
}
try {
return {
runtimeEnv: await resolveBrowserRuntimeEnv({
@@ -103,13 +247,12 @@ export async function resolveOptionalBrowserAttachRuntime(
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const usesManagedDefaultDir =
config.source === 'config' &&
path.resolve(config.userDataDir) === path.resolve(getRecommendedBrowserUserDataDir());
if (usesManagedDefaultDir && message.includes('Chrome profile directory is invalid')) {
const managedDefaultMessage = describeManagedBrowserAttachNotReady(config, message, {
createdProfileDir: bootstrap.createdProfileDir,
});
if (managedDefaultMessage) {
return {
warning: `Claude Browser Attach is enabled, but the managed browser profile does not exist yet (${config.userDataDir}). Launching without browser tools. Run \`ccs browser doctor\` to finish setup.`,
warning: managedDefaultMessage.warning,
};
}
+38
View File
@@ -1,9 +1,12 @@
import * as path from 'path';
import { getBrowserConfig } from '../../config/unified-config-loader';
import { getCodexBinaryInfo } from '../../targets/codex-detector';
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer';
import { getNodePlatformKey } from './platform';
import {
describeManagedBrowserAttachNotReady,
ensureManagedBrowserUserDataDir,
getEffectiveClaudeBrowserAttachConfig,
getRecommendedBrowserUserDataDir,
} from './browser-settings';
@@ -61,6 +64,7 @@ async function buildClaudeBrowserStatus(
): Promise<ClaudeBrowserStatus> {
const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig);
const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort);
const managedBootstrap = ensureManagedBrowserUserDataDir(effective);
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
enabled: effective.enabled,
source: effective.source,
@@ -85,6 +89,26 @@ async function buildClaudeBrowserStatus(
};
}
if (managedBootstrap.createdProfileDir) {
const managedDefaultMessage = describeManagedBrowserAttachNotReady(
effective,
`Chrome reuse metadata not found: ${path.join(effective.userDataDir, 'DevToolsActivePort')}`,
{
createdProfileDir: true,
launchCommand: launchCommands[getNodePlatformKey()],
}
);
if (managedDefaultMessage) {
return {
...base,
state: managedDefaultMessage.state,
title: managedDefaultMessage.title,
detail: managedDefaultMessage.detail,
nextStep: managedDefaultMessage.nextStep,
};
}
}
try {
const runtimeEnv = await resolveBrowserRuntimeEnv({
profileDir: effective.userDataDir,
@@ -102,6 +126,20 @@ async function buildClaudeBrowserStatus(
};
} catch (error) {
const message = (error as Error).message;
const managedDefaultMessage = describeManagedBrowserAttachNotReady(effective, message, {
createdProfileDir: managedBootstrap.createdProfileDir,
launchCommand: launchCommands[getNodePlatformKey()],
});
if (managedDefaultMessage) {
return {
...base,
state: managedDefaultMessage.state,
title: managedDefaultMessage.title,
detail: managedDefaultMessage.detail,
nextStep: managedDefaultMessage.nextStep,
};
}
if (message.includes('Chrome profile directory is invalid')) {
return {
...base,
+6 -2
View File
@@ -6,7 +6,11 @@
*/
import { spawn, ChildProcess, SpawnOptions } from 'child_process';
import { escapeShellArg, stripClaudeCodeEnv } from './shell-executor';
import {
escapeShellArg,
getWindowsEscapedCommandShell,
stripClaudeCodeEnv,
} from './shell-executor';
import { getClaudeCliInfo } from './claude-detector';
import { ErrorManager } from './error-manager';
@@ -56,7 +60,7 @@ export function spawnClaude(options: SpawnClaudeOptions = {}): SpawnClaudeResult
child = spawn(cmdString, {
stdio,
windowsHide: true,
shell: true,
shell: getWindowsEscapedCommandShell(),
env: mergedEnv,
cwd,
});
+16 -2
View File
@@ -4,7 +4,7 @@
* Cross-platform shell execution utilities for CCS.
*/
import { spawn, spawnSync, ChildProcess } from 'child_process';
import { spawn, spawnSync, ChildProcess, type SpawnOptions } from 'child_process';
import { ErrorManager } from './error-manager';
import { getWebSearchHookEnv } from './websearch-manager';
import { wireChildProcessSignals } from './signal-forwarder';
@@ -107,6 +107,20 @@ export function escapeShellArg(arg: string): string {
}
}
/**
* Return the shell that matches escapeShellArg() quoting semantics.
*
* On Windows, prefer ComSpec over a bare `cmd.exe` so escaped wrapper launches
* keep the same shell contract without depending on PATH lookup.
*/
export function getWindowsEscapedCommandShell(): SpawnOptions['shell'] {
if (process.platform !== 'win32') {
return true;
}
return process.env.ComSpec || process.env.COMSPEC || 'cmd.exe';
}
/**
* Execute Claude CLI with unified spawn logic
*/
@@ -182,7 +196,7 @@ export function execClaude(
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
shell: getWindowsEscapedCommandShell(),
env,
});
} else {
@@ -116,13 +116,63 @@ describe('openai proxy messages endpoint', () => {
const parsedUpstream = upstreamBody as {
messages?: Array<{ role: string; content: string }>;
tool_choice?: unknown;
tools?: Array<{ type: string; function: { name: string } }>;
};
expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' });
expect(parsedUpstream.tool_choice).toBe('auto');
expect(parsedUpstream.tools?.[0]?.type).toBe('function');
expect(parsedUpstream.tools?.[0]?.function.name).toBe('search');
});
it('preserves tool schemas and forwards explicit tool_choice semantics upstream', async () => {
const response = await requestProxy({
model: 'hf-model',
messages: [{ role: 'user', content: [{ type: 'text', text: 'Search docs' }] }],
tools: [
{
name: 'search',
description: 'Search docs',
input_schema: {
type: 'object',
properties: {
q: { type: 'string', pattern: '^[a-z]+$' },
},
required: ['q'],
additionalProperties: true,
},
},
],
tool_choice: {
type: 'tool',
name: 'search',
disable_parallel_tool_use: true,
},
});
expect(response.status).toBe(200);
const parsedUpstream = upstreamBody as {
tool_choice?: unknown;
parallel_tool_calls?: boolean;
tools?: Array<{ type: string; function: { parameters: Record<string, unknown> } }>;
};
expect(parsedUpstream.tool_choice).toEqual({
type: 'function',
function: { name: 'search' },
});
expect(parsedUpstream.parallel_tool_calls).toBe(false);
expect(parsedUpstream.tools?.[0]?.function.parameters).toEqual({
type: 'object',
properties: {
q: { type: 'string', pattern: '^[a-z]+$' },
},
required: ['q'],
additionalProperties: true,
});
});
it('falls back to Anthropic JSON for non-streaming requests', async () => {
const response = await requestProxy({
model: 'hf-model',
@@ -155,6 +205,24 @@ describe('openai proxy messages endpoint', () => {
expect(body.error?.message).toContain('Invalid JSON');
});
it('returns invalid_request_error for orphan tool_result blocks', async () => {
const response = await requestProxy({
model: 'hf-model',
messages: [
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_orphan', content: 'orphan' }],
},
],
});
const body = (await response.json()) as { error?: { type?: string; message?: string } };
expect(response.status).toBe(400);
expect(body.error?.type).toBe('invalid_request_error');
expect(body.error?.message).toContain('tool_result requires a preceding assistant tool_use');
});
it('rejects requests without the local proxy auth token', async () => {
const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, {
method: 'POST',
@@ -214,4 +214,75 @@ describe('openai proxy request routing', () => {
expect(hits).toEqual(['thinker']);
expect(bodies[0]?.body).toMatchObject({ model: 'deepseek-reasoner' });
});
it('routes adaptive thinking requests through the configured think scenario', async () => {
const primaryPort = await getPort();
const thinkPort = await getPort();
const hits: string[] = [];
const bodies: Array<{ label: string; body: unknown }> = [];
await startMockUpstream(primaryPort, 'primary', hits, bodies);
await startMockUpstream(thinkPort, 'thinker', hits, bodies);
const primarySettings = writeSettings('hf', {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
ANTHROPIC_AUTH_TOKEN: 'hf_token',
ANTHROPIC_MODEL: 'hf-default',
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
});
const thinkSettings = writeSettings('thinker', {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${thinkPort}`,
ANTHROPIC_AUTH_TOKEN: 'think_token',
ANTHROPIC_MODEL: 'deepseek-reasoner',
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
});
fs.writeFileSync(
path.join(tempDir, '.ccs', 'config.json'),
JSON.stringify(
{
profiles: { hf: primarySettings, thinker: thinkSettings },
proxy: {
routing: {
think: 'thinker:deepseek-reasoner',
},
},
},
null,
2
),
'utf8'
);
const profile: OpenAICompatProfileConfig = {
profileName: 'hf',
settingsPath: primarySettings,
baseUrl: `http://127.0.0.1:${primaryPort}`,
apiKey: 'hf_token',
provider: 'generic-chat-completion-api',
model: 'hf-default',
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
authToken: 'test-proxy-token',
});
const response = await requestProxy({
model: 'hf-default',
thinking: { type: 'adaptive' },
output_config: { effort: 'max' },
messages: [{ role: 'user', content: 'think adaptively' }],
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
content: [{ type: 'text', text: 'Reply from thinker' }],
});
expect(hits).toEqual(['thinker']);
expect(bodies[0]?.body).toMatchObject({
model: 'deepseek-reasoner',
reasoning_effort: 'high',
reasoning: { enabled: true, effort: 'high' },
});
});
});
@@ -445,6 +445,38 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
assert(newConfig.includes('port: 9999'), 'Should preserve custom port');
});
it('preserves openai-compatibility connectors during regeneration', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const initialConfig = `# CLIProxyAPI config generated by CCS v17
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
openai-compatibility:
- name: mimo
base-url: https://api.xiaomimimo.com/v1
api-key-entries:
- api-key: sk-test
models:
- name: mimo-v2-flash
alias: mimo-v2-flash
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(newConfig.includes('openai-compatibility:'), 'Should preserve openai-compatibility');
assert(newConfig.includes('name: mimo'), 'Should preserve connector name');
assert(newConfig.includes('base-url: https://api.xiaomimimo.com/v1'), 'Should preserve base URL');
assert(newConfig.includes('alias: mimo-v2-flash'), 'Should preserve model aliases');
});
it('creates fresh config when none exists', () => {
// Ensure clean state
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
@@ -0,0 +1,110 @@
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
describe('openai-compat manager', () => {
let testDir;
let originalCcsHome;
let originalCcsDir;
let configGenerator;
let openAICompatManager;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-compat-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
process.env.CCS_HOME = testDir;
process.env.CCS_DIR = path.join(testDir, '.ccs');
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
delete require.cache[require.resolve('../../../dist/cliproxy/openai-compat-manager')];
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
configGenerator = require('../../../dist/cliproxy/config-generator');
openAICompatManager = require('../../../dist/cliproxy/openai-compat-manager');
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (originalCcsDir !== undefined) {
process.env.CCS_DIR = originalCcsDir;
} else {
delete process.env.CCS_DIR;
}
if (testDir && fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
it('preserves the generated header and connector entries across regeneration', () => {
configGenerator.regenerateConfig();
const configPath = configGenerator.getCliproxyConfigPath();
const initialHeader = fs.readFileSync(configPath, 'utf8').split('\n')[0];
openAICompatManager.addOpenAICompatProvider({
name: 'mimo',
baseUrl: 'https://api.xiaomimimo.com/v1',
apiKey: 'sk-test',
models: [{ name: 'mimo-v2-flash', alias: 'mimo-v2-flash' }],
});
const afterWrite = fs.readFileSync(configPath, 'utf8');
assert.strictEqual(afterWrite.split('\n')[0], initialHeader, 'Should preserve the generated header');
assert(afterWrite.includes('openai-compatibility:'), 'Should write the openai-compatibility section');
assert.strictEqual(
configGenerator.configNeedsRegeneration(),
false,
'Legacy openai-compat writes should not force regeneration'
);
configGenerator.regenerateConfig();
const afterRegen = fs.readFileSync(configPath, 'utf8');
assert(afterRegen.includes('openai-compatibility:'), 'Connector section should survive regeneration');
assert(afterRegen.includes('name: mimo'), 'Connector name should survive regeneration');
assert(
afterRegen.includes('base-url: https://api.xiaomimimo.com/v1'),
'Connector base URL should survive regeneration'
);
});
it('removes the openai-compatibility section cleanly when the last legacy connector is deleted', () => {
configGenerator.regenerateConfig();
const configPath = configGenerator.getCliproxyConfigPath();
const initialHeader = fs.readFileSync(configPath, 'utf8').split('\n')[0];
openAICompatManager.addOpenAICompatProvider({
name: 'mimo',
baseUrl: 'https://api.xiaomimimo.com/v1',
apiKey: 'sk-test',
models: [{ name: 'mimo-v2-flash', alias: 'mimo-v2-flash' }],
});
const removed = openAICompatManager.removeOpenAICompatProvider('mimo');
assert.strictEqual(removed, true, 'Expected the legacy connector to be removed');
const afterRemove = fs.readFileSync(configPath, 'utf8');
assert.strictEqual(
afterRemove.split('\n')[0],
initialHeader,
'Should preserve the generated header after removing the last connector'
);
assert(
!afterRemove.includes('openai-compatibility:'),
'Should remove the openai-compatibility section when the last connector is deleted'
);
assert(!afterRemove.includes('name: mimo'), 'Should remove the deleted connector payload');
assert.strictEqual(
configGenerator.configNeedsRegeneration(),
false,
'Removing the last legacy connector should not force regeneration'
);
});
});
+10 -4
View File
@@ -20,6 +20,13 @@ describe('cliproxy routing strategy service', () => {
beforeEach(async () => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-strategy-'));
scopedConfigDir = path.join(tempHome, '.ccs');
routingTarget = {
host: '127.0.0.1',
port: 8317,
protocol: 'http',
isRemote: false,
};
responseFactory = null;
originalCcsDir = process.env.CCS_DIR;
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_DIR = scopedConfigDir;
@@ -109,10 +116,9 @@ describe('cliproxy routing strategy service', () => {
expect(result.applied).toBe('config-only');
expect(result.strategy).toBe('fill-first');
const configPath = path.join(scopedConfigDir, 'cliproxy', 'config.yaml');
const configContent = fs.readFileSync(configPath, 'utf8');
expect(configContent).toContain('routing:');
expect(configContent).toContain('strategy: fill-first');
const { loadUnifiedConfig } = await import('../../../src/config/unified-config-loader');
const persisted = loadUnifiedConfig();
expect(persisted?.cliproxy?.routing?.strategy).toBe('fill-first');
});
});
@@ -465,6 +465,7 @@ global.fetch = async (url) => {
}),
env: {
...process.env,
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
CCS_WEBSEARCH_ENABLED: '1',
CCS_WEBSEARCH_SKIP: '0',
CCS_WEBSEARCH_BRAVE: '0',
@@ -0,0 +1,255 @@
import { describe, expect, it } from 'bun:test';
import { ProxyRequestTransformer } from '../../../../src/proxy/transformers/request-transformer';
describe('ProxyRequestTransformer regressions', () => {
it('drops assistant messages that only contain stripped thinking blocks', () => {
const result = new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [
{ type: 'thinking', text: 'internal' },
{ type: 'redacted_thinking', text: 'hidden' },
],
},
],
});
expect(result.messages).toEqual([]);
});
it('maps adaptive thinking through output_config effort for OpenAI-compatible upstreams', () => {
const result = new ProxyRequestTransformer().transform({
messages: [{ role: 'user', content: 'hello' }],
thinking: { type: 'adaptive' },
output_config: { effort: 'max' },
});
expect(result.reasoning_effort).toBe('high');
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
});
it('rejects unsupported thinking types instead of silently dropping them', () => {
expect(() =>
new ProxyRequestTransformer().transform({
messages: [{ role: 'user', content: 'hello' }],
thinking: { type: 'typo' },
})
).toThrow('thinking.type must be "enabled", "adaptive", or "disabled"');
});
it('keeps Anthropic role validation for tool_use, image, and tool_result blocks', () => {
expect(() =>
new ProxyRequestTransformer().transform({
messages: [{ role: 'user', content: [{ type: 'tool_use', name: 'search', input: {} }] }],
})
).toThrow('tool_use requires assistant role');
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [
{
type: 'image',
source: { type: 'url', url: 'https://example.com/image.png' },
},
],
},
],
})
).toThrow('image requires user role');
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'nope' }],
},
],
})
).toThrow('tool_result requires user role');
});
it('rejects orphaned, incomplete, or mixed-order tool_result blocks', () => {
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'orphan' }],
},
],
})
).toThrow('tool_result requires a preceding assistant tool_use');
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [
{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'docs' } },
{ type: 'tool_use', id: 'toolu_2', name: 'open', input: { url: 'https://example.com' } },
],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'partial' }],
},
],
})
).toThrow('must provide tool_result blocks for all pending tool_use ids');
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
},
{
role: 'user',
content: [
{ type: 'text', text: 'Here you go' },
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'result' },
],
},
],
})
).toThrow('tool_result blocks must come before other user content');
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
},
{
role: 'user',
content: 'plain follow-up',
},
],
})
).toThrow('must start with tool_result blocks for pending tool_use ids');
});
it('rejects tool_result content that cannot be represented as OpenAI tool text', () => {
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_1',
content: [{ type: 'image', source: { type: 'url', url: 'https://example.com/error.png' } }],
},
],
},
],
})
).toThrow('type "image" is not supported in tool_result content');
});
it('rejects unsupported assistant blocks instead of silently dropping them', () => {
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [{ type: 'server_tool_use', id: 'srv_1' }],
},
],
})
).toThrow('type "server_tool_use" is not supported');
});
it('translates url images and tool_choice while coalescing repeated turns', () => {
const result = new ProxyRequestTransformer().transform({
tool_choice: {
type: 'tool',
name: 'vision',
disable_parallel_tool_use: true,
},
tools: [{ name: 'vision', description: 'Inspect image', input_schema: { type: 'object' } }],
messages: [
{
role: 'user',
content: [{ type: 'image', source: { type: 'url', url: 'https://example.com/cat.png' } }],
},
{ role: 'user', content: [{ type: 'text', text: 'Describe it' }] },
{
role: 'assistant',
content: [{ type: 'text', text: 'Checking' }],
},
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_1',
is_error: true,
content: [{ type: 'text', text: 'fetch failed' }],
},
],
},
],
});
expect(result.tool_choice).toEqual({
type: 'function',
function: { name: 'vision' },
});
expect(result.parallel_tool_calls).toBe(false);
expect(result.messages[0]).toEqual({
role: 'user',
content: [
{ type: 'image_url', image_url: { url: 'https://example.com/cat.png' } },
{ type: 'text', text: 'Describe it' },
],
});
expect(result.messages[1]).toEqual({
role: 'assistant',
content: 'Checking',
tool_calls: [
{
id: 'toolu_1',
type: 'function',
function: {
name: 'vision',
arguments: '{"detail":"high"}',
},
},
],
});
expect(result.messages[2]).toEqual({
role: 'tool',
tool_call_id: 'toolu_1',
content: 'Error: fetch failed',
});
});
it('defaults tools to auto tool_choice when none is specified', () => {
const result = new ProxyRequestTransformer().transform({
messages: [{ role: 'user', content: 'hello' }],
tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }],
});
expect(result.tool_choice).toBe('auto');
});
});
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as childProcess from 'child_process';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodexAdapter } from '../../../src/targets/codex-adapter';
import { buildCodexBrowserMcpOverrides } from '../../../src/utils/browser-codex-overrides';
import * as signalForwarder from '../../../src/utils/signal-forwarder';
function createMockChild(): EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
exitCode: number | null;
killed: boolean;
pid: number;
unref: () => EventEmitter;
kill: () => boolean;
} {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
exitCode: number | null;
killed: boolean;
pid: number;
unref: () => EventEmitter;
kill: () => boolean;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.exitCode = null;
child.killed = false;
child.pid = process.pid;
child.unref = () => child;
child.kill = () => {
child.killed = true;
child.exitCode = 1;
return true;
};
return child;
}
describe('codex-adapter exec', () => {
const originalPlatform = process.platform;
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-adapter-exec-'));
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('launches Windows cmd wrappers via cmd.exe when runtime overrides include browser MCP args', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
const fakeCodex = path.join(tmpDir, 'codex.cmd');
fs.writeFileSync(fakeCodex, '');
const spawnSpy = spyOn(childProcess, 'spawn').mockImplementation(
() => createMockChild() as unknown as ReturnType<typeof childProcess.spawn>
);
const signalSpy = spyOn(signalForwarder, 'wireChildProcessSignals').mockImplementation(
() => undefined
);
try {
const adapter = new CodexAdapter();
const binaryInfo = {
path: fakeCodex,
needsShell: true,
features: ['config-overrides'],
};
const args = adapter.buildArgs('default', ['--version'], {
profileType: 'default',
creds: {
profile: 'default',
baseUrl: '',
apiKey: '',
runtimeConfigOverrides: buildCodexBrowserMcpOverrides(),
},
binaryInfo,
});
adapter.exec(args, {}, { binaryInfo });
expect(spawnSpy).toHaveBeenCalledTimes(1);
const [command, options] = spawnSpy.mock.calls[0] as [
string,
Record<string, unknown> | undefined,
];
expect(options?.shell).toBe('cmd.exe');
expect(command).toContain(fakeCodex);
expect(command).toContain('mcp_servers.ccs_browser.args=');
expect(command).toContain('@playwright/mcp@0.0.70');
} finally {
spawnSpy.mockRestore();
signalSpy.mockRestore();
}
});
});
+22 -6
View File
@@ -58,19 +58,35 @@ describe('codex-detector', () => {
process.env.CCS_CODEX_PATH = fakeCodex;
Object.defineProperty(process, 'platform', { value: 'win32' });
const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
return String(command).includes('cmd.exe') && Array.isArray(args) && args.join(' ').includes('--help')
? 'Codex CLI\n -c, --config <key=value>\n'
: 'codex-cli 0.118.0-alpha.3';
const spawnSyncSpy = spyOn(childProcess, 'spawnSync').mockImplementation((command) => {
const commandString = String(command);
return {
pid: 123,
output: ['', '', ''],
stdout: commandString.includes('--help')
? 'Codex CLI\n -c, --config <key=value>\n'
: 'codex-cli 0.118.0-alpha.3',
stderr: '',
status: 0,
signal: null,
} as unknown as ReturnType<typeof childProcess.spawnSync>;
});
const info = getCodexBinaryInfo();
const calls = spawnSyncSpy.mock.calls;
const cmdWrapperProbeCall = calls.find(([command]) => {
return String(command).includes(fakeCodex);
});
expect(execFileSyncSpy).toHaveBeenCalled();
expect(spawnSyncSpy).toHaveBeenCalled();
expect(cmdWrapperProbeCall).toBeDefined();
expect((cmdWrapperProbeCall?.[1] as Record<string, unknown> | undefined)?.shell).toBe(
'cmd.exe'
);
expect(info?.needsShell).toBe(true);
expect(info?.features).toContain('config-overrides');
execFileSyncSpy.mockRestore();
spawnSyncSpy.mockRestore();
});
it('keeps the cmd wrapper when Windows PATH exposes codex.cmd and a sibling ps1 also exists', () => {
@@ -81,6 +81,18 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
};
}
function reserveClosedPort(): number {
const server = Bun.serve({
port: 0,
fetch() {
return new Response('ok');
},
});
const { port } = server;
server.stop(true);
return port;
}
describe('default profile browser launch', () => {
let tmpHome = '';
let fakeClaudePath = '';
@@ -259,7 +271,7 @@ server.listen(0, '127.0.0.1', () => {
claude: {
enabled: true,
user_data_dir: '',
devtools_port: 9222,
devtools_port: 43123,
},
codex: {
enabled: true,
@@ -272,8 +284,10 @@ server.listen(0, '127.0.0.1', () => {
});
expect(result.status).toBe(0);
expect(result.stderr).toContain('Launching without browser tools');
expect(result.stderr).toContain('ccs browser doctor');
expect(result.stderr).toContain('CCS created the managed browser profile');
expect(result.stderr).toContain('Start Chrome with remote debugging');
expect(result.stderr).toContain('continue without browser tools');
expect(fs.existsSync(path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
@@ -291,6 +305,47 @@ server.listen(0, '127.0.0.1', () => {
}
});
it('skips managed browser attach when the managed profile exists but no browser session is running', () => {
if (process.platform === 'win32') return;
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpHome;
try {
const unreachablePort = reserveClosedPort();
const managedProfileDir = path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data');
fs.mkdirSync(managedProfileDir, { recursive: true });
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: true,
user_data_dir: '',
devtools_port: unreachablePort,
},
codex: {
enabled: true,
},
};
});
const result = runCcs(['default', 'smoke'], {
...baseEnv,
});
expect(result.status).toBe(0);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
} finally {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
it('uses config-backed browser attach settings when env overrides are absent', async () => {
if (process.platform === 'win32') return;
@@ -28,6 +28,18 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
};
}
function reserveClosedPort(): number {
const server = Bun.serve({
port: 0,
fetch() {
return new Response('ok');
},
});
const { port } = server;
server.stop(true);
return port;
}
describe('settings profile browser launch', () => {
let tmpHome = '';
let ccsDir = '';
@@ -210,7 +222,7 @@ server.listen(0, '127.0.0.1', () => {
claude: {
enabled: true,
user_data_dir: '',
devtools_port: 9222,
devtools_port: 43123,
},
codex: {
enabled: true,
@@ -223,8 +235,10 @@ server.listen(0, '127.0.0.1', () => {
});
expect(result.status).toBe(0);
expect(result.stderr).toContain('Launching without browser tools');
expect(result.stderr).toContain('ccs browser doctor');
expect(result.stderr).toContain('CCS created the managed browser profile');
expect(result.stderr).toContain('Start Chrome with remote debugging');
expect(result.stderr).toContain('continue without browser tools');
expect(fs.existsSync(path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
@@ -242,6 +256,48 @@ server.listen(0, '127.0.0.1', () => {
}
});
it('skips managed browser attach for settings-profile launches when no managed browser session is running', () => {
if (process.platform === 'win32') return;
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpHome;
try {
const unreachablePort = reserveClosedPort();
fs.mkdirSync(path.join(tmpHome, '.ccs', 'browser', 'chrome-user-data'), {
recursive: true,
});
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: true,
user_data_dir: '',
devtools_port: unreachablePort,
},
codex: {
enabled: true,
},
};
});
const result = runCcs(['glm', 'smoke'], {
...baseEnv,
});
expect(result.status).toBe(0);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
} finally {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
it('uses config-backed browser attach settings for settings-profile launches', async () => {
if (process.platform === 'win32') return;
@@ -1,10 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mutateUnifiedConfig } from '../../../../src/config/unified-config-loader';
import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse';
import { getBrowserStatus } from '../../../../src/utils/browser/browser-status';
import {
getBrowserStatus,
} from '../../../../src/utils/browser/browser-status';
import { resolveOptionalBrowserAttachRuntime } from '../../../../src/utils/browser/browser-settings';
import * as codexDetector from '../../../../src/targets/codex-detector';
describe('browser status', () => {
@@ -86,6 +89,48 @@ describe('browser status', () => {
}
});
it('bootstraps the managed default browser profile dir before reporting attach readiness', async () => {
mutateUnifiedConfig((config) => {
config.browser = {
claude: {
enabled: true,
user_data_dir: '',
devtools_port: 9222,
},
codex: {
enabled: true,
},
};
});
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue(
new Error(
`Chrome reuse metadata not found: ${join(tempHome, '.ccs', 'browser', 'chrome-user-data', 'DevToolsActivePort')}`
)
);
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
path: '/usr/local/bin/codex',
needsShell: false,
version: 'codex-cli 0.120.0',
features: ['config-overrides'],
});
try {
const status = await getBrowserStatus();
expect(status.claude.state).toBe('browser_not_running');
expect(status.claude.title).toBe(
'Claude Browser Attach is waiting for a managed Chrome session.'
);
expect(status.claude.detail).toContain('CCS created the managed browser profile');
expect(status.claude.nextStep).toContain('--remote-debugging-port=9222');
expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
} finally {
runtimeSpy.mockRestore();
codexSpy.mockRestore();
}
});
it('prefers CCS_BROWSER_USER_DATA_DIR over config when an env override is present', async () => {
mutateUnifiedConfig((config) => {
config.browser = {
@@ -169,6 +214,26 @@ describe('browser status', () => {
}
});
it('returns a managed attach warning when the configured DevTools port is unreachable', async () => {
const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data');
mkdirSync(managedDir, { recursive: true });
const runtime = await resolveOptionalBrowserAttachRuntime({
enabled: true,
source: 'config',
overrideActive: false,
userDataDir: managedDir,
devtoolsPort: 43123,
hasExplicitDevtoolsPort: true,
});
expect(runtime.runtimeEnv).toBeUndefined();
expect(runtime.warning).toContain(
'could not reach the attach-mode DevTools endpoint for the managed browser profile'
);
expect(runtime.warning).toContain('continue without browser tools');
});
it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => {
process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser';
@@ -266,6 +266,7 @@ describe('CLAUDECODE environment stripping', () => {
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
expect(spawnCalls[0].options?.shell).toBe('cmd.exe');
});
it('execClaude sets DISABLE_AUTOUPDATER=1 when preferences.auto_update is false', () => {
+50
View File
@@ -81,6 +81,56 @@ describe('escapeShellArg', () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('hello!')).toBe('"hello^^!"');
});
it('prefers ComSpec when resolving the escaped command shell', async () => {
const originalComSpec = process.env.ComSpec;
const originalCOMSPEC = process.env.COMSPEC;
try {
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe';
delete process.env.COMSPEC;
const { getWindowsEscapedCommandShell } = await import(
'../../../src/utils/shell-executor'
);
expect(getWindowsEscapedCommandShell()).toBe('C:\\Windows\\System32\\cmd.exe');
} finally {
if (originalComSpec === undefined) delete process.env.ComSpec;
else process.env.ComSpec = originalComSpec;
if (originalCOMSPEC === undefined) delete process.env.COMSPEC;
else process.env.COMSPEC = originalCOMSPEC;
}
});
it('falls back to cmd.exe when ComSpec is unavailable', async () => {
const originalComSpec = process.env.ComSpec;
const originalCOMSPEC = process.env.COMSPEC;
try {
delete process.env.ComSpec;
delete process.env.COMSPEC;
const { getWindowsEscapedCommandShell } = await import(
'../../../src/utils/shell-executor'
);
expect(getWindowsEscapedCommandShell()).toBe('cmd.exe');
} finally {
if (originalComSpec === undefined) delete process.env.ComSpec;
else process.env.ComSpec = originalComSpec;
if (originalCOMSPEC === undefined) delete process.env.COMSPEC;
else process.env.COMSPEC = originalCOMSPEC;
}
});
});
});
describe('getWindowsEscapedCommandShell', () => {
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('returns shell=true outside Windows if called defensively', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
const { getWindowsEscapedCommandShell } = await import('../../../src/utils/shell-executor');
expect(getWindowsEscapedCommandShell()).toBe(true);
});
});
+4 -1
View File
@@ -1,6 +1,6 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import { mkdtempSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import type { Server } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -184,6 +184,9 @@ describe('browser routes', () => {
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9333,
});
expect(payload.browser.status.claude.state).toBe('browser_not_running');
expect(payload.browser.status.claude.detail).toContain('CCS created the managed browser profile');
expect(existsSync(join(tempHome, '.ccs', 'browser', 'chrome-user-data'))).toBe(true);
const config = loadOrCreateUnifiedConfig();
expect(config.browser).toMatchObject({