feat(cursor): split legacy bridge from cliproxy provider

This commit is contained in:
Tam Nhu Tran
2026-04-15 17:04:37 -04:00
parent 1bad3b0305
commit 3b5941c60b
36 changed files with 974 additions and 155 deletions
+32 -18
View File
@@ -1,17 +1,20 @@
# Cursor IDE Integration # Cursor IDE Integration
This guide covers the current CCS-owned Cursor runtime, including auth import, local daemon lifecycle, live probe checks, and dashboard controls. This guide covers the deprecated CCS-owned Cursor IDE bridge, including auth import, local daemon lifecycle, live probe checks, and dashboard controls.
`ccs cursor` now belongs to the CLIProxy-backed Cursor provider path.
Use `ccs legacy cursor` for the deprecated local bridge documented here.
## What It Provides ## What It Provides
- OpenAI-compatible local endpoint powered by Cursor credentials. - OpenAI-compatible local endpoint powered by Cursor credentials.
- Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients. - Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients.
- Cursor model list and chat completions via the local CCS daemon. - Cursor model list and chat completions via the local CCS daemon.
- Dedicated dashboard page: `ccs config` -> `Cursor IDE`. - Dedicated dashboard page: `ccs config` -> `Deprecated` -> `Cursor IDE (Legacy)`.
## What This Runtime Actually Does ## What This Runtime Actually Does
`ccs cursor` does not launch Cursor IDE itself. `ccs legacy cursor` does not launch Cursor IDE itself.
The current workflow is: The current workflow is:
1. import Cursor credentials from local SQLite or manual input 1. import Cursor credentials from local SQLite or manual input
@@ -32,7 +35,7 @@ Treat this as a CCS-managed Cursor bridge, not a generic CLIProxy-backed provide
### 1) Enable integration ### 1) Enable integration
```bash ```bash
ccs cursor enable ccs legacy cursor enable
``` ```
### 2) Import credentials ### 2) Import credentials
@@ -40,25 +43,25 @@ ccs cursor enable
Auto-detect from Cursor local SQLite state: Auto-detect from Cursor local SQLite state:
```bash ```bash
ccs cursor auth ccs legacy cursor auth
``` ```
Manual fallback: Manual fallback:
```bash ```bash
ccs cursor auth --manual --token <token> --machine-id <machine-id> ccs legacy cursor auth --manual --token <token> --machine-id <machine-id>
``` ```
### 3) Start daemon ### 3) Start daemon
```bash ```bash
ccs cursor start ccs legacy cursor start
``` ```
### 4) Run a live probe ### 4) Run a live probe
```bash ```bash
ccs cursor probe ccs legacy cursor probe
``` ```
Use this to verify that the current build can complete one real authenticated request through the local daemon. Use this to verify that the current build can complete one real authenticated request through the local daemon.
@@ -66,26 +69,37 @@ Use this to verify that the current build can complete one real authenticated re
### 5) Run Cursor-backed Claude ### 5) Run Cursor-backed Claude
```bash ```bash
ccs cursor "explain this repo" ccs legacy cursor "explain this repo"
``` ```
### 6) Verify status ### 6) Verify status
```bash ```bash
ccs cursor status ccs legacy cursor status
``` ```
Use `ccs cursor` with bare or normal Claude args to run through the local Cursor proxy. Use `ccs legacy cursor` with bare or normal Claude args to run through the local Cursor proxy.
The admin namespace remains available for setup and inspection: The admin namespace remains available for setup and inspection:
```bash ```bash
ccs cursor help ccs legacy cursor help
``` ```
### 7) Stop daemon ### 7) Stop daemon
```bash ```bash
ccs cursor stop ccs legacy cursor stop
```
## Supported Cursor Provider Path
For the supported CLIProxy-backed Cursor provider, use:
```bash
ccs cursor --auth
ccs cursor --accounts
ccs cursor --config
ccs cursor "task"
``` ```
## Runtime Defaults ## Runtime Defaults
@@ -96,7 +110,7 @@ ccs cursor stop
- Model list resolution: authenticated live fetch when available, with cached/default fallback. - Model list resolution: authenticated live fetch when available, with cached/default fallback.
- Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model. - Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model.
- Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`. - Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`.
- Live verification: `ccs cursor probe` or `POST /api/cursor/probe` - Live verification: `ccs legacy cursor probe` or `POST /api/cursor/probe`
These values are managed in unified config and can be updated from CLI or dashboard. These values are managed in unified config and can be updated from CLI or dashboard.
@@ -108,7 +122,7 @@ Open dashboard:
ccs config ccs config
``` ```
Then navigate to `Cursor IDE` in the sidebar. Then navigate to `Cursor IDE (Legacy)` in the `Deprecated` section.
Available controls: Available controls:
@@ -131,9 +145,9 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr
### `Not authenticated` or `expired` in `ccs cursor status` ### `Not authenticated` or `expired` in `ccs cursor status`
- Re-run `ccs cursor auth` (or manual auth command). - Re-run `ccs legacy cursor auth` (or manual auth command).
### `ccs cursor probe` fails even though status is green ### `ccs legacy cursor probe` fails even though status is green
- `status` proves local config/auth/daemon readiness only. - `status` proves local config/auth/daemon readiness only.
- `probe` proves the live runtime path. - `probe` proves the live runtime path.
@@ -148,4 +162,4 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr
### Daemon fails to start ### Daemon fails to start
- Check if port `20129` is in use. - Check if port `20129` is in use.
- Change port in dashboard config tab, then retry `ccs cursor start`. - Change port in dashboard config tab, then retry `ccs legacy cursor start`.
@@ -0,0 +1,127 @@
---
phase: 1
title: "CLI Routing & Namespacing"
status: complete
effort: "6h"
---
# Phase 1: CLI Routing & Namespacing
## Context Links
- `plan.md`
- `src/ccs.ts`
- `src/auth/profile-detector.ts`
- `src/cursor/constants.ts`
- `src/commands/root-command-router.ts`
- `src/commands/command-catalog.ts`
- `src/commands/help-command.ts`
- `src/commands/cursor-command.ts`
- `src/commands/cursor-command-display.ts`
- `src/types/profile.ts`
- `src/config/reserved-names.ts`
## Overview
- Priority: P1
- Owner scope: CLI entry, help, profile detection, command naming
- Goal: make `cursor` provider-first and move the deprecated bridge under `legacy cursor`
## Key Insights
- The current collision is structural, not cosmetic. `ccs cursor` means "legacy bridge" in `src/ccs.ts` and `src/auth/profile-detector.ts`, but `cursor` is also listed as a built-in CLIProxy provider.
- `shouldUseCursorCliproxyShortcut()` is only a heuristic escape hatch. It does not fix bare `ccs cursor`, quoted prompts, or help routing.
- Help is currently inconsistent: provider help exists generically, but `cursor` is excluded and routed to bridge help instead.
## Requirements
- Reserve `cursor` for CLIProxy runtime and CLIProxy admin flags.
- Introduce explicit legacy syntax: `ccs legacy cursor ...`.
- Keep a release-N alias for old legacy admin subcommands only.
- Rename internal bridge-only profile typing from ambiguous `cursor` to explicit `legacy-cursor`.
- Keep file ownership isolated to CLI/router/help files in this phase.
## Data Flow
- Provider path:
`argv -> root command resolution -> provider shortcut/help path -> ProfileDetector(type=cliproxy, provider=cursor) -> CLIProxy runtime`
- Legacy path:
`argv -> legacy root command -> legacy cursor subrouter -> ProfileDetector(type=legacy-cursor) or direct handler -> local bridge runtime`
- Deprecated alias path, release N only:
`argv=ccs cursor auth|status|... -> alias shim -> warning -> dispatch to legacy cursor handler`
## Architecture
- Add a new root command namespace: `ccs legacy`.
- Add nested routing under `legacy` with `cursor` as the first migrated leaf. Do not overload `cursor` itself any longer.
- Remove provider exceptions for `cursor` from the generic provider help/routing logic. `ccs cursor --help` should now use provider shortcut help.
- Convert bridge-only type checks from `profileInfo.type === 'cursor'` to `profileInfo.type === 'legacy-cursor'`.
- Keep `ccs cursor help` only as a release-N compatibility shim that prints:
- `Use "ccs cursor --help" for CLIProxy Cursor`
- `Use "ccs legacy cursor help" for the deprecated bridge`
## Related Code Files
- Modify:
- `src/ccs.ts`
- `src/auth/profile-detector.ts`
- `src/cursor/constants.ts`
- `src/commands/root-command-router.ts`
- `src/commands/command-catalog.ts`
- `src/commands/help-command.ts`
- `src/commands/cursor-command.ts`
- `src/commands/cursor-command-display.ts`
- `src/types/profile.ts`
- `src/config/reserved-names.ts`
- `src/shared/claude-extension-setup.ts`
- `src/targets/target-runtime-compatibility.ts`
- Create:
- `src/commands/legacy-command.ts` or `src/commands/legacy/index.ts`
- `src/commands/legacy/cursor-command.ts` if the team wants physical separation immediately
## Implementation Steps
1. Add the `legacy` root command route and its help surface.
2. Flip `src/ccs.ts` so `cursor` goes through normal CLIProxy provider routing; remove the special-case that gives the bridge ownership of the name.
3. Replace the `shouldUseCursorCliproxyShortcut()` hack with provider-first dispatch plus a compatibility alias table for the old legacy subcommands.
4. Update `ProfileDetector` priority order so `cursor` resolves as `cliproxy`, while `legacy cursor` resolves as `legacy-cursor`.
5. Rename bridge-only help text, summaries, and status text to say "legacy Cursor bridge" explicitly.
6. Audit all `profileType === 'cursor'` checks and convert only the bridge-specific ones to `legacy-cursor`.
## Todo List
- [x] Add `legacy cursor` routing
- [x] Make `ccs cursor` provider-first for bare, prompt, and `--help` usage
- [x] Add deprecated alias forwarding for old admin subcommands
- [x] Rename internal bridge profile path to `legacy-cursor`
- [x] Update provider help, completion, and command catalog summaries
## Success Criteria
- `ccs cursor "task"` resolves to CLIProxy Cursor.
- `ccs legacy cursor "task"` resolves to the old bridge.
- `ccs cursor --help` shows provider shortcut help.
- `ccs cursor auth` still works in release N, but prints an exact replacement warning.
- No CLI path depends on `shouldUseCursorCliproxyShortcut()` to disambiguate runtime meaning.
## Risk Assessment
- High likelihood / high impact: users with scripts calling `ccs cursor "task"` will hit the provider path immediately.
Mitigation: call this out in release notes, keep admin aliases, add explicit warning when legacy files/config are detected and the user invokes `ccs cursor` with no flags.
- Medium likelihood / medium impact: bridge-only type renames may break target compatibility checks or extension setup.
Mitigation: grep audit every `profileType === 'cursor'` branch before tests.
## Rollback Plan
- Re-enable the old `cursor` special-case in `src/ccs.ts` and `ProfileDetector`.
- Keep the new `legacy` namespace in place even if dormant; it is additive and safe to leave.
- Do not roll back migrated files in this phase; routing rollback alone is enough.
## Security Considerations
- No auth material moves in this phase.
- Preserve existing `CCS_HOME`-aware path resolution. Do not introduce `os.homedir()` shortcuts while adding the new namespace.
## Next Steps
- Phase 2 depends on the new command contract from this phase.
@@ -0,0 +1,140 @@
---
phase: 2
title: "Storage & API Boundaries"
status: partial
effort: "6h"
---
# Phase 2: Storage & API Boundaries
## Context Links
- `plan.md`
- `src/config/unified-config-types.ts`
- `src/config/unified-config-loader.ts`
- `src/cursor/cursor-auth.ts`
- `src/cursor/cursor-daemon-pid.ts`
- `src/cliproxy/config/path-resolver.ts`
- `src/cliproxy/config/env-builder.ts`
- `src/web-server/routes/index.ts`
- `src/web-server/routes/cursor-routes.ts`
- `src/web-server/routes/cursor-settings-routes.ts`
- `src/web-server/routes/cliproxy-stats-routes.ts`
- `src/api/services/profile-lifecycle-service.ts`
## Overview
- Priority: P1
- Owner scope: config schema, path resolution, backend APIs, migration readers
- Goal: make legacy bridge storage explicit and guarantee CLIProxy Cursor never writes the legacy raw settings file
## Key Insights
- Top-level `config.cursor` is bridge-only configuration today and must move.
- The legacy bridge owns `~/.ccs/cursor.settings.json`, `~/.ccs/cursor/credentials.json`, and `~/.ccs/cursor/daemon.pid`.
- CLIProxy provider settings currently resolve through generic provider settings helpers and can still collide with the legacy file for provider `cursor`.
- `~/.ccs/cursor.settings.json` is historically documented as legacy-owned, so it is unsafe to auto-import it into provider storage by default.
## Requirements
- Canonical legacy config key: `legacy.cursor`
- Canonical legacy files:
- `~/.ccs/legacy/cursor.settings.json`
- `~/.ccs/legacy/cursor/credentials.json`
- `~/.ccs/legacy/cursor/daemon.pid`
- Canonical provider file for CLIProxy Cursor only:
- `~/.ccs/cliproxy/cursor.settings.json`
- Canonical legacy API namespace:
- `/api/legacy/cursor/*`
- Compatibility reads:
- read old `config.cursor`
- read old `~/.ccs/cursor.settings.json`
- read old `~/.ccs/cursor/*`
- Compatibility writes:
- write only the new `legacy.*` and `cliproxy/*` paths
## Data Flow
- Legacy config:
`load config -> prefer legacy.cursor -> fallback config.cursor -> normalize -> write legacy.cursor only`
- Legacy raw settings:
`load /api/legacy/cursor/settings/raw -> prefer ~/.ccs/legacy/cursor.settings.json -> fallback ~/.ccs/cursor.settings.json -> write new legacy path`
- Provider settings:
`CLIProxy env builder/stats updater -> read ~/.ccs/cliproxy/cursor.settings.json -> if absent use defaults -> never read/write ~/.ccs/cursor.settings.json`
## Architecture
- Add a `legacy` section to unified config types and loader. Keep old `cursor` as read-only migration input during the compatibility window.
- Move legacy bridge filesystem helpers under a `legacy/cursor` path prefix.
- Split API routing:
- new canonical mount: `/api/legacy/cursor`
- release-N alias: `/api/cursor` -> same handlers + deprecation header
- Special-case CLIProxy provider settings for `cursor` only in the provider path resolver. Do not expand this migration to every provider in this issue.
- Treat existing `~/.ccs/cursor.settings.json` as legacy-owned. Do not auto-copy it into provider storage unless a future explicit provider migration is added.
## Related Code Files
- Modify:
- `src/config/unified-config-types.ts`
- `src/config/unified-config-loader.ts`
- `src/cursor/cursor-auth.ts`
- `src/cursor/cursor-daemon-pid.ts`
- `src/cliproxy/config/path-resolver.ts`
- `src/cliproxy/config/env-builder.ts`
- `src/web-server/routes/index.ts`
- `src/web-server/routes/cursor-routes.ts`
- `src/web-server/routes/cursor-settings-routes.ts`
- `src/web-server/routes/cliproxy-stats-routes.ts`
- `src/api/services/profile-lifecycle-service.ts`
- Create:
- `src/web-server/routes/legacy-cursor-routes.ts`
- `src/web-server/routes/legacy-cursor-settings-routes.ts`
- `src/config/migrations/cursor-legacy-migration.ts` if migration logic should stay out of the loader
## Implementation Steps
1. Extend config types and loader to support `legacy.cursor`, with `legacy.cursor` taking precedence over old `cursor`.
2. Update legacy bridge credential and pid helpers to use `~/.ccs/legacy/cursor/`.
3. Update the raw settings route to use `~/.ccs/legacy/cursor.settings.json` as canonical and old root path as read fallback only.
4. Move legacy API mounts to `/api/legacy/cursor/*` and keep `/api/cursor/*` as a warned alias for release N.
5. Change CLIProxy Cursor provider settings resolution to `~/.ccs/cliproxy/cursor.settings.json`.
6. Update orphan detection and cleanup logic so old `cursor.settings.json` is treated as a migration target, not a permanent provider-owned file.
## Todo List
- [ ] Add `legacy.cursor` config schema and loader precedence
- [ ] Move bridge credentials/pid/raw settings under `~/.ccs/legacy/`
- [x] Add canonical `/api/legacy/cursor/*` routes
- [x] Keep release-N `/api/cursor/*` alias
- [x] Isolate CLIProxy Cursor settings away from `~/.ccs/cursor.settings.json`
- [ ] Update cleanup/orphan handling
## Success Criteria
- Saving legacy bridge settings writes only to `legacy.cursor` and `~/.ccs/legacy/*`.
- CLIProxy Cursor model/env updates write only to `~/.ccs/cliproxy/cursor.settings.json`.
- Existing legacy users can still read old config/files during the compatibility window.
- No backend route that serves the provider path references `~/.ccs/cursor.settings.json`.
## Risk Assessment
- High likelihood / high impact: old `~/.ccs/cursor.settings.json` contents are ambiguous between bridge and provider expectations.
Mitigation: treat the file as legacy-owned and do not auto-import it into provider storage.
- Medium likelihood / medium impact: route aliasing may mask which API is canonical.
Mitigation: add explicit response headers or payload flags marking `/api/cursor/*` as deprecated.
## Rollback Plan
- Keep read fallback from old paths even if the canonical write path changes back.
- If the new legacy API namespace causes regressions, remount `/api/cursor/*` as canonical temporarily and keep the new namespace dormant.
- Do not delete old files during release N; cleanup stays opt-in until release N+2.
## Security Considerations
- Preserve `0600` for migrated credentials and `0700` for directories.
- Use atomic temp-file writes exactly as current routes do.
- Never copy provider tokens into the legacy namespace or legacy tokens into provider storage automatically.
## Next Steps
- Phase 3 depends on the canonical API and path names from this phase.
@@ -0,0 +1,121 @@
---
phase: 3
title: "Dashboard & Deprecation UX"
status: partial
effort: "4h"
---
# Phase 3: Dashboard & Deprecation UX
## Context Links
- `plan.md`
- `ui/src/App.tsx`
- `ui/src/components/layout/app-sidebar.tsx`
- `ui/src/pages/cursor.tsx`
- `ui/src/hooks/use-cursor.ts`
- `ui/src/lib/i18n.ts`
- `src/web-server/routes/index.ts`
- `src/commands/cursor-command-display.ts`
## Overview
- Priority: P1
- Owner scope: dashboard route ownership, labels, user-facing deprecation messaging
- Goal: align dashboard semantics with CLI semantics so `/cursor` means provider and legacy UI is clearly marked and isolated
## Key Insights
- The current dashboard already admits the bridge is deprecated, but the route `/cursor` still belongs to it.
- The page includes direct navigation to CLIProxy Cursor, which means the UX already wants a split; the route layer just has not caught up.
- Keeping `/cursor` for legacy while CLI uses `cursor` for provider would create the same ambiguity in a different surface.
## Requirements
- `/cursor` must become the provider-owned dashboard surface.
- The legacy bridge page must move to `/legacy/cursor`.
- Legacy bridge API hooks must move to `/api/legacy/cursor/*`.
- The deprecated UX must contain exact replacements, not generic warnings.
- Sidebar grouping must reflect support level:
- provider view under provider/cliproxy navigation
- legacy bridge under deprecated navigation
## Data Flow
- Provider dashboard:
`browser /cursor -> provider view or redirect wrapper -> /cliproxy?provider=cursor -> existing CLIProxy provider APIs`
- Legacy dashboard:
`browser /legacy/cursor -> legacy bridge page -> useLegacyCursor hook -> /api/legacy/cursor/*`
- Compatibility API path, release N only:
`old UI/tests -> /api/cursor/* -> alias handler -> same legacy payload + deprecation signal`
## Architecture
- Keep provider UI DRY by making `/cursor` a thin redirect or preselected wrapper around the existing CLIProxy provider page instead of building a second Cursor-provider page.
- Move the current `ui/src/pages/cursor.tsx` implementation to a new `legacy-cursor` page and rename its hook to `useLegacyCursor`.
- Change nav labels from generic "Cursor IDE" to explicit "Cursor Bridge (Legacy)" in the deprecated section.
- Update CLI and dashboard warnings to show both paths side-by-side:
- `ccs cursor --auth` / `/cursor`
- `ccs legacy cursor auth` / `/legacy/cursor`
## Related Code Files
- Modify:
- `ui/src/App.tsx`
- `ui/src/components/layout/app-sidebar.tsx`
- `ui/src/lib/i18n.ts`
- `src/commands/cursor-command-display.ts`
- Move or rename:
- `ui/src/pages/cursor.tsx` -> `ui/src/pages/legacy-cursor.tsx`
- `ui/src/hooks/use-cursor.ts` -> `ui/src/hooks/use-legacy-cursor.ts`
- Create:
- `ui/src/pages/cursor-provider-redirect.tsx` if a wrapper is preferred over direct router config
## Implementation Steps
1. Move the legacy page and hook to `legacy-*` names and update all imports.
2. Reassign `/cursor` to the provider path and add `/legacy/cursor` for the bridge page.
3. Update sidebar grouping and labels so the provider path is no longer listed under Deprecated.
4. Replace vague deprecated copy with concrete migration copy:
- old command
- new command
- old route
- new route
5. Keep the legacy page banner persistent until release N+2, not dismissible per session.
## Todo List
- [ ] Move legacy page/hook module names to `legacy-*`
- [x] Reassign `/cursor` and add `/legacy/cursor`
- [x] Update deprecated nav group and labels
- [x] Rewrite key banners, button copy, and path labels with exact replacements
- [x] Keep provider and legacy links visible from both surfaces during release N
## Success Criteria
- Opening `/cursor` lands on the CLIProxy Cursor provider surface.
- Opening `/legacy/cursor` lands on the bridge page with a persistent deprecation banner.
- No dashboard component serving the provider route uses the legacy API hook.
- Every warning banner shows the exact before/after command and route.
## Risk Assessment
- Medium likelihood / medium impact: users with bookmarked `/cursor` expect the legacy page.
Mitigation: provider page shows a top-level "Looking for the old bridge?" callout linking to `/legacy/cursor`.
- Low likelihood / medium impact: UI rename churn breaks lazy imports or tests.
Mitigation: do route and hook rename in one phase and leave compatibility API alias in place until tests pass.
## Rollback Plan
- Point `/cursor` back to the legacy page if the provider redirect breaks.
- Keep `/legacy/cursor` additive; it does not block rollback.
- Do not remove the deprecation banner on rollback; it still communicates future intent.
## Security Considerations
- No auth secrets should be exposed in UI copy or route params.
- Keep manual auth dialogs scoped to the legacy page only. Provider auth remains in CLIProxy flows.
## Next Steps
- Phase 4 owns test rewrites, docs updates, and release gating for these UI changes.
@@ -0,0 +1,148 @@
---
phase: 4
title: "Tests Docs & Rollout"
status: complete
effort: "4h"
---
# Phase 4: Tests Docs & Rollout
## Context Links
- `plan.md`
- `docs/cursor-integration.md`
- `README.md`
- `docs/system-architecture/provider-flows.md`
- `docs/system-architecture/index.md`
- `tests/unit/cursor/cursor-shortcut-routing.test.ts`
- `tests/unit/web-server/cursor-settings-routes.test.ts`
- `tests/unit/web-server/cursor-routes.test.ts`
- `ui/tests/unit/hooks/use-cursor.test.tsx`
- `ui/tests/unit/ui/pages/cursor-page.test.tsx`
## Overview
- Priority: P1
- Owner scope: compatibility rollout, validation, docs/help updates, release notes
- Goal: ship the namespace split without surprising existing bridge users or leaving docs/help inconsistent
## Key Insights
- This change has one intentional breaking behavior: positional `ccs cursor` stops being the legacy bridge.
- Everything else can use a compatibility window: admin subcommands, API aliases, old config reads, old file-path reads.
- Tests must lock both meanings so the ambiguity does not regress later.
## Requirements
- Document exact before/after commands and routes.
- Add a concrete migration path for three user groups:
- legacy bridge users
- CLIProxy Cursor users
- dashboard bookmark users
- Define removal windows for aliases and old path fallbacks.
- Run repo quality gates after implementation:
- root: `bun run format && bun run lint:fix && bun run validate && bun run validate:ci-parity`
- UI: `cd ui && bun run format && bun run lint:fix && bun run validate`
## Test Matrix
- Unit:
- provider-first cursor routing
- legacy alias forwarding
- `legacy.cursor` loader precedence
- path resolvers for legacy vs provider files
- deprecation help text snapshots
- Integration:
- `ccs cursor "task"` -> provider
- `ccs legacy cursor "task"` -> bridge
- `/api/legacy/cursor/*` canonical behavior
- `/api/cursor/*` alias behavior during release N
- UI:
- `/cursor` route ownership
- `/legacy/cursor` banner and actions
- hook path changes and raw settings save targets
- Manual release validation:
- migrate old config/files in a temp `CCS_HOME`
- verify provider path never writes `~/.ccs/cursor.settings.json`
## User Migration Plan
1. Legacy bridge users:
- replace `ccs cursor ...` with `ccs legacy cursor ...`
- run `ccs legacy cursor status`
- update scripts and dashboard bookmarks to `/legacy/cursor`
2. CLIProxy Cursor users:
- keep using `ccs cursor ...`
- if provider-specific settings are needed, re-save them under the new provider-owned path instead of relying on `~/.ccs/cursor.settings.json`
3. Mixed/unclear state:
- `ccs migrate` should move `config.cursor` and legacy files into the new legacy namespace
- do not auto-copy the old raw settings file into provider storage
## Deprecation UX Plan
- CLI warning text, release N:
- `ccs cursor auth` is deprecated. Use `ccs legacy cursor auth` for the old bridge or `ccs cursor --auth` for CLIProxy Cursor.
- Dashboard banner:
- visible on `/legacy/cursor`
- provider route links back to legacy route with "Looking for the old bridge?"
- Docs banner:
- top callout in `docs/cursor-integration.md` pointing users to CLIProxy Cursor as the supported path
## Related Code Files
- Modify tests:
- `tests/unit/cursor/cursor-shortcut-routing.test.ts`
- `tests/unit/web-server/cursor-settings-routes.test.ts`
- `tests/unit/web-server/cursor-routes.test.ts`
- `ui/tests/unit/hooks/use-cursor.test.tsx`
- `ui/tests/unit/ui/pages/cursor-page.test.tsx`
- Modify docs:
- `docs/cursor-integration.md`
- `README.md` if root command examples mention Cursor
- `docs/system-architecture/provider-flows.md`
- `docs/system-architecture/index.md`
- CLI help snapshots or generated references if present
## Implementation Steps
1. Rewrite tests around the new command contract and route ownership before removing aliases in later releases.
2. Update docs/help text in the same PR as code changes so the new syntax ships atomically.
3. Add migration notes to changelog/release notes with a bold callout that `ccs cursor "task"` now means CLIProxy Cursor.
4. Keep a removal checklist for release N+1 and N+2 in the plan or roadmap so the compatibility window does not become permanent.
## Todo List
- [x] Update unit, integration, and selected UI tests
- [x] Update docs and CLI help text
- [x] Add migration note and deprecation wording
- [x] Run root and UI quality gates
- [x] Record alias-removal follow-up for N+1 and old-path-removal follow-up for N+2
## Success Criteria
- Test suite covers both provider and legacy cursor paths explicitly.
- Docs and help text match the shipped command contract exactly.
- Release notes include the migration table and deprecation window.
- Quality gates pass in both root and `ui/`.
## Risk Assessment
- High likelihood / medium impact: docs or tests lag behind the command flip and users keep invoking the wrong surface.
Mitigation: block merge until help text, docs, and tests all match the new contract.
- Medium likelihood / medium impact: compatibility shims never get removed.
Mitigation: create follow-up issues or roadmap entries for N+1 and N+2 removal work before merge.
## Rollback Plan
- If rollout messaging is incomplete, revert the command flip before removing aliases.
- If only docs/help are wrong, fix docs first and keep aliases until corrected.
- Old-path readers stay in place through N+1, so rollback does not strand migrated users.
## Security Considerations
- Use temp `CCS_HOME` in tests and manual verification. Never touch the real `~/.ccs`.
- Sanitize any migration logs or warnings so they mention paths, not token contents.
## Next Steps
- Implementation is complete when all four phases land together; do not ship phase 1 without phases 2-4.
@@ -0,0 +1,93 @@
---
title: "Separate legacy Cursor bridge from CLIProxy Cursor provider"
description: "Reserve `cursor` for the CLIProxy provider, move the reverse-engineered bridge under `legacy`, and split storage/UI with a staged migration."
status: in_progress
priority: P1
effort: 2d
branch: kai/feat/1016-missing-provider-integration
tags: [cursor, cliproxy, migration, dashboard, deprecation]
created: 2026-04-15
blockedBy: []
blocks: []
---
# Separate legacy Cursor bridge from CLIProxy Cursor provider
## Goal
Make `cursor` mean one thing everywhere: the CLIProxy-backed provider. Move the deprecated local bridge to `legacy`, stop provider writes to `~/.ccs/cursor.settings.json`, and ship a low-risk migration window.
## Current Collision Points
- `src/ccs.ts` hardcodes `cursor` as a legacy command/profile, then reclaims only `--auth|--logout|--config|--accounts` for CLIProxy.
- `src/auth/profile-detector.ts` resolves `cursor` to the legacy runtime before CLIProxy provider detection.
- `src/commands/command-catalog.ts` and `src/commands/help-command.ts` advertise `cursor` as both bridge and provider.
- `src/config/unified-config-types.ts` + `src/config/unified-config-loader.ts` store bridge config under top-level `cursor`.
- `src/cliproxy/config/path-resolver.ts`, `src/cliproxy/config/env-builder.ts`, and `src/web-server/routes/cliproxy-stats-routes.ts` still use provider settings paths that collide with the legacy raw file.
- `src/web-server/routes/cursor-*.ts`, `ui/src/pages/cursor.tsx`, `ui/src/hooks/use-cursor.ts`, `ui/src/App.tsx`, and `ui/src/components/layout/app-sidebar.tsx` dedicate `/cursor` and `/api/cursor/*` to the legacy bridge.
- `docs/cursor-integration.md` documents `ccs cursor` as the bridge even though CLIProxy already exposes a `cursor` provider shortcut.
## Command Contract
Before:
```text
ccs cursor -> legacy bridge runtime
ccs cursor "task" -> legacy bridge runtime
ccs cursor auth|status|... -> legacy bridge admin
ccs cursor --auth|--config -> CLIProxy Cursor shortcut
```
After release N:
```text
ccs cursor -> CLIProxy Cursor runtime
ccs cursor "task" -> CLIProxy Cursor runtime
ccs cursor --auth|--config -> CLIProxy Cursor admin
ccs legacy cursor -> legacy bridge runtime
ccs legacy cursor "task" -> legacy bridge runtime
ccs legacy cursor auth|... -> legacy bridge admin
```
Compatibility window, release N only:
- `ccs cursor auth|status|probe|models|start|stop|enable|disable|help` forwards to `ccs legacy cursor ...` with a deprecation warning.
- Bare and positional `ccs cursor` switch immediately to the provider path; no silent legacy fallback.
## Phase Plan
| Phase | Scope | Output |
| --- | --- | --- |
| 1 | [CLI Routing & Namespacing](./phase-01-cli-routing-namespacing.md) | Provider-first `cursor`, explicit `legacy cursor`, updated help/catalog/type names |
| 2 | [Storage & API Boundaries](./phase-02-storage-api-boundaries.md) | `legacy.cursor` config, split file paths, `/api/legacy/cursor/*`, provider path isolation |
| 3 | [Dashboard & Deprecation UX](./phase-03-dashboard-deprecation-ux.md) | `/cursor` -> provider view, `/legacy/cursor` -> bridge view, clear migration UX |
| 4 | [Tests Docs & Rollout](./phase-04-tests-docs-rollout.md) | Compatibility plan, migration steps, test matrix, docs updates, rollback gates |
## Rollout Sequence
1. Release N: add new legacy namespace, flip `ccs cursor` to provider, keep old admin subcommands and `/api/cursor/*` as warned aliases, and split provider settings away from `~/.ccs/cursor.settings.json`.
2. Release N+1: move the remaining legacy backend/config namespaces fully under `legacy.cursor`, keep old file-path fallback and `/api/cursor/*` alias for one more release.
3. Release N+2: remove old `config.cursor` and root-level `~/.ccs/cursor*` fallback reads, delete stale alias docs/help, and let cleanup/migrate remove leftovers.
## Current Implementation Status
- Completed in this branch:
- `ccs cursor` is provider-first for runtime and `--help`
- `ccs legacy cursor` works as the explicit legacy bridge namespace
- old legacy admin subcommands under `ccs cursor ...` forward with deprecation warnings
- CLIProxy Cursor settings no longer collide with `~/.ccs/cursor.settings.json`
- `/cursor` redirects to the provider surface while `/legacy/cursor` serves the deprecated bridge page
- `/api/legacy/cursor/*` is mounted and the legacy page uses that namespace
- docs, completion, and core regression tests were updated
- Intentionally deferred follow-up:
- move top-level `config.cursor` to `legacy.cursor`
- move legacy credentials/pid/raw settings fully under `~/.ccs/legacy/cursor/*`
- rename `use-cursor` and `CursorPage` modules to explicit `legacy-*`
## Success Criteria
- `cursor` is provider-owned in CLI help, routing, dashboard nav, and docs.
- Legacy bridge is reachable only through `legacy cursor` and `legacy.cursor` storage.
- CLIProxy Cursor never reads or writes `~/.ccs/cursor.settings.json`.
- Existing legacy users have an explicit migration path, warning UX, and rollback-safe compatibility window.
## Docs Impact
Major. CLI reference, Cursor docs, dashboard tour, provider docs, and migration notes all change in the same release.
+8 -10
View File
@@ -26,6 +26,7 @@ import { getCcsDir } from '../utils/config-manager';
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
import type { CLIProxyProvider } from '../cliproxy/types'; import type { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
import type { TargetType } from '../targets/target-adapter'; import type { TargetType } from '../targets/target-adapter';
import type { ProfileType } from '../types/profile'; import type { ProfileType } from '../types/profile';
@@ -296,20 +297,17 @@ class ProfileDetector {
}; };
} }
// Priority 0.25: Check Cursor profile - local Cursor daemon runtime. // Priority 0.25: Check explicit legacy Cursor bridge profile.
// This keeps bare `ccs cursor` and `ccs cursor <subcommand>` bound to the if (profileName === LEGACY_CURSOR_PROFILE_NAME) {
// existing runtime/admin surface even though CLIProxy also exposes a
// distinct provider named "cursor".
if (profileName === 'cursor') {
const cursorConfig = getCursorConfig(); const cursorConfig = getCursorConfig();
if (!cursorConfig?.enabled) { if (!cursorConfig?.enabled) {
const error = new Error( const error = new Error(
'Cursor profile is not enabled.\n\n' + 'Legacy Cursor profile is not enabled.\n\n' +
'To enable Cursor integration:\n' + 'To enable Cursor integration:\n' +
' 1. Run: ccs cursor enable\n' + ' 1. Run: ccs legacy cursor enable\n' +
' 2. Import auth: ccs cursor auth\n' + ' 2. Import auth: ccs legacy cursor auth\n' +
' 3. Start daemon: ccs cursor start\n\n' + ' 3. Start daemon: ccs legacy cursor start\n\n' +
'Or manually edit ~/.ccs/config.yaml:\n' + 'Or manually edit ~/.ccs/config.yaml:\n' +
' cursor:\n' + ' cursor:\n' +
' enabled: true' ' enabled: true'
@@ -322,7 +320,7 @@ class ProfileDetector {
return { return {
type: 'cursor', type: 'cursor',
name: 'cursor', name: LEGACY_CURSOR_PROFILE_NAME,
cursorConfig, cursorConfig,
}; };
} }
+41 -17
View File
@@ -65,7 +65,7 @@ import {
resolveOfficialChannelsLaunchPlan, resolveOfficialChannelsLaunchPlan,
} from './channels/official-channels-runtime'; } from './channels/official-channels-runtime';
import { getOfficialChannelReadiness } from './channels/official-channels-store'; import { getOfficialChannelReadiness } from './channels/official-channels-store';
import { isCursorSubcommandToken, shouldUseCursorCliproxyShortcut } from './cursor/constants'; import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants';
import { isCLIProxyProvider } from './cliproxy/provider-capabilities'; import { isCLIProxyProvider } from './cliproxy/provider-capabilities';
// Import centralized error handling // Import centralized error handling
@@ -149,6 +149,26 @@ function detectProfile(args: string[]): DetectedProfile {
} }
} }
function normalizeLegacyCursorArgs(args: string[]): string[] {
if (args[0] === 'legacy' && args[1] === 'cursor') {
return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)];
}
return args;
}
function printCursorLegacySubcommandDeprecation(subcommand: string): void {
console.error(
info(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`)
);
console.error(
info(
`Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.`
)
);
console.error('');
}
function resolveRuntimeReasoningFlags( function resolveRuntimeReasoningFlags(
args: string[], args: string[],
envThinkingValue: string | undefined envThinkingValue: string | undefined
@@ -343,7 +363,7 @@ async function main(): Promise<void> {
registerTarget(new CodexAdapter()); registerTarget(new CodexAdapter());
const cliLogger = createLogger('cli'); const cliLogger = createLogger('cli');
const args = process.argv.slice(2); let args = process.argv.slice(2);
const isCompletionCommand = args[0] === '__complete'; const isCompletionCommand = args[0] === '__complete';
// Initialize UI colors early to ensure consistent colored output // Initialize UI colors early to ensure consistent colored output
@@ -419,6 +439,8 @@ async function main(): Promise<void> {
return; return;
} }
args = normalizeLegacyCursorArgs(args);
cliLogger.info('command.start', 'CLI invocation started', { cliLogger.info('command.start', 'CLI invocation started', {
command: args[0] || 'default', command: args[0] || 'default',
argCount: args.length, argCount: args.length,
@@ -489,7 +511,6 @@ async function main(): Promise<void> {
if ( if (
typeof firstArg === 'string' && typeof firstArg === 'string' &&
isCLIProxyProvider(firstArg) && isCLIProxyProvider(firstArg) &&
firstArg !== 'cursor' &&
args.length > 1 && args.length > 1 &&
(args.includes('--help') || args.includes('-h')) (args.includes('--help') || args.includes('-h'))
) { ) {
@@ -511,9 +532,8 @@ async function main(): Promise<void> {
} }
} }
// Special case: cursor command (Cursor local proxy integration) // Special case: explicit legacy Cursor bridge namespace.
// Route known admin subcommands to the command handler, keep all other args as profile passthrough. if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) {
if (firstArg === 'cursor' && args.length > 1) {
const { handleCursorCommand } = await import('./commands/cursor-command'); const { handleCursorCommand } = await import('./commands/cursor-command');
const cursorToken = args[1]; const cursorToken = args[1];
@@ -523,6 +543,19 @@ async function main(): Promise<void> {
} }
} }
// Compatibility shim: old `ccs cursor <subcommand>` still forwards to the legacy bridge
// for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy.
if (firstArg === 'cursor' && args.length > 1) {
const { handleCursorCommand } = await import('./commands/cursor-command');
const cursorToken = args[1];
if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') {
printCursorLegacySubcommandDeprecation(cursorToken);
const exitCode = await handleCursorCommand(args.slice(1));
process.exit(exitCode);
}
}
// First-time install: offer setup wizard for interactive users // First-time install: offer setup wizard for interactive users
// Check independently of recovery status (user may have empty config.yaml) // Check independently of recovery status (user may have empty config.yaml)
// Skip if headless, CI, or non-TTY environment // Skip if headless, CI, or non-TTY environment
@@ -551,17 +584,8 @@ async function main(): Promise<void> {
try { try {
// Detect profile (strip --target flags before profile detection) // Detect profile (strip --target flags before profile detection)
const cleanArgs = stripTargetFlag(args); const cleanArgs = stripTargetFlag(args);
const useCursorCliproxyShortcut = shouldUseCursorCliproxyShortcut(cleanArgs); const { profile, remainingArgs } = detectProfile(cleanArgs);
const { profile, remainingArgs } = useCursorCliproxyShortcut const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
? { profile: 'cursor', remainingArgs: cleanArgs.slice(1) }
: detectProfile(cleanArgs);
const profileInfo: ProfileDetectionResult = useCursorCliproxyShortcut
? {
type: 'cliproxy',
name: 'cursor',
provider: 'cursor',
}
: detector.detectProfileType(profile);
let resolvedTarget: ReturnType<typeof resolveTargetType>; let resolvedTarget: ReturnType<typeof resolveTargetType>;
try { try {
resolvedTarget = resolveTargetType( resolvedTarget = resolveTargetType(
+4 -4
View File
@@ -19,7 +19,7 @@ import {
normalizeProtocol, normalizeProtocol,
CLIPROXY_DEFAULT_PORT, CLIPROXY_DEFAULT_PORT,
} from './port-manager'; } from './port-manager';
import { getProviderSettingsPath } from './path-resolver'; import { migrateLegacyProviderSettingsIfNeeded } from './path-resolver';
import { import {
canonicalizeModelIdForProvider, canonicalizeModelIdForProvider,
MODEL_ENV_VAR_KEYS, MODEL_ENV_VAR_KEYS,
@@ -465,7 +465,7 @@ export function getEffectiveEnvVars(
} }
// Priority 2: Default provider settings file // Priority 2: Default provider settings file
const settingsPath = getProviderSettingsPath(provider); const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider);
// Check for user override file // Check for user override file
if (fs.existsSync(settingsPath)) { if (fs.existsSync(settingsPath)) {
@@ -505,7 +505,7 @@ export function getEffectiveEnvVars(
* Called during installation/first run * Called during installation/first run
*/ */
export function ensureProviderSettings(provider: CLIProxyProvider): void { export function ensureProviderSettings(provider: CLIProxyProvider): void {
const settingsPath = getProviderSettingsPath(provider); const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider);
const defaultEnv = getClaudeEnvVars(provider); const defaultEnv = getClaudeEnvVars(provider);
const writeSettings = (settings: Record<string, unknown>): void => { const writeSettings = (settings: Record<string, unknown>): void => {
@@ -663,7 +663,7 @@ export function getRemoteEnvVars(
// Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json) // Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json)
if (Object.keys(userEnvVars).length === 0) { if (Object.keys(userEnvVars).length === 0) {
const settingsPath = getProviderSettingsPath(provider); const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider);
if (fs.existsSync(settingsPath)) { if (fs.existsSync(settingsPath)) {
try { try {
const content = fs.readFileSync(settingsPath, 'utf-8'); const content = fs.readFileSync(settingsPath, 'utf-8');
+42
View File
@@ -16,6 +16,13 @@ export function getCliproxyDir(): string {
return path.join(getCcsDir(), 'cliproxy'); return path.join(getCcsDir(), 'cliproxy');
} }
/**
* Get CLIProxy provider settings directory.
*/
export function getCliproxyProvidersDir(): string {
return path.join(getCliproxyDir(), 'providers');
}
/** /**
* Get CLIProxy writable directory for logs and runtime files. * Get CLIProxy writable directory for logs and runtime files.
* This directory is set as WRITABLE_PATH env var when spawning CLIProxy. * This directory is set as WRITABLE_PATH env var when spawning CLIProxy.
@@ -75,5 +82,40 @@ export function getBinDir(): string {
* Example: ~/.ccs/gemini.settings.json * Example: ~/.ccs/gemini.settings.json
*/ */
export function getProviderSettingsPath(provider: CLIProxyProvider): string { export function getProviderSettingsPath(provider: CLIProxyProvider): string {
if (provider === 'cursor') {
return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`);
}
return getLegacyProviderSettingsPath(provider);
}
/**
* Get CLIProxy provider settings path in the dedicated cliproxy/providers namespace.
* Used only for providers that must not collide with legacy top-level settings files.
*/
export function getDedicatedProviderSettingsPath(provider: CLIProxyProvider): string {
return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`);
}
/**
* Get legacy provider settings file path in ~/.ccs root.
* This is kept for compatibility reads/migration of older provider settings.
*/
export function getLegacyProviderSettingsPath(provider: CLIProxyProvider): string {
return path.join(getCcsDir(), `${provider}.settings.json`); return path.join(getCcsDir(), `${provider}.settings.json`);
} }
/**
* Resolve the effective provider settings path.
*
* Cursor uses a dedicated cliproxy/providers namespace so it does not collide
* with the deprecated Cursor IDE bridge raw settings file.
*/
export function migrateLegacyProviderSettingsIfNeeded(provider: CLIProxyProvider): string {
if (provider !== 'cursor') {
return getProviderSettingsPath(provider);
}
const targetPath = getDedicatedProviderSettingsPath(provider);
return targetPath;
}
+5 -5
View File
@@ -9,7 +9,7 @@ import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import { InteractivePrompt } from '../utils/prompt'; import { InteractivePrompt } from '../utils/prompt';
import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog'; import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog';
import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator'; import { getClaudeEnvVars, migrateLegacyProviderSettingsIfNeeded } from './config-generator';
import { CLIProxyProvider } from './types'; import { CLIProxyProvider } from './types';
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager'; import { getCcsDir } from '../utils/config-manager';
@@ -31,7 +31,7 @@ function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string)
* Check if provider has user settings configured * Check if provider has user settings configured
*/ */
export function hasUserSettings(provider: CLIProxyProvider): boolean { export function hasUserSettings(provider: CLIProxyProvider): boolean {
const settingsPath = getProviderSettingsPath(provider); const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider);
return fs.existsSync(settingsPath); return fs.existsSync(settingsPath);
} }
@@ -46,7 +46,7 @@ export function getCurrentModel(
): string | undefined { ): string | undefined {
const settingsPath = customSettingsPath const settingsPath = customSettingsPath
? customSettingsPath.replace(/^~/, os.homedir()) ? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider); : migrateLegacyProviderSettingsIfNeeded(provider);
if (!fs.existsSync(settingsPath)) return undefined; if (!fs.existsSync(settingsPath)) return undefined;
try { try {
@@ -116,7 +116,7 @@ export async function configureProviderModel(
// Use custom settings path for CLIProxy variants, otherwise use default provider path // Use custom settings path for CLIProxy variants, otherwise use default provider path
const settingsPath = customSettingsPath const settingsPath = customSettingsPath
? customSettingsPath.replace(/^~/, os.homedir()) ? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider); : migrateLegacyProviderSettingsIfNeeded(provider);
// Skip if already configured with a model (unless --config flag). // Skip if already configured with a model (unless --config flag).
// A settings file can exist without model env keys (e.g., hook-only writes). // A settings file can exist without model env keys (e.g., hook-only writes).
@@ -249,7 +249,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
await initUI(); await initUI();
const currentModel = getCurrentModel(provider); const currentModel = getCurrentModel(provider);
const settingsPath = getProviderSettingsPath(provider); const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider);
const normalizedCurrentModel = currentModel const normalizedCurrentModel = currentModel
? canonicalizeModelForProvider(provider, currentModel) ? canonicalizeModelForProvider(provider, currentModel)
: undefined; : undefined;
+9 -3
View File
@@ -1,5 +1,4 @@
import { COPILOT_SUBCOMMANDS } from '../copilot/constants'; import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
import { CURSOR_SUBCOMMANDS } from '../cursor/constants';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets'; export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets';
@@ -105,7 +104,7 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
}, },
{ {
name: 'cursor', name: 'cursor',
summary: 'Run or manage the Cursor bridge', summary: 'Run Cursor via CLIProxy or manage Cursor provider auth',
group: 'runtime', group: 'runtime',
visibility: 'public', visibility: 'public',
}, },
@@ -308,7 +307,14 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
update: ['--force', '--beta', '--dev', '--help', '-h'], update: ['--force', '--beta', '--dev', '--help', '-h'],
}; };
export const CURSOR_COMPLETION_SUBCOMMANDS = [...CURSOR_SUBCOMMANDS] as const; export const CURSOR_COMPLETION_SUBCOMMANDS = [
'--auth',
'--accounts',
'--config',
'--logout',
'--help',
'-h',
] as const;
export const COPILOT_COMPLETION_SUBCOMMANDS = [...COPILOT_SUBCOMMANDS, 'help'] as const; export const COPILOT_COMPLETION_SUBCOMMANDS = [...COPILOT_SUBCOMMANDS, 'help'] as const;
export function getPublicRootCommands(): readonly RootCommandEntry[] { export function getPublicRootCommands(): readonly RootCommandEntry[] {
+30 -25
View File
@@ -4,6 +4,9 @@ import type { CursorConfig } from '../config/unified-config-types';
import { getCcsDirDisplay } from '../utils/config-manager'; import { getCcsDirDisplay } from '../utils/config-manager';
import { color } from '../utils/ui'; import { color } from '../utils/ui';
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
function printLines(lines: string[]): void { function printLines(lines: string[]): void {
for (const line of lines) { for (const line of lines) {
console.log(line); console.log(line);
@@ -14,11 +17,11 @@ export function renderCursorHelp(): number {
printLines([ printLines([
'Legacy Cursor Compatibility', 'Legacy Cursor Compatibility',
'', '',
'Deprecated: prefer CLIProxy-backed Cursor auth and account management.', 'Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.',
'Supported auth path: ccs cursor --auth', `Supported auth path: ${CLIPROXY_CURSOR_COMMAND} --auth`,
'Supported dashboard path: ccs config -> CLIProxy -> Cursor', 'Supported dashboard path: ccs config -> CLIProxy -> Cursor',
'', '',
'Usage: ccs cursor <subcommand>', `Usage: ${LEGACY_CURSOR_COMMAND} <subcommand>`,
'', '',
'Subcommands (deprecated compatibility for the local reverse-engineered bridge):', 'Subcommands (deprecated compatibility for the local reverse-engineered bridge):',
' auth Import Cursor IDE authentication token (deprecated)', ' auth Import Cursor IDE authentication token (deprecated)',
@@ -32,24 +35,24 @@ export function renderCursorHelp(): number {
' help Show this help message', ' help Show this help message',
'', '',
'Supported CLIProxy path:', 'Supported CLIProxy path:',
' ccs cursor --auth # Authenticate Cursor via CLIProxy', ` ${CLIPROXY_CURSOR_COMMAND} --auth # Authenticate Cursor via CLIProxy`,
' ccs cursor --accounts # Manage CLIProxy Cursor accounts', ` ${CLIPROXY_CURSOR_COMMAND} --accounts # Manage CLIProxy Cursor accounts`,
' ccs cursor --config # Open CLIProxy Cursor settings', ` ${CLIPROXY_CURSOR_COMMAND} --config # Open CLIProxy Cursor settings`,
'', '',
'Legacy runtime entry (deprecated compatibility):', 'Legacy runtime entry (deprecated compatibility):',
' ccs cursor [claude args] # Run Claude via the local Cursor bridge', ` ${LEGACY_CURSOR_COMMAND} [claude args] # Run Claude via the local Cursor bridge`,
'', '',
'Legacy auth options:', 'Legacy auth options:',
' ccs cursor auth # Auto-detect from Cursor SQLite (deprecated)', ` ${LEGACY_CURSOR_COMMAND} auth # Auto-detect from Cursor SQLite (deprecated)`,
' ccs cursor auth --manual --token <t> --machine-id <id>', ` ${LEGACY_CURSOR_COMMAND} auth --manual --token <t> --machine-id <id>`,
'', '',
'Legacy bridge quick start:', 'Legacy bridge quick start:',
' 1. ccs cursor enable # Deprecated compatibility: enable local bridge', ` 1. ${LEGACY_CURSOR_COMMAND} enable # Deprecated compatibility: enable local bridge`,
' 2. ccs cursor auth # Deprecated compatibility: import Cursor IDE token', ` 2. ${LEGACY_CURSOR_COMMAND} auth # Deprecated compatibility: import Cursor IDE token`,
' 3. ccs cursor start # Start local daemon', ` 3. ${LEGACY_CURSOR_COMMAND} start # Start local daemon`,
' 4. ccs cursor probe # Verify live runtime health', ` 4. ${LEGACY_CURSOR_COMMAND} probe # Verify live runtime health`,
' 5. ccs cursor "task" # Run Claude through the local bridge', ` 5. ${LEGACY_CURSOR_COMMAND} "task" # Run Claude through the local bridge`,
' 6. ccs cursor status # Inspect auth/daemon wiring', ` 6. ${LEGACY_CURSOR_COMMAND} status # Inspect auth/daemon wiring`,
'', '',
'Web UI: ccs config -> Deprecated -> Cursor IDE', 'Web UI: ccs config -> Deprecated -> Cursor IDE',
'', '',
@@ -109,11 +112,13 @@ export function renderCursorStatus(
console.log(''); console.log('');
console.log('Client setup:'); console.log('Client setup:');
console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`); console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`);
console.log(' Runtime entry: ccs cursor [claude args] (deprecated compatibility)'); console.log(
console.log(' Supported auth: ccs cursor --auth'); ` Runtime entry: ${LEGACY_CURSOR_COMMAND} [claude args] (deprecated compatibility)`
console.log(' Live probe: ccs cursor probe'); );
console.log(' Status command: ccs cursor status'); console.log(` Supported auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(' Help command: ccs cursor help'); console.log(` Live probe: ${LEGACY_CURSOR_COMMAND} probe`);
console.log(` Status command: ${LEGACY_CURSOR_COMMAND} status`);
console.log(` Help command: ${LEGACY_CURSOR_COMMAND} help`);
if (isReady) { if (isReady) {
return; return;
@@ -123,16 +128,16 @@ export function renderCursorStatus(
console.log('Next steps:'); console.log('Next steps:');
if (!cursorConfig.enabled) { if (!cursorConfig.enabled) {
console.log(' - Enable: ccs cursor enable'); console.log(` - Enable: ${LEGACY_CURSOR_COMMAND} enable`);
} }
if (!authStatus.authenticated || authStatus.expired) { if (!authStatus.authenticated || authStatus.expired) {
console.log(' - Supported: ccs cursor --auth'); console.log(` - Supported: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(' - Legacy auth: ccs cursor auth'); console.log(` - Legacy auth: ${LEGACY_CURSOR_COMMAND} auth`);
} }
if (!daemonStatus.running) { if (!daemonStatus.running) {
console.log(' - Start: ccs cursor start'); console.log(` - Start: ${LEGACY_CURSOR_COMMAND} start`);
} }
console.log(' - Help: ccs cursor help'); console.log(` - Help: ${LEGACY_CURSOR_COMMAND} help`);
} }
export function renderCursorModels(models: CursorModel[], defaultModel: string): void { export function renderCursorModels(models: CursorModel[], defaultModel: string): void {
+20 -17
View File
@@ -26,10 +26,13 @@ import {
} from './cursor-command-display'; } from './cursor-command-display';
import { ok, fail, info } from '../utils/ui'; import { ok, fail, info } from '../utils/ui';
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
function printLegacyCursorDeprecationNotice(): void { function printLegacyCursorDeprecationNotice(): void {
console.log( console.log(
info( info(
'Deprecated compatibility path. Prefer CLIProxy-backed Cursor auth via `ccs cursor --auth` or the CLIProxy dashboard.' `Deprecated compatibility path. \`${CLIPROXY_CURSOR_COMMAND}\` now belongs to the CLIProxy Cursor provider; use \`${LEGACY_CURSOR_COMMAND}\` for the old bridge.`
) )
); );
console.log(''); console.log('');
@@ -135,7 +138,7 @@ async function handleAuth(args: string[]): Promise<number> {
if (!accessToken || !machineId) { if (!accessToken || !machineId) {
console.error( console.error(
fail( fail(
'Manual auth requires both token and machine ID.\n\nExample:\n ccs cursor auth --manual --token <token> --machine-id <machine-id>' `Manual auth requires both token and machine ID.\n\nExample:\n ${LEGACY_CURSOR_COMMAND} auth --manual --token <token> --machine-id <machine-id>`
) )
); );
return 1; return 1;
@@ -156,9 +159,9 @@ async function handleAuth(args: string[]): Promise<number> {
console.log(ok('Cursor credentials imported (manual mode)')); console.log(ok('Cursor credentials imported (manual mode)'));
console.log(''); console.log('');
console.log('Next steps:'); console.log('Next steps:');
console.log(' 0. Preferred auth: ccs cursor --auth'); console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(' 1. Enable integration: ccs cursor enable'); console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`);
console.log(' 2. Start daemon: ccs cursor start'); console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
return 0; return 0;
} }
@@ -179,10 +182,10 @@ async function handleAuth(args: string[]): Promise<number> {
console.log(ok('Auto-detected Cursor credentials')); console.log(ok('Auto-detected Cursor credentials'));
console.log(''); console.log('');
console.log('Next steps:'); console.log('Next steps:');
console.log(' 0. Preferred auth: ccs cursor --auth'); console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(' 1. Enable integration: ccs cursor enable'); console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`);
console.log(' 2. Start daemon: ccs cursor start'); console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
console.log(' 3. Check status: ccs cursor status'); console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`);
return 0; return 0;
} }
@@ -190,7 +193,7 @@ async function handleAuth(args: string[]): Promise<number> {
printAutoDetectFailure(autoResult); printAutoDetectFailure(autoResult);
console.log(''); console.log('');
console.log('Manual fallback:'); console.log('Manual fallback:');
console.log(' ccs cursor auth --manual --token <token> --machine-id <machine-id>'); console.log(` ${LEGACY_CURSOR_COMMAND} auth --manual --token <token> --machine-id <machine-id>`);
console.log(''); console.log('');
return 1; return 1;
@@ -230,17 +233,17 @@ async function handleStart(): Promise<number> {
const cursorConfig = getCursorConfig(); const cursorConfig = getCursorConfig();
if (!cursorConfig.enabled) { if (!cursorConfig.enabled) {
console.error(fail('Cursor integration is disabled. Run: ccs cursor enable')); console.error(fail(`Cursor integration is disabled. Run: ${LEGACY_CURSOR_COMMAND} enable`));
return 1; return 1;
} }
const authStatus = checkAuthStatus(); const authStatus = checkAuthStatus();
if (!authStatus.authenticated) { if (!authStatus.authenticated) {
console.error(fail('Not authenticated. Run: ccs cursor auth')); console.error(fail(`Not authenticated. Run: ${LEGACY_CURSOR_COMMAND} auth`));
return 1; return 1;
} }
if (authStatus.expired) { if (authStatus.expired) {
console.error(fail('Credentials expired. Run: ccs cursor auth')); console.error(fail(`Credentials expired. Run: ${LEGACY_CURSOR_COMMAND} auth`));
return 1; return 1;
} }
@@ -294,10 +297,10 @@ async function handleEnable(): Promise<number> {
console.log(ok('Cursor integration enabled')); console.log(ok('Cursor integration enabled'));
console.log(''); console.log('');
console.log('Next steps:'); console.log('Next steps:');
console.log(' 0. Preferred auth: ccs cursor --auth'); console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(' 1. Authenticate: ccs cursor auth'); console.log(` 1. Authenticate: ${LEGACY_CURSOR_COMMAND} auth`);
console.log(' 2. Start daemon: ccs cursor start'); console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
console.log(' 3. Check status: ccs cursor status'); console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`);
return 0; return 0;
} }
+1 -2
View File
@@ -344,8 +344,7 @@ export async function handleHelpRoute(
cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(), cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(),
copilot: async () => copilot: async () =>
process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])), process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])),
cursor: async () => cursor: async () => await showProviderShortcutHelp('cursor', writeLine),
process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])),
docker: async () => (await import('./docker/help-subcommand')).showHelp(), docker: async () => (await import('./docker/help-subcommand')).showHelp(),
migrate: async () => (await import('./migrate-command')).printMigrateHelp(), migrate: async () => (await import('./migrate-command')).printMigrateHelp(),
setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']), setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']),
+2
View File
@@ -20,6 +20,8 @@ export const RESERVED_PROFILE_NAMES = [
'copilot', 'copilot',
// Cursor IDE (Cursor proxy daemon) // Cursor IDE (Cursor proxy daemon)
'cursor', 'cursor',
'legacy-cursor',
'legacy',
// CLI commands and special names // CLI commands and special names
'default', 'default',
'config', 'config',
+2
View File
@@ -1,3 +1,5 @@
export const LEGACY_CURSOR_PROFILE_NAME = 'legacy-cursor';
export const CURSOR_SUBCOMMANDS = [ export const CURSOR_SUBCOMMANDS = [
'auth', 'auth',
'status', 'status',
+2 -2
View File
@@ -248,7 +248,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
const authStatus = checkAuthStatus(); const authStatus = checkAuthStatus();
if (!authStatus.authenticated || !authStatus.credentials) { if (!authStatus.authenticated || !authStatus.credentials) {
const message = 'Cursor credentials not found. Run `ccs cursor auth` first.'; const message = 'Cursor credentials not found. Run `ccs legacy cursor auth` first.';
if (isAnthropicRoute) { if (isAnthropicRoute) {
await pipeWebResponseToNode( await pipeWebResponseToNode(
createAnthropicErrorResponse(401, 'authentication_error', message), createAnthropicErrorResponse(401, 'authentication_error', message),
@@ -266,7 +266,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
} }
if (authStatus.expired) { if (authStatus.expired) {
const message = 'Cursor credentials expired. Run `ccs cursor auth` again.'; const message = 'Cursor credentials expired. Run `ccs legacy cursor auth` again.';
if (isAnthropicRoute) { if (isAnthropicRoute) {
await pipeWebResponseToNode( await pipeWebResponseToNode(
createAnthropicErrorResponse(401, 'authentication_error', message), createAnthropicErrorResponse(401, 'authentication_error', message),
+4 -4
View File
@@ -97,7 +97,7 @@ export async function executeCursorProfile(
if (!config.enabled) { if (!config.enabled) {
console.error(fail('Cursor integration is not enabled.')); console.error(fail('Cursor integration is not enabled.'));
console.error(''); console.error('');
console.error('Enable it first: ccs cursor enable'); console.error('Enable it first: ccs legacy cursor enable');
return 1; return 1;
} }
@@ -105,13 +105,13 @@ export async function executeCursorProfile(
if (!authStatus.authenticated) { if (!authStatus.authenticated) {
console.error(fail('Cursor credentials not found.')); console.error(fail('Cursor credentials not found.'));
console.error(''); console.error('');
console.error('Authenticate first: ccs cursor auth'); console.error('Authenticate first: ccs legacy cursor auth');
return 1; return 1;
} }
if (authStatus.expired) { if (authStatus.expired) {
console.error(fail('Cursor credentials have expired.')); console.error(fail('Cursor credentials have expired.'));
console.error(''); console.error('');
console.error('Refresh them with: ccs cursor auth'); console.error('Refresh them with: ccs legacy cursor auth');
return 1; return 1;
} }
@@ -133,7 +133,7 @@ export async function executeCursorProfile(
console.error(fail('Cursor daemon is not running.')); console.error(fail('Cursor daemon is not running.'));
console.error(''); console.error('');
console.error('Start the daemon:'); console.error('Start the daemon:');
console.error(' ccs cursor start'); console.error(' ccs legacy cursor start');
console.error('Or enable auto_start in the Cursor config section.'); console.error('Or enable auto_start in the Cursor config section.');
return 1; return 1;
} }
+3 -3
View File
@@ -120,7 +120,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
stage: 'auth', stage: 'auth',
status: 401, status: 401,
duration_ms: Date.now() - startedAt, duration_ms: Date.now() - startedAt,
message: 'Cursor credentials not found. Run `ccs cursor auth` first.', message: 'Cursor credentials not found. Run `ccs legacy cursor auth` first.',
error_type: 'authentication_error', error_type: 'authentication_error',
}; };
} }
@@ -131,7 +131,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
stage: 'auth', stage: 'auth',
status: 401, status: 401,
duration_ms: Date.now() - startedAt, duration_ms: Date.now() - startedAt,
message: 'Cursor credentials expired. Run `ccs cursor auth` again.', message: 'Cursor credentials expired. Run `ccs legacy cursor auth` again.',
error_type: 'authentication_error', error_type: 'authentication_error',
}; };
} }
@@ -168,7 +168,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
status: 503, status: 503,
duration_ms: Date.now() - startedAt, duration_ms: Date.now() - startedAt,
message: message:
'Cursor daemon is not running. Start it with `ccs cursor start` or enable auto_start.', 'Cursor daemon is not running. Start it with `ccs legacy cursor start` or enable auto_start.',
error_type: 'daemon_not_running', error_type: 'daemon_not_running',
}; };
} }
@@ -396,7 +396,7 @@ function resolveBackend(
}; };
} }
if (profileType === 'cursor' || profileName === 'cursor') { if (profileType === 'cursor') {
return { return {
backendId: null, backendId: null,
backendDisplayName: null, backendDisplayName: null,
+1
View File
@@ -104,6 +104,7 @@ apiRoutes.use('/copilot', copilotRoutes);
// ==================== Cursor ==================== // ==================== Cursor ====================
apiRoutes.use('/cursor', cursorRoutes); apiRoutes.use('/cursor', cursorRoutes);
apiRoutes.use('/legacy/cursor', cursorRoutes);
// ==================== Droid ==================== // ==================== Droid ====================
apiRoutes.use('/droid', droidRoutes); apiRoutes.use('/droid', droidRoutes);
+12
View File
@@ -30,6 +30,7 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar
import type { Settings } from '../../types/config'; import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types'; import type { CLIProxyProvider } from '../../cliproxy/types';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import { migrateLegacyProviderSettingsIfNeeded } from '../../cliproxy/config/path-resolver';
import { expandPath } from '../../utils/helpers'; import { expandPath } from '../../utils/helpers';
import { import {
canonicalizeModelIdForProvider, canonicalizeModelIdForProvider,
@@ -101,6 +102,17 @@ function resolveSettingsPath(profileOrVariant: string): string {
const ccsDir = getCcsDir(); const ccsDir = getCcsDir();
const resolvedCcsDir = path.resolve(ccsDir); const resolvedCcsDir = path.resolve(ccsDir);
const directProvider = mapExternalProviderName(profileOrVariant);
if (directProvider) {
if (profileOrVariant !== directProvider) {
return resolvePathWithin(
resolvedCcsDir,
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
);
}
return path.resolve(migrateLegacyProviderSettingsIfNeeded(directProvider));
}
// Check if this is a variant // Check if this is a variant
const variants = listVariants(); const variants = listVariants();
const variant = variants[profileOrVariant]; const variant = variants[profileOrVariant];
@@ -74,7 +74,7 @@ describe('cursor daemon lifecycle smoke', () => {
}; };
expect(anthropicBody.type).toBe('error'); expect(anthropicBody.type).toBe('error');
expect(anthropicBody.error?.type).toBe('authentication_error'); expect(anthropicBody.error?.type).toBe('authentication_error');
expect(anthropicBody.error?.message).toContain('Run `ccs cursor auth` first'); expect(anthropicBody.error?.message).toContain('Run `ccs legacy cursor auth` first');
const stopResult = await stopDaemon(); const stopResult = await stopDaemon();
expect(stopResult.success).toBe(true); expect(stopResult.success).toBe(true);
+9 -4
View File
@@ -87,17 +87,22 @@ describe('npm CLI', () => {
}); });
it('routes cursor probe through the cursor command handler', function() { it('routes cursor probe through the cursor command handler', function() {
let output = '';
try { try {
execSync(`bun "${srcCcsPath}" cursor probe`, { output = execSync(`bun "${srcCcsPath}" cursor probe`, {
encoding: 'utf8',
stdio: 'pipe', stdio: 'pipe',
timeout: 3000, timeout: 3000,
env: { ...process.env, CCS_HOME: testCcsHome } env: { ...process.env, CCS_HOME: testCcsHome }
}); });
} catch (e) { } catch (e) {
const output = e.stderr?.toString() || e.stdout?.toString() || ''; output = e.stderr?.toString() || e.stdout?.toString() || '';
assert(!output.includes("Profile 'cursor' not found"), 'Should not fall through to profile lookup');
assert(output.includes('Cursor Live Probe'), 'Should render the cursor probe command output');
} }
assert(!output.includes("Profile 'cursor' not found"), 'Should not fall through to profile lookup');
assert(
output.includes('Cursor Live Probe') || output.includes('legacy cursor probe'),
'Should route through the legacy cursor compatibility handler'
);
}); });
it('routes gitlab --help to provider shortcut help instead of starting auth', function() { it('routes gitlab --help to provider shortcut help instead of starting auth', function() {
+16 -7
View File
@@ -202,7 +202,14 @@ describe('ProfileDetector', () => {
} }
}); });
it('should detect cursor as a first-class runtime profile when enabled', () => { it('should detect cursor as a CLIProxy provider shortcut', () => {
const result = detector.detectProfileType('cursor');
expect(result.type).toBe('cliproxy');
expect(result.name).toBe('cursor');
expect(result.provider).toBe('cursor');
});
it('should detect legacy-cursor as a first-class runtime profile when enabled', () => {
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({ const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({
enabled: true, enabled: true,
@@ -213,9 +220,9 @@ describe('ProfileDetector', () => {
}); });
try { try {
const result = detector.detectProfileType('cursor'); const result = detector.detectProfileType('legacy-cursor');
expect(result.type).toBe('cursor'); expect(result.type).toBe('cursor');
expect(result.name).toBe('cursor'); expect(result.name).toBe('legacy-cursor');
expect(result.cursorConfig?.auto_start).toBe(true); expect(result.cursorConfig?.auto_start).toBe(true);
} finally { } finally {
isUnifiedModeSpy.mockRestore(); isUnifiedModeSpy.mockRestore();
@@ -223,7 +230,7 @@ describe('ProfileDetector', () => {
} }
}); });
it('should merge default cursor fields when enabled via partial unified config', () => { it('should merge default legacy cursor fields when enabled via partial unified config', () => {
const originalCcsHome = process.env.CCS_HOME; const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempDir; process.env.CCS_HOME = tempDir;
const ccsDir = path.join(tempDir, '.ccs'); const ccsDir = path.join(tempDir, '.ccs');
@@ -235,7 +242,7 @@ describe('ProfileDetector', () => {
try { try {
const localDetector = new ProfileDetector(); const localDetector = new ProfileDetector();
const result = localDetector.detectProfileType('cursor'); const result = localDetector.detectProfileType('legacy-cursor');
expect(result.type).toBe('cursor'); expect(result.type).toBe('cursor');
expect(result.cursorConfig).toEqual({ expect(result.cursorConfig).toEqual({
enabled: true, enabled: true,
@@ -253,7 +260,7 @@ describe('ProfileDetector', () => {
} }
}); });
it('should throw a helpful error when cursor profile is disabled', () => { it('should throw a helpful error when legacy cursor profile is disabled', () => {
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({ const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({
enabled: false, enabled: false,
@@ -264,7 +271,9 @@ describe('ProfileDetector', () => {
}); });
try { try {
expect(() => detector.detectProfileType('cursor')).toThrow(/Cursor profile is not enabled/); expect(() => detector.detectProfileType('legacy-cursor')).toThrow(
/Legacy Cursor profile is not enabled/
);
} finally { } finally {
isUnifiedModeSpy.mockRestore(); isUnifiedModeSpy.mockRestore();
getCursorConfigSpy.mockRestore(); getCursorConfigSpy.mockRestore();
@@ -140,6 +140,15 @@ describe('completion backend', () => {
expect(suggestionValues(['cliproxy'])).toEqual(expect.arrayContaining(['remove', '--backend'])); expect(suggestionValues(['cliproxy'])).toEqual(expect.arrayContaining(['remove', '--backend']));
}); });
test('treats cursor as a provider shortcut in completion', () => {
const values = suggestionValues(['cursor']);
expect(values).toEqual(
expect.arrayContaining(['--auth', '--accounts', '--config', '--logout'])
);
expect(values).not.toContain('probe');
expect(values).not.toContain('start');
});
test('filters suggestions by the current token prefix', () => { test('filters suggestions by the current token prefix', () => {
const values = suggestionValues([], 'do'); const values = suggestionValues([], 'do');
expect(values).toEqual(expect.arrayContaining(['docker', 'doctor'])); expect(values).toEqual(expect.arrayContaining(['docker', 'doctor']));
+14 -6
View File
@@ -322,7 +322,9 @@ describe('handleCursorCommand', () => {
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
expect(logs.some((line) => line.includes('Legacy Cursor Compatibility'))).toBe(true); expect(logs.some((line) => line.includes('Legacy Cursor Compatibility'))).toBe(true);
expect(logs.some((line) => line.includes('Usage: ccs cursor <subcommand>'))).toBe(true); expect(logs.some((line) => line.includes('Usage: ccs legacy cursor <subcommand>'))).toBe(
true
);
} finally { } finally {
console.log = originalLog; console.log = originalLog;
console.error = originalError; console.error = originalError;
@@ -423,7 +425,9 @@ describe('renderCursorStatus', () => {
true true
); );
expect(logs.some((line) => line.includes('Next steps:'))).toBe(true); expect(logs.some((line) => line.includes('Next steps:'))).toBe(true);
expect(logs.some((line) => line.includes(' - Help: ccs cursor help'))).toBe(true); expect(logs.some((line) => line.includes(' - Help: ccs legacy cursor help'))).toBe(
true
);
} finally { } finally {
console.log = originalLog; console.log = originalLog;
} }
@@ -483,11 +487,15 @@ describe('renderCursorHelp', () => {
const exitCode = renderCursorHelp(); const exitCode = renderCursorHelp();
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
expect(logs.some((line) => line.includes('Usage: ccs cursor <subcommand>'))).toBe(true); expect(logs.some((line) => line.includes('Usage: ccs legacy cursor <subcommand>'))).toBe(
expect(logs.some((line) => line.includes('Legacy Cursor Compatibility'))).toBe(true);
expect(logs.some((line) => line.includes('Deprecated: prefer CLIProxy-backed Cursor auth'))).toBe(
true true
); );
expect(logs.some((line) => line.includes('Legacy Cursor Compatibility'))).toBe(true);
expect(
logs.some((line) =>
line.includes('Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.')
)
).toBe(true);
expect(logs.some((line) => line.includes('probe Run a live authenticated runtime probe'))).toBe( expect(logs.some((line) => line.includes('probe Run a live authenticated runtime probe'))).toBe(
true true
); );
@@ -495,7 +503,7 @@ describe('renderCursorHelp', () => {
logs.some((line) => line.includes('ccs cursor --auth')) logs.some((line) => line.includes('ccs cursor --auth'))
).toBe(true); ).toBe(true);
expect( expect(
logs.some((line) => line.includes('ccs cursor [claude args]')) logs.some((line) => line.includes('ccs legacy cursor [claude args]'))
).toBe(true); ).toBe(true);
} finally { } finally {
console.log = originalLog; console.log = originalLog;
@@ -82,6 +82,21 @@ describe('image-analysis-backend-resolver', () => {
expect(status.resolutionSource).toBe('profile-backend'); expect(status.resolutionSource).toBe('profile-backend');
}); });
it('treats cliproxy cursor as a provider-backed profile rather than a legacy bridge alias', () => {
const status = resolveImageAnalysisStatus(
{
profileName: 'cursor',
profileType: 'cliproxy',
cliproxyProvider: 'cursor',
},
DEFAULT_IMAGE_ANALYSIS_CONFIG
);
expect(status.backendId).toBe('cursor');
expect(status.resolutionSource).toBe('cliproxy-provider');
expect(status.reason).toContain('no image-analysis model configured');
});
it('uses the fallback backend for an unmapped third-party settings profile', () => { it('uses the fallback backend for an unmapped third-party settings profile', () => {
const status = resolveImageAnalysisStatus( const status = resolveImageAnalysisStatus(
{ {
+5 -1
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { queryClient } from '@/lib/query-client'; import { queryClient } from '@/lib/query-client';
@@ -129,6 +129,10 @@ export default function App() {
/> />
<Route <Route
path="/cursor" path="/cursor"
element={<Navigate to="/cliproxy?provider=cursor" replace />}
/>
<Route
path="/legacy/cursor"
element={ element={
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<CursorPage /> <CursorPage />
@@ -25,6 +25,17 @@ interface ProviderEditorHeaderProps {
onSave: () => void; onSave: () => void;
} }
function formatSettingsPathBadgeLabel(pathValue: string): string {
const normalized = pathValue.replace(/\\/g, '/');
const cliproxySegment = '/cliproxy/providers/';
const cliproxyIndex = normalized.lastIndexOf(cliproxySegment);
if (cliproxyIndex !== -1) {
return normalized.slice(cliproxyIndex + 1);
}
return normalized.replace(/^.*\//, '');
}
export function ProviderEditorHeader({ export function ProviderEditorHeader({
displayName, displayName,
logoProvider, logoProvider,
@@ -63,7 +74,7 @@ export function ProviderEditorHeader({
)} )}
{!isRemoteMode && data?.path && ( {!isRemoteMode && data?.path && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{data.path.replace(/^.*[\\/]/, '')} {formatSettingsPathBadgeLabel(data.path)}
</Badge> </Badge>
)} )}
</div> </div>
@@ -15,6 +15,7 @@ import {
isAnthropicModelEnvKey, isAnthropicModelEnvKey,
} from '@/lib/extended-context-utils'; } from '@/lib/extended-context-utils';
import { supportsExtendedContext } from '@/lib/model-catalogs'; import { supportsExtendedContext } from '@/lib/model-catalogs';
import { isValidProvider } from '@/lib/provider-config';
/** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */ /** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
@@ -39,12 +40,18 @@ export function useProviderEditor(
queryFn: async () => { queryFn: async () => {
const res = await fetch(`/api/settings/${provider}/raw`); const res = await fetch(`/api/settings/${provider}/raw`);
if (!res.ok) { if (!res.ok) {
const fallbackPath =
provider === 'cursor'
? `~/.ccs/cliproxy/providers/${provider}.settings.json`
: isValidProvider(provider)
? `~/.ccs/${provider}.settings.json`
: `~/.ccs/profiles/${provider}/settings.json`;
// Return empty settings for unconfigured providers // Return empty settings for unconfigured providers
return { return {
profile: provider, profile: provider,
settings: { env: {} }, settings: { env: {} },
mtime: Date.now(), mtime: Date.now(),
path: `~/.ccs/profiles/${provider}/settings.json`, path: fallbackPath,
}; };
} }
return res.json(); return res.json();
+5 -1
View File
@@ -126,7 +126,11 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
{ {
title: t('nav.deprecated'), title: t('nav.deprecated'),
items: [ items: [
{ path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: t('nav.cursorIde') }, {
path: '/legacy/cursor',
iconSrc: '/assets/sidebar/cursor.svg',
label: `${t('nav.cursorIde')} (Legacy)`,
},
], ],
}, },
{ {
+19 -11
View File
@@ -57,6 +57,8 @@ interface CursorAuthResult {
message: string; message: string;
} }
const LEGACY_CURSOR_API_BASE = '/legacy/cursor';
export interface CursorProbeResult { export interface CursorProbeResult {
ok: boolean; ok: boolean;
stage: 'config' | 'auth' | 'daemon' | 'runtime'; stage: 'config' | 'auth' | 'daemon' | 'runtime';
@@ -87,25 +89,25 @@ function getProbeErrorMessage(value: unknown): string | null {
} }
async function fetchCursorStatus(): Promise<CursorStatus> { async function fetchCursorStatus(): Promise<CursorStatus> {
const res = await fetch(withApiBase('/cursor/status')); const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/status`));
if (!res.ok) throw new Error('Failed to fetch cursor status'); if (!res.ok) throw new Error('Failed to fetch cursor status');
return res.json(); return res.json();
} }
async function fetchCursorConfig(): Promise<CursorConfig> { async function fetchCursorConfig(): Promise<CursorConfig> {
const res = await fetch(withApiBase('/cursor/settings')); const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/settings`));
if (!res.ok) throw new Error('Failed to fetch cursor config'); if (!res.ok) throw new Error('Failed to fetch cursor config');
return res.json(); return res.json();
} }
async function fetchCursorModels(): Promise<CursorModelsResponse> { async function fetchCursorModels(): Promise<CursorModelsResponse> {
const res = await fetch(withApiBase('/cursor/models')); const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/models`));
if (!res.ok) throw new Error('Failed to fetch cursor models'); if (!res.ok) throw new Error('Failed to fetch cursor models');
return res.json(); return res.json();
} }
async function fetchCursorRawSettings(): Promise<CursorRawSettings> { async function fetchCursorRawSettings(): Promise<CursorRawSettings> {
const res = await fetch(withApiBase('/cursor/settings/raw')); const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/settings/raw`));
if (!res.ok) throw new Error('Failed to fetch cursor raw settings'); if (!res.ok) throw new Error('Failed to fetch cursor raw settings');
return res.json(); return res.json();
} }
@@ -113,7 +115,7 @@ async function fetchCursorRawSettings(): Promise<CursorRawSettings> {
async function updateCursorConfig( async function updateCursorConfig(
updates: Partial<CursorConfig> updates: Partial<CursorConfig>
): Promise<{ success: boolean; cursor: CursorConfig }> { ): Promise<{ success: boolean; cursor: CursorConfig }> {
const res = await fetch(withApiBase('/cursor/settings'), { const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/settings`), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates), body: JSON.stringify(updates),
@@ -126,7 +128,7 @@ async function saveCursorRawSettings(data: {
settings: CursorRawSettings['settings']; settings: CursorRawSettings['settings'];
expectedMtime?: number; expectedMtime?: number;
}): Promise<{ success: boolean; mtime: number }> { }): Promise<{ success: boolean; mtime: number }> {
const res = await fetch(withApiBase('/cursor/settings/raw'), { const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/settings/raw`), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -137,7 +139,9 @@ async function saveCursorRawSettings(data: {
} }
async function autoDetectCursorAuth(): Promise<CursorAuthResult> { async function autoDetectCursorAuth(): Promise<CursorAuthResult> {
const res = await fetch(withApiBase('/cursor/auth/auto-detect'), { method: 'POST' }); const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/auth/auto-detect`), {
method: 'POST',
});
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => ({ error: 'Auto-detect failed' })); const error = await res.json().catch(() => ({ error: 'Auto-detect failed' }));
throw new Error(error.error || 'Auto-detect failed'); throw new Error(error.error || 'Auto-detect failed');
@@ -149,7 +153,7 @@ async function importCursorAuthManual(data: {
accessToken: string; accessToken: string;
machineId: string; machineId: string;
}): Promise<CursorAuthResult> { }): Promise<CursorAuthResult> {
const res = await fetch(withApiBase('/cursor/auth/import'), { const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/auth/import`), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -162,19 +166,23 @@ async function importCursorAuthManual(data: {
} }
async function startCursorDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> { async function startCursorDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> {
const res = await fetch(withApiBase('/cursor/daemon/start'), { method: 'POST' }); const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/daemon/start`), {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to start cursor daemon'); if (!res.ok) throw new Error('Failed to start cursor daemon');
return res.json(); return res.json();
} }
async function stopCursorDaemon(): Promise<{ success: boolean; error?: string }> { async function stopCursorDaemon(): Promise<{ success: boolean; error?: string }> {
const res = await fetch(withApiBase('/cursor/daemon/stop'), { method: 'POST' }); const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/daemon/stop`), {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to stop cursor daemon'); if (!res.ok) throw new Error('Failed to stop cursor daemon');
return res.json(); return res.json();
} }
async function probeCursorRuntime(): Promise<CursorProbeResult> { async function probeCursorRuntime(): Promise<CursorProbeResult> {
const res = await fetch(withApiBase('/cursor/probe'), { method: 'POST' }); const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/probe`), { method: 'POST' });
const payload = await res.json().catch(() => null); const payload = await res.json().catch(() => null);
if (isCursorProbeResult(payload)) { if (isCursorProbeResult(payload)) {
+2 -2
View File
@@ -1283,7 +1283,7 @@ export function CursorPage() {
<span className="font-medium text-muted-foreground"> <span className="font-medium text-muted-foreground">
{t('cursorPage.provider')} {t('cursorPage.provider')}
</span> </span>
<span className="font-mono">Cursor IDE</span> <span className="font-mono">Cursor IDE (Legacy)</span>
</div> </div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center"> <div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground"> <span className="font-medium text-muted-foreground">
@@ -1295,7 +1295,7 @@ export function CursorPage() {
</div> </div>
{/* TODO i18n: missing key for model mapping env var info paragraph */} {/* TODO i18n: missing key for model mapping env var info paragraph */}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Model mapping writes `ANTHROPIC_MODEL`, Legacy bridge model mapping writes `ANTHROPIC_MODEL`,
`ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and
`ANTHROPIC_DEFAULT_HAIKU_MODEL` in `cursor.settings.json`. `ANTHROPIC_DEFAULT_HAIKU_MODEL` in `cursor.settings.json`.
</p> </p>
+11 -9
View File
@@ -32,7 +32,7 @@ describe('useCursor', () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); const url = String(input);
if (url.endsWith('/api/cursor/status')) { if (url.endsWith('/api/legacy/cursor/status')) {
return Promise.resolve( return Promise.resolve(
createJsonResponse({ createJsonResponse({
enabled: true, enabled: true,
@@ -48,7 +48,7 @@ describe('useCursor', () => {
); );
} }
if (url.endsWith('/api/cursor/settings')) { if (url.endsWith('/api/legacy/cursor/settings')) {
return Promise.resolve( return Promise.resolve(
createJsonResponse({ createJsonResponse({
enabled: true, enabled: true,
@@ -60,7 +60,7 @@ describe('useCursor', () => {
); );
} }
if (url.endsWith('/api/cursor/models')) { if (url.endsWith('/api/legacy/cursor/models')) {
return Promise.resolve( return Promise.resolve(
createJsonResponse({ createJsonResponse({
models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }], models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }],
@@ -69,7 +69,7 @@ describe('useCursor', () => {
); );
} }
if (url.endsWith('/api/cursor/settings/raw')) { if (url.endsWith('/api/legacy/cursor/settings/raw')) {
return Promise.resolve( return Promise.resolve(
createJsonResponse({ createJsonResponse({
settings: {}, settings: {},
@@ -80,7 +80,7 @@ describe('useCursor', () => {
); );
} }
if (url.endsWith('/api/cursor/probe') && init?.method === 'POST') { if (url.endsWith('/api/legacy/cursor/probe') && init?.method === 'POST') {
return Promise.resolve(createJsonResponse(probeFailure, 503)); return Promise.resolve(createJsonResponse(probeFailure, 503));
} }
@@ -102,14 +102,16 @@ describe('useCursor', () => {
await waitFor(() => expect(result.current.probeResult).toMatchObject(probeFailure)); await waitFor(() => expect(result.current.probeResult).toMatchObject(probeFailure));
await waitFor(() => await waitFor(() =>
expect( expect(
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/status')) fetchMock.mock.calls.filter(([input]) =>
.length String(input).endsWith('/api/legacy/cursor/status')
).length
).toBeGreaterThanOrEqual(2) ).toBeGreaterThanOrEqual(2)
); );
await waitFor(() => await waitFor(() =>
expect( expect(
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/models')) fetchMock.mock.calls.filter(([input]) =>
.length String(input).endsWith('/api/legacy/cursor/models')
).length
).toBeGreaterThanOrEqual(2) ).toBeGreaterThanOrEqual(2)
); );
}); });