Merge pull request #1024 from kaitranntt/kai/feat/1016-missing-provider-integration

feat: integrate missing CLIProxy providers
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-15 18:04:37 -04:00
commit 0dc72462a7
72 files changed
+2589 -217

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codebuddy",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "auto",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2.5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v3-2-volc"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/cursor",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "composer-2",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-4-sonnet",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-4-sonnet",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "cursor-small"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/gitlab",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "gitlab-duo",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "duo-chat-opus-4-6",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "duo-chat-sonnet-4-6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "duo-chat-haiku-4-5"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kilo",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "kilo/auto",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kilo/auto",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kilo/auto",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kilo/auto"
}
}
+32 -18
View File
@@ -1,17 +1,20 @@
# Cursor IDE Integration
This guide covers the current CCS-owned Cursor runtime, including auth import, local daemon lifecycle, live probe checks, and dashboard controls.
This guide covers the deprecated CCS-owned Cursor IDE bridge, including auth import, local daemon lifecycle, live probe checks, and dashboard controls.
`ccs cursor` now belongs to the CLIProxy-backed Cursor provider path.
Use `ccs legacy cursor` for the deprecated local bridge documented here.
## What It Provides
- OpenAI-compatible local endpoint powered by Cursor credentials.
- Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients.
- Cursor model list and chat completions via the local CCS daemon.
- Dedicated dashboard page: `ccs config` -> `Cursor IDE`.
- Dedicated dashboard page: `ccs config` -> `Deprecated` -> `Cursor IDE (Legacy)`.
## What This Runtime Actually Does
`ccs cursor` does not launch Cursor IDE itself.
`ccs legacy cursor` does not launch Cursor IDE itself.
The current workflow is:
1. import Cursor credentials from local SQLite or manual input
@@ -32,7 +35,7 @@ Treat this as a CCS-managed Cursor bridge, not a generic CLIProxy-backed provide
### 1) Enable integration
```bash
ccs cursor enable
ccs legacy cursor enable
```
### 2) Import credentials
@@ -40,25 +43,25 @@ ccs cursor enable
Auto-detect from Cursor local SQLite state:
```bash
ccs cursor auth
ccs legacy cursor auth
```
Manual fallback:
```bash
ccs cursor auth --manual --token <token> --machine-id <machine-id>
ccs legacy cursor auth --manual --token <token> --machine-id <machine-id>
```
### 3) Start daemon
```bash
ccs cursor start
ccs legacy cursor start
```
### 4) Run a live probe
```bash
ccs cursor probe
ccs legacy cursor probe
```
Use this to verify that the current build can complete one real authenticated request through the local daemon.
@@ -66,26 +69,37 @@ Use this to verify that the current build can complete one real authenticated re
### 5) Run Cursor-backed Claude
```bash
ccs cursor "explain this repo"
ccs legacy cursor "explain this repo"
```
### 6) Verify status
```bash
ccs cursor status
ccs legacy cursor status
```
Use `ccs cursor` with bare or normal Claude args to run through the local Cursor proxy.
Use `ccs legacy cursor` with bare or normal Claude args to run through the local Cursor proxy.
The admin namespace remains available for setup and inspection:
```bash
ccs cursor help
ccs legacy cursor help
```
### 7) Stop daemon
```bash
ccs cursor stop
ccs legacy cursor stop
```
## Supported Cursor Provider Path
For the supported CLIProxy-backed Cursor provider, use:
```bash
ccs cursor --auth
ccs cursor --accounts
ccs cursor --config
ccs cursor "task"
```
## Runtime Defaults
@@ -96,7 +110,7 @@ ccs cursor stop
- Model list resolution: authenticated live fetch when available, with cached/default fallback.
- Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model.
- Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`.
- Live verification: `ccs cursor probe` or `POST /api/cursor/probe`
- Live verification: `ccs legacy cursor probe` or `POST /api/cursor/probe`
These values are managed in unified config and can be updated from CLI or dashboard.
@@ -108,7 +122,7 @@ Open dashboard:
ccs config
```
Then navigate to `Cursor IDE` in the sidebar.
Then navigate to `Cursor IDE (Legacy)` in the `Deprecated` section.
Available controls:
@@ -131,9 +145,9 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr
### `Not authenticated` or `expired` in `ccs cursor status`
- Re-run `ccs cursor auth` (or manual auth command).
- Re-run `ccs legacy cursor auth` (or manual auth command).
### `ccs cursor probe` fails even though status is green
### `ccs legacy cursor probe` fails even though status is green
- `status` proves local config/auth/daemon readiness only.
- `probe` proves the live runtime path.
@@ -148,4 +162,4 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr
### Daemon fails to start
- Check if port `20129` is in use.
- Change port in dashboard config tab, then retry `ccs cursor start`.
- Change port in dashboard config tab, then retry `ccs legacy cursor start`.
@@ -0,0 +1,127 @@
---
phase: 1
title: "CLI Routing & Namespacing"
status: complete
effort: "6h"
---
# Phase 1: CLI Routing & Namespacing
## Context Links
- `plan.md`
- `src/ccs.ts`
- `src/auth/profile-detector.ts`
- `src/cursor/constants.ts`
- `src/commands/root-command-router.ts`
- `src/commands/command-catalog.ts`
- `src/commands/help-command.ts`
- `src/commands/cursor-command.ts`
- `src/commands/cursor-command-display.ts`
- `src/types/profile.ts`
- `src/config/reserved-names.ts`
## Overview
- Priority: P1
- Owner scope: CLI entry, help, profile detection, command naming
- Goal: make `cursor` provider-first and move the deprecated bridge under `legacy cursor`
## Key Insights
- The current collision is structural, not cosmetic. `ccs cursor` means "legacy bridge" in `src/ccs.ts` and `src/auth/profile-detector.ts`, but `cursor` is also listed as a built-in CLIProxy provider.
- `shouldUseCursorCliproxyShortcut()` is only a heuristic escape hatch. It does not fix bare `ccs cursor`, quoted prompts, or help routing.
- Help is currently inconsistent: provider help exists generically, but `cursor` is excluded and routed to bridge help instead.
## Requirements
- Reserve `cursor` for CLIProxy runtime and CLIProxy admin flags.
- Introduce explicit legacy syntax: `ccs legacy cursor ...`.
- Keep a release-N alias for old legacy admin subcommands only.
- Rename internal bridge-only profile typing from ambiguous `cursor` to explicit `legacy-cursor`.
- Keep file ownership isolated to CLI/router/help files in this phase.
## Data Flow
- Provider path:
`argv -> root command resolution -> provider shortcut/help path -> ProfileDetector(type=cliproxy, provider=cursor) -> CLIProxy runtime`
- Legacy path:
`argv -> legacy root command -> legacy cursor subrouter -> ProfileDetector(type=legacy-cursor) or direct handler -> local bridge runtime`
- Deprecated alias path, release N only:
`argv=ccs cursor auth|status|... -> alias shim -> warning -> dispatch to legacy cursor handler`
## Architecture
- Add a new root command namespace: `ccs legacy`.
- Add nested routing under `legacy` with `cursor` as the first migrated leaf. Do not overload `cursor` itself any longer.
- Remove provider exceptions for `cursor` from the generic provider help/routing logic. `ccs cursor --help` should now use provider shortcut help.
- Convert bridge-only type checks from `profileInfo.type === 'cursor'` to `profileInfo.type === 'legacy-cursor'`.
- Keep `ccs cursor help` only as a release-N compatibility shim that prints:
- `Use "ccs cursor --help" for CLIProxy Cursor`
- `Use "ccs legacy cursor help" for the deprecated bridge`
## Related Code Files
- Modify:
- `src/ccs.ts`
- `src/auth/profile-detector.ts`
- `src/cursor/constants.ts`
- `src/commands/root-command-router.ts`
- `src/commands/command-catalog.ts`
- `src/commands/help-command.ts`
- `src/commands/cursor-command.ts`
- `src/commands/cursor-command-display.ts`
- `src/types/profile.ts`
- `src/config/reserved-names.ts`
- `src/shared/claude-extension-setup.ts`
- `src/targets/target-runtime-compatibility.ts`
- Create:
- `src/commands/legacy-command.ts` or `src/commands/legacy/index.ts`
- `src/commands/legacy/cursor-command.ts` if the team wants physical separation immediately
## Implementation Steps
1. Add the `legacy` root command route and its help surface.
2. Flip `src/ccs.ts` so `cursor` goes through normal CLIProxy provider routing; remove the special-case that gives the bridge ownership of the name.
3. Replace the `shouldUseCursorCliproxyShortcut()` hack with provider-first dispatch plus a compatibility alias table for the old legacy subcommands.
4. Update `ProfileDetector` priority order so `cursor` resolves as `cliproxy`, while `legacy cursor` resolves as `legacy-cursor`.
5. Rename bridge-only help text, summaries, and status text to say "legacy Cursor bridge" explicitly.
6. Audit all `profileType === 'cursor'` checks and convert only the bridge-specific ones to `legacy-cursor`.
## Todo List
- [x] Add `legacy cursor` routing
- [x] Make `ccs cursor` provider-first for bare, prompt, and `--help` usage
- [x] Add deprecated alias forwarding for old admin subcommands
- [x] Rename internal bridge profile path to `legacy-cursor`
- [x] Update provider help, completion, and command catalog summaries
## Success Criteria
- `ccs cursor "task"` resolves to CLIProxy Cursor.
- `ccs legacy cursor "task"` resolves to the old bridge.
- `ccs cursor --help` shows provider shortcut help.
- `ccs cursor auth` still works in release N, but prints an exact replacement warning.
- No CLI path depends on `shouldUseCursorCliproxyShortcut()` to disambiguate runtime meaning.
## Risk Assessment
- High likelihood / high impact: users with scripts calling `ccs cursor "task"` will hit the provider path immediately.
Mitigation: call this out in release notes, keep admin aliases, add explicit warning when legacy files/config are detected and the user invokes `ccs cursor` with no flags.
- Medium likelihood / medium impact: bridge-only type renames may break target compatibility checks or extension setup.
Mitigation: grep audit every `profileType === 'cursor'` branch before tests.
## Rollback Plan
- Re-enable the old `cursor` special-case in `src/ccs.ts` and `ProfileDetector`.
- Keep the new `legacy` namespace in place even if dormant; it is additive and safe to leave.
- Do not roll back migrated files in this phase; routing rollback alone is enough.
## Security Considerations
- No auth material moves in this phase.
- Preserve existing `CCS_HOME`-aware path resolution. Do not introduce `os.homedir()` shortcuts while adding the new namespace.
## Next Steps
- Phase 2 depends on the new command contract from this phase.
@@ -0,0 +1,140 @@
---
phase: 2
title: "Storage & API Boundaries"
status: partial
effort: "6h"
---
# Phase 2: Storage & API Boundaries
## Context Links
- `plan.md`
- `src/config/unified-config-types.ts`
- `src/config/unified-config-loader.ts`
- `src/cursor/cursor-auth.ts`
- `src/cursor/cursor-daemon-pid.ts`
- `src/cliproxy/config/path-resolver.ts`
- `src/cliproxy/config/env-builder.ts`
- `src/web-server/routes/index.ts`
- `src/web-server/routes/cursor-routes.ts`
- `src/web-server/routes/cursor-settings-routes.ts`
- `src/web-server/routes/cliproxy-stats-routes.ts`
- `src/api/services/profile-lifecycle-service.ts`
## Overview
- Priority: P1
- Owner scope: config schema, path resolution, backend APIs, migration readers
- Goal: make legacy bridge storage explicit and guarantee CLIProxy Cursor never writes the legacy raw settings file
## Key Insights
- Top-level `config.cursor` is bridge-only configuration today and must move.
- The legacy bridge owns `~/.ccs/cursor.settings.json`, `~/.ccs/cursor/credentials.json`, and `~/.ccs/cursor/daemon.pid`.
- CLIProxy provider settings currently resolve through generic provider settings helpers and can still collide with the legacy file for provider `cursor`.
- `~/.ccs/cursor.settings.json` is historically documented as legacy-owned, so it is unsafe to auto-import it into provider storage by default.
## Requirements
- Canonical legacy config key: `legacy.cursor`
- Canonical legacy files:
- `~/.ccs/legacy/cursor.settings.json`
- `~/.ccs/legacy/cursor/credentials.json`
- `~/.ccs/legacy/cursor/daemon.pid`
- Canonical provider file for CLIProxy Cursor only:
- `~/.ccs/cliproxy/cursor.settings.json`
- Canonical legacy API namespace:
- `/api/legacy/cursor/*`
- Compatibility reads:
- read old `config.cursor`
- read old `~/.ccs/cursor.settings.json`
- read old `~/.ccs/cursor/*`
- Compatibility writes:
- write only the new `legacy.*` and `cliproxy/*` paths
## Data Flow
- Legacy config:
`load config -> prefer legacy.cursor -> fallback config.cursor -> normalize -> write legacy.cursor only`
- Legacy raw settings:
`load /api/legacy/cursor/settings/raw -> prefer ~/.ccs/legacy/cursor.settings.json -> fallback ~/.ccs/cursor.settings.json -> write new legacy path`
- Provider settings:
`CLIProxy env builder/stats updater -> read ~/.ccs/cliproxy/cursor.settings.json -> if absent use defaults -> never read/write ~/.ccs/cursor.settings.json`
## Architecture
- Add a `legacy` section to unified config types and loader. Keep old `cursor` as read-only migration input during the compatibility window.
- Move legacy bridge filesystem helpers under a `legacy/cursor` path prefix.
- Split API routing:
- new canonical mount: `/api/legacy/cursor`
- release-N alias: `/api/cursor` -> same handlers + deprecation header
- Special-case CLIProxy provider settings for `cursor` only in the provider path resolver. Do not expand this migration to every provider in this issue.
- Treat existing `~/.ccs/cursor.settings.json` as legacy-owned. Do not auto-copy it into provider storage unless a future explicit provider migration is added.
## Related Code Files
- Modify:
- `src/config/unified-config-types.ts`
- `src/config/unified-config-loader.ts`
- `src/cursor/cursor-auth.ts`
- `src/cursor/cursor-daemon-pid.ts`
- `src/cliproxy/config/path-resolver.ts`
- `src/cliproxy/config/env-builder.ts`
- `src/web-server/routes/index.ts`
- `src/web-server/routes/cursor-routes.ts`
- `src/web-server/routes/cursor-settings-routes.ts`
- `src/web-server/routes/cliproxy-stats-routes.ts`
- `src/api/services/profile-lifecycle-service.ts`
- Create:
- `src/web-server/routes/legacy-cursor-routes.ts`
- `src/web-server/routes/legacy-cursor-settings-routes.ts`
- `src/config/migrations/cursor-legacy-migration.ts` if migration logic should stay out of the loader
## Implementation Steps
1. Extend config types and loader to support `legacy.cursor`, with `legacy.cursor` taking precedence over old `cursor`.
2. Update legacy bridge credential and pid helpers to use `~/.ccs/legacy/cursor/`.
3. Update the raw settings route to use `~/.ccs/legacy/cursor.settings.json` as canonical and old root path as read fallback only.
4. Move legacy API mounts to `/api/legacy/cursor/*` and keep `/api/cursor/*` as a warned alias for release N.
5. Change CLIProxy Cursor provider settings resolution to `~/.ccs/cliproxy/cursor.settings.json`.
6. Update orphan detection and cleanup logic so old `cursor.settings.json` is treated as a migration target, not a permanent provider-owned file.
## Todo List
- [ ] Add `legacy.cursor` config schema and loader precedence
- [ ] Move bridge credentials/pid/raw settings under `~/.ccs/legacy/`
- [x] Add canonical `/api/legacy/cursor/*` routes
- [x] Keep release-N `/api/cursor/*` alias
- [x] Isolate CLIProxy Cursor settings away from `~/.ccs/cursor.settings.json`
- [ ] Update cleanup/orphan handling
## Success Criteria
- Saving legacy bridge settings writes only to `legacy.cursor` and `~/.ccs/legacy/*`.
- CLIProxy Cursor model/env updates write only to `~/.ccs/cliproxy/cursor.settings.json`.
- Existing legacy users can still read old config/files during the compatibility window.
- No backend route that serves the provider path references `~/.ccs/cursor.settings.json`.
## Risk Assessment
- High likelihood / high impact: old `~/.ccs/cursor.settings.json` contents are ambiguous between bridge and provider expectations.
Mitigation: treat the file as legacy-owned and do not auto-import it into provider storage.
- Medium likelihood / medium impact: route aliasing may mask which API is canonical.
Mitigation: add explicit response headers or payload flags marking `/api/cursor/*` as deprecated.
## Rollback Plan
- Keep read fallback from old paths even if the canonical write path changes back.
- If the new legacy API namespace causes regressions, remount `/api/cursor/*` as canonical temporarily and keep the new namespace dormant.
- Do not delete old files during release N; cleanup stays opt-in until release N+2.
## Security Considerations
- Preserve `0600` for migrated credentials and `0700` for directories.
- Use atomic temp-file writes exactly as current routes do.
- Never copy provider tokens into the legacy namespace or legacy tokens into provider storage automatically.
## Next Steps
- Phase 3 depends on the canonical API and path names from this phase.
@@ -0,0 +1,121 @@
---
phase: 3
title: "Dashboard & Deprecation UX"
status: partial
effort: "4h"
---
# Phase 3: Dashboard & Deprecation UX
## Context Links
- `plan.md`
- `ui/src/App.tsx`
- `ui/src/components/layout/app-sidebar.tsx`
- `ui/src/pages/cursor.tsx`
- `ui/src/hooks/use-cursor.ts`
- `ui/src/lib/i18n.ts`
- `src/web-server/routes/index.ts`
- `src/commands/cursor-command-display.ts`
## Overview
- Priority: P1
- Owner scope: dashboard route ownership, labels, user-facing deprecation messaging
- Goal: align dashboard semantics with CLI semantics so `/cursor` means provider and legacy UI is clearly marked and isolated
## Key Insights
- The current dashboard already admits the bridge is deprecated, but the route `/cursor` still belongs to it.
- The page includes direct navigation to CLIProxy Cursor, which means the UX already wants a split; the route layer just has not caught up.
- Keeping `/cursor` for legacy while CLI uses `cursor` for provider would create the same ambiguity in a different surface.
## Requirements
- `/cursor` must become the provider-owned dashboard surface.
- The legacy bridge page must move to `/legacy/cursor`.
- Legacy bridge API hooks must move to `/api/legacy/cursor/*`.
- The deprecated UX must contain exact replacements, not generic warnings.
- Sidebar grouping must reflect support level:
- provider view under provider/cliproxy navigation
- legacy bridge under deprecated navigation
## Data Flow
- Provider dashboard:
`browser /cursor -> provider view or redirect wrapper -> /cliproxy?provider=cursor -> existing CLIProxy provider APIs`
- Legacy dashboard:
`browser /legacy/cursor -> legacy bridge page -> useLegacyCursor hook -> /api/legacy/cursor/*`
- Compatibility API path, release N only:
`old UI/tests -> /api/cursor/* -> alias handler -> same legacy payload + deprecation signal`
## Architecture
- Keep provider UI DRY by making `/cursor` a thin redirect or preselected wrapper around the existing CLIProxy provider page instead of building a second Cursor-provider page.
- Move the current `ui/src/pages/cursor.tsx` implementation to a new `legacy-cursor` page and rename its hook to `useLegacyCursor`.
- Change nav labels from generic "Cursor IDE" to explicit "Cursor Bridge (Legacy)" in the deprecated section.
- Update CLI and dashboard warnings to show both paths side-by-side:
- `ccs cursor --auth` / `/cursor`
- `ccs legacy cursor auth` / `/legacy/cursor`
## Related Code Files
- Modify:
- `ui/src/App.tsx`
- `ui/src/components/layout/app-sidebar.tsx`
- `ui/src/lib/i18n.ts`
- `src/commands/cursor-command-display.ts`
- Move or rename:
- `ui/src/pages/cursor.tsx` -> `ui/src/pages/legacy-cursor.tsx`
- `ui/src/hooks/use-cursor.ts` -> `ui/src/hooks/use-legacy-cursor.ts`
- Create:
- `ui/src/pages/cursor-provider-redirect.tsx` if a wrapper is preferred over direct router config
## Implementation Steps
1. Move the legacy page and hook to `legacy-*` names and update all imports.
2. Reassign `/cursor` to the provider path and add `/legacy/cursor` for the bridge page.
3. Update sidebar grouping and labels so the provider path is no longer listed under Deprecated.
4. Replace vague deprecated copy with concrete migration copy:
- old command
- new command
- old route
- new route
5. Keep the legacy page banner persistent until release N+2, not dismissible per session.
## Todo List
- [ ] Move legacy page/hook module names to `legacy-*`
- [x] Reassign `/cursor` and add `/legacy/cursor`
- [x] Update deprecated nav group and labels
- [x] Rewrite key banners, button copy, and path labels with exact replacements
- [x] Keep provider and legacy links visible from both surfaces during release N
## Success Criteria
- Opening `/cursor` lands on the CLIProxy Cursor provider surface.
- Opening `/legacy/cursor` lands on the bridge page with a persistent deprecation banner.
- No dashboard component serving the provider route uses the legacy API hook.
- Every warning banner shows the exact before/after command and route.
## Risk Assessment
- Medium likelihood / medium impact: users with bookmarked `/cursor` expect the legacy page.
Mitigation: provider page shows a top-level "Looking for the old bridge?" callout linking to `/legacy/cursor`.
- Low likelihood / medium impact: UI rename churn breaks lazy imports or tests.
Mitigation: do route and hook rename in one phase and leave compatibility API alias in place until tests pass.
## Rollback Plan
- Point `/cursor` back to the legacy page if the provider redirect breaks.
- Keep `/legacy/cursor` additive; it does not block rollback.
- Do not remove the deprecation banner on rollback; it still communicates future intent.
## Security Considerations
- No auth secrets should be exposed in UI copy or route params.
- Keep manual auth dialogs scoped to the legacy page only. Provider auth remains in CLIProxy flows.
## Next Steps
- Phase 4 owns test rewrites, docs updates, and release gating for these UI changes.
@@ -0,0 +1,148 @@
---
phase: 4
title: "Tests Docs & Rollout"
status: complete
effort: "4h"
---
# Phase 4: Tests Docs & Rollout
## Context Links
- `plan.md`
- `docs/cursor-integration.md`
- `README.md`
- `docs/system-architecture/provider-flows.md`
- `docs/system-architecture/index.md`
- `tests/unit/cursor/cursor-shortcut-routing.test.ts`
- `tests/unit/web-server/cursor-settings-routes.test.ts`
- `tests/unit/web-server/cursor-routes.test.ts`
- `ui/tests/unit/hooks/use-cursor.test.tsx`
- `ui/tests/unit/ui/pages/cursor-page.test.tsx`
## Overview
- Priority: P1
- Owner scope: compatibility rollout, validation, docs/help updates, release notes
- Goal: ship the namespace split without surprising existing bridge users or leaving docs/help inconsistent
## Key Insights
- This change has one intentional breaking behavior: positional `ccs cursor` stops being the legacy bridge.
- Everything else can use a compatibility window: admin subcommands, API aliases, old config reads, old file-path reads.
- Tests must lock both meanings so the ambiguity does not regress later.
## Requirements
- Document exact before/after commands and routes.
- Add a concrete migration path for three user groups:
- legacy bridge users
- CLIProxy Cursor users
- dashboard bookmark users
- Define removal windows for aliases and old path fallbacks.
- Run repo quality gates after implementation:
- root: `bun run format && bun run lint:fix && bun run validate && bun run validate:ci-parity`
- UI: `cd ui && bun run format && bun run lint:fix && bun run validate`
## Test Matrix
- Unit:
- provider-first cursor routing
- legacy alias forwarding
- `legacy.cursor` loader precedence
- path resolvers for legacy vs provider files
- deprecation help text snapshots
- Integration:
- `ccs cursor "task"` -> provider
- `ccs legacy cursor "task"` -> bridge
- `/api/legacy/cursor/*` canonical behavior
- `/api/cursor/*` alias behavior during release N
- UI:
- `/cursor` route ownership
- `/legacy/cursor` banner and actions
- hook path changes and raw settings save targets
- Manual release validation:
- migrate old config/files in a temp `CCS_HOME`
- verify provider path never writes `~/.ccs/cursor.settings.json`
## User Migration Plan
1. Legacy bridge users:
- replace `ccs cursor ...` with `ccs legacy cursor ...`
- run `ccs legacy cursor status`
- update scripts and dashboard bookmarks to `/legacy/cursor`
2. CLIProxy Cursor users:
- keep using `ccs cursor ...`
- if provider-specific settings are needed, re-save them under the new provider-owned path instead of relying on `~/.ccs/cursor.settings.json`
3. Mixed/unclear state:
- `ccs migrate` should move `config.cursor` and legacy files into the new legacy namespace
- do not auto-copy the old raw settings file into provider storage
## Deprecation UX Plan
- CLI warning text, release N:
- `ccs cursor auth` is deprecated. Use `ccs legacy cursor auth` for the old bridge or `ccs cursor --auth` for CLIProxy Cursor.
- Dashboard banner:
- visible on `/legacy/cursor`
- provider route links back to legacy route with "Looking for the old bridge?"
- Docs banner:
- top callout in `docs/cursor-integration.md` pointing users to CLIProxy Cursor as the supported path
## Related Code Files
- Modify tests:
- `tests/unit/cursor/cursor-shortcut-routing.test.ts`
- `tests/unit/web-server/cursor-settings-routes.test.ts`
- `tests/unit/web-server/cursor-routes.test.ts`
- `ui/tests/unit/hooks/use-cursor.test.tsx`
- `ui/tests/unit/ui/pages/cursor-page.test.tsx`
- Modify docs:
- `docs/cursor-integration.md`
- `README.md` if root command examples mention Cursor
- `docs/system-architecture/provider-flows.md`
- `docs/system-architecture/index.md`
- CLI help snapshots or generated references if present
## Implementation Steps
1. Rewrite tests around the new command contract and route ownership before removing aliases in later releases.
2. Update docs/help text in the same PR as code changes so the new syntax ships atomically.
3. Add migration notes to changelog/release notes with a bold callout that `ccs cursor "task"` now means CLIProxy Cursor.
4. Keep a removal checklist for release N+1 and N+2 in the plan or roadmap so the compatibility window does not become permanent.
## Todo List
- [x] Update unit, integration, and selected UI tests
- [x] Update docs and CLI help text
- [x] Add migration note and deprecation wording
- [x] Run root and UI quality gates
- [x] Record alias-removal follow-up for N+1 and old-path-removal follow-up for N+2
## Success Criteria
- Test suite covers both provider and legacy cursor paths explicitly.
- Docs and help text match the shipped command contract exactly.
- Release notes include the migration table and deprecation window.
- Quality gates pass in both root and `ui/`.
## Risk Assessment
- High likelihood / medium impact: docs or tests lag behind the command flip and users keep invoking the wrong surface.
Mitigation: block merge until help text, docs, and tests all match the new contract.
- Medium likelihood / medium impact: compatibility shims never get removed.
Mitigation: create follow-up issues or roadmap entries for N+1 and N+2 removal work before merge.
## Rollback Plan
- If rollout messaging is incomplete, revert the command flip before removing aliases.
- If only docs/help are wrong, fix docs first and keep aliases until corrected.
- Old-path readers stay in place through N+1, so rollback does not strand migrated users.
## Security Considerations
- Use temp `CCS_HOME` in tests and manual verification. Never touch the real `~/.ccs`.
- Sanitize any migration logs or warnings so they mention paths, not token contents.
## Next Steps
- Implementation is complete when all four phases land together; do not ship phase 1 without phases 2-4.
@@ -0,0 +1,93 @@
---
title: "Separate legacy Cursor bridge from CLIProxy Cursor provider"
description: "Reserve `cursor` for the CLIProxy provider, move the reverse-engineered bridge under `legacy`, and split storage/UI with a staged migration."
status: in_progress
priority: P1
effort: 2d
branch: kai/feat/1016-missing-provider-integration
tags: [cursor, cliproxy, migration, dashboard, deprecation]
created: 2026-04-15
blockedBy: []
blocks: []
---
# Separate legacy Cursor bridge from CLIProxy Cursor provider
## Goal
Make `cursor` mean one thing everywhere: the CLIProxy-backed provider. Move the deprecated local bridge to `legacy`, stop provider writes to `~/.ccs/cursor.settings.json`, and ship a low-risk migration window.
## Current Collision Points
- `src/ccs.ts` hardcodes `cursor` as a legacy command/profile, then reclaims only `--auth|--logout|--config|--accounts` for CLIProxy.
- `src/auth/profile-detector.ts` resolves `cursor` to the legacy runtime before CLIProxy provider detection.
- `src/commands/command-catalog.ts` and `src/commands/help-command.ts` advertise `cursor` as both bridge and provider.
- `src/config/unified-config-types.ts` + `src/config/unified-config-loader.ts` store bridge config under top-level `cursor`.
- `src/cliproxy/config/path-resolver.ts`, `src/cliproxy/config/env-builder.ts`, and `src/web-server/routes/cliproxy-stats-routes.ts` still use provider settings paths that collide with the legacy raw file.
- `src/web-server/routes/cursor-*.ts`, `ui/src/pages/cursor.tsx`, `ui/src/hooks/use-cursor.ts`, `ui/src/App.tsx`, and `ui/src/components/layout/app-sidebar.tsx` dedicate `/cursor` and `/api/cursor/*` to the legacy bridge.
- `docs/cursor-integration.md` documents `ccs cursor` as the bridge even though CLIProxy already exposes a `cursor` provider shortcut.
## Command Contract
Before:
```text
ccs cursor -> legacy bridge runtime
ccs cursor "task" -> legacy bridge runtime
ccs cursor auth|status|... -> legacy bridge admin
ccs cursor --auth|--config -> CLIProxy Cursor shortcut
```
After release N:
```text
ccs cursor -> CLIProxy Cursor runtime
ccs cursor "task" -> CLIProxy Cursor runtime
ccs cursor --auth|--config -> CLIProxy Cursor admin
ccs legacy cursor -> legacy bridge runtime
ccs legacy cursor "task" -> legacy bridge runtime
ccs legacy cursor auth|... -> legacy bridge admin
```
Compatibility window, release N only:
- `ccs cursor auth|status|probe|models|start|stop|enable|disable|help` forwards to `ccs legacy cursor ...` with a deprecation warning.
- Bare and positional `ccs cursor` switch immediately to the provider path; no silent legacy fallback.
## Phase Plan
| Phase | Scope | Output |
| --- | --- | --- |
| 1 | [CLI Routing & Namespacing](./phase-01-cli-routing-namespacing.md) | Provider-first `cursor`, explicit `legacy cursor`, updated help/catalog/type names |
| 2 | [Storage & API Boundaries](./phase-02-storage-api-boundaries.md) | `legacy.cursor` config, split file paths, `/api/legacy/cursor/*`, provider path isolation |
| 3 | [Dashboard & Deprecation UX](./phase-03-dashboard-deprecation-ux.md) | `/cursor` -> provider view, `/legacy/cursor` -> bridge view, clear migration UX |
| 4 | [Tests Docs & Rollout](./phase-04-tests-docs-rollout.md) | Compatibility plan, migration steps, test matrix, docs updates, rollback gates |
## Rollout Sequence
1. Release N: add new legacy namespace, flip `ccs cursor` to provider, keep old admin subcommands and `/api/cursor/*` as warned aliases, and split provider settings away from `~/.ccs/cursor.settings.json`.
2. Release N+1: move the remaining legacy backend/config namespaces fully under `legacy.cursor`, keep old file-path fallback and `/api/cursor/*` alias for one more release.
3. Release N+2: remove old `config.cursor` and root-level `~/.ccs/cursor*` fallback reads, delete stale alias docs/help, and let cleanup/migrate remove leftovers.
## Current Implementation Status
- Completed in this branch:
- `ccs cursor` is provider-first for runtime and `--help`
- `ccs legacy cursor` works as the explicit legacy bridge namespace
- old legacy admin subcommands under `ccs cursor ...` forward with deprecation warnings
- CLIProxy Cursor settings no longer collide with `~/.ccs/cursor.settings.json`
- `/cursor` redirects to the provider surface while `/legacy/cursor` serves the deprecated bridge page
- `/api/legacy/cursor/*` is mounted and the legacy page uses that namespace
- docs, completion, and core regression tests were updated
- Intentionally deferred follow-up:
- move top-level `config.cursor` to `legacy.cursor`
- move legacy credentials/pid/raw settings fully under `~/.ccs/legacy/cursor/*`
- rename `use-cursor` and `CursorPage` modules to explicit `legacy-*`
## Success Criteria
- `cursor` is provider-owned in CLI help, routing, dashboard nav, and docs.
- Legacy bridge is reachable only through `legacy cursor` and `legacy.cursor` storage.
- CLIProxy Cursor never reads or writes `~/.ccs/cursor.settings.json`.
- Existing legacy users have an explicit migration path, warning UX, and rollback-safe compatibility window.
## Docs Impact
Major. CLI reference, Cursor docs, dashboard tour, provider docs, and migration notes all change in the same release.
+20 -20
View File
@@ -26,6 +26,7 @@ import { getCcsDir } from '../utils/config-manager';
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
import type { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
import type { TargetType } from '../targets/target-adapter';
import type { ProfileType } from '../types/profile';
@@ -253,9 +254,8 @@ class ProfileDetector {
* Detect profile type and return routing information
*
* Priority order:
* 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen)
* 0.5. Copilot profile (if enabled in config)
* 0.75. Cursor profile (if enabled in config)
* 0. Hardcoded special runtime profiles (copilot, cursor)
* 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...)
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
* 3. Settings-based profiles (config.profiles section) [legacy]
@@ -267,16 +267,7 @@ class ProfileDetector {
return this.resolveDefaultProfile();
}
// Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config
if (isCLIProxyProvider(profileName)) {
return {
type: 'cliproxy',
name: profileName,
provider: profileName,
};
}
// Priority 0.5: Check Copilot profile - GitHub Copilot subscription via copilot-api
// Priority 0: Check Copilot profile - GitHub Copilot subscription via copilot-api
if (profileName === 'copilot') {
const unifiedConfig = this.readUnifiedConfig();
const copilotConfig = unifiedConfig?.copilot;
@@ -306,17 +297,17 @@ class ProfileDetector {
};
}
// Priority 0.75: Check Cursor profile - local Cursor daemon runtime
if (profileName === 'cursor') {
// Priority 0.25: Check explicit legacy Cursor bridge profile.
if (profileName === LEGACY_CURSOR_PROFILE_NAME) {
const cursorConfig = getCursorConfig();
if (!cursorConfig?.enabled) {
const error = new Error(
'Cursor profile is not enabled.\n\n' +
'Legacy Cursor profile is not enabled.\n\n' +
'To enable Cursor integration:\n' +
' 1. Run: ccs cursor enable\n' +
' 2. Import auth: ccs cursor auth\n' +
' 3. Start daemon: ccs cursor start\n\n' +
' 1. Run: ccs legacy cursor enable\n' +
' 2. Import auth: ccs legacy cursor auth\n' +
' 3. Start daemon: ccs legacy cursor start\n\n' +
'Or manually edit ~/.ccs/config.yaml:\n' +
' cursor:\n' +
' enabled: true'
@@ -329,11 +320,20 @@ class ProfileDetector {
return {
type: 'cursor',
name: 'cursor',
name: LEGACY_CURSOR_PROFILE_NAME,
cursorConfig,
};
}
// Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...)
if (isCLIProxyProvider(profileName)) {
return {
type: 'cliproxy',
name: profileName,
provider: profileName,
};
}
// Priority 1: Try unified config if available
const unifiedConfig = this.readUnifiedConfig();
if (unifiedConfig) {
+53 -6
View File
@@ -65,7 +65,8 @@ import {
resolveOfficialChannelsLaunchPlan,
} from './channels/official-channels-runtime';
import { getOfficialChannelReadiness } from './channels/official-channels-store';
import { isCursorSubcommandToken } from './cursor/constants';
import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants';
import { isCLIProxyProvider } from './cliproxy/provider-capabilities';
// Import centralized error handling
import { handleError, runCleanup } from './errors';
@@ -77,6 +78,7 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
import type { ProfileDetectionResult } from './auth/profile-detector';
// Import target adapter system
import {
@@ -147,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(
args: string[],
envThinkingValue: string | undefined
@@ -341,7 +363,7 @@ async function main(): Promise<void> {
registerTarget(new CodexAdapter());
const cliLogger = createLogger('cli');
const args = process.argv.slice(2);
let args = process.argv.slice(2);
const isCompletionCommand = args[0] === '__complete';
// Initialize UI colors early to ensure consistent colored output
@@ -417,6 +439,8 @@ async function main(): Promise<void> {
return;
}
args = normalizeLegacyCursorArgs(args);
cliLogger.info('command.start', 'CLI invocation started', {
command: args[0] || 'default',
argCount: args.length,
@@ -484,6 +508,17 @@ async function main(): Promise<void> {
return;
}
if (
typeof firstArg === 'string' &&
isCLIProxyProvider(firstArg) &&
args.length > 1 &&
(args.includes('--help') || args.includes('-h'))
) {
const { showProviderShortcutHelp } = await import('./commands/help-command');
await showProviderShortcutHelp(firstArg);
return;
}
// Special case: copilot command (GitHub Copilot integration)
// Route known subcommands to command handler, keep all other args as profile passthrough.
if (firstArg === 'copilot' && args.length > 1) {
@@ -497,9 +532,8 @@ async function main(): Promise<void> {
}
}
// Special case: cursor command (Cursor local proxy integration)
// Route known admin subcommands to the command handler, keep all other args as profile passthrough.
if (firstArg === 'cursor' && args.length > 1) {
// Special case: explicit legacy Cursor bridge namespace.
if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) {
const { handleCursorCommand } = await import('./commands/cursor-command');
const cursorToken = args[1];
@@ -509,6 +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
// Check independently of recovery status (user may have empty config.yaml)
// Skip if headless, CI, or non-TTY environment
@@ -538,7 +585,7 @@ async function main(): Promise<void> {
// Detect profile (strip --target flags before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
const profileInfo = detector.detectProfileType(profile);
const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
let resolvedTarget: ReturnType<typeof resolveTargetType>;
try {
resolvedTarget = resolveTargetType(
+38
View File
@@ -155,6 +155,10 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google'
* - Qwen: Device Code Flow (polling-based, NO callback port needed)
* - GHCP: Device Code Flow (polling-based, NO callback port needed)
* - Kimi: Device Code Flow (polling-based, NO callback port needed)
* - Cursor: Device-style browser polling (NO callback port needed)
* - GitLab: Authorization Code Flow with callback server on port 17171
* - CodeBuddy: Device-style browser polling (NO callback port needed)
* - Kilo: Device Code Flow (polling-based, NO callback port needed)
*/
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> =
CLIPROXY_PROVIDER_IDS.reduce(
@@ -274,6 +278,34 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
scopes: ['api'],
authFlag: '--kimi-login',
},
cursor: {
provider: 'cursor',
displayName: 'Cursor',
authUrl: 'https://cursor.com/loginDeepControl',
scopes: [],
authFlag: '--cursor-login',
},
gitlab: {
provider: 'gitlab',
displayName: 'GitLab Duo',
authUrl: 'https://gitlab.com/oauth/authorize',
scopes: ['api', 'read_user'],
authFlag: '--gitlab-login',
},
codebuddy: {
provider: 'codebuddy',
displayName: 'CodeBuddy (Tencent)',
authUrl: 'https://copilot.tencent.com/v2/plugin/auth/state',
scopes: [],
authFlag: '--codebuddy-login',
},
kilo: {
provider: 'kilo',
displayName: 'Kilo AI',
authUrl: 'https://api.kilo.ai/api/device-auth/codes',
scopes: [],
authFlag: '--kilo-login',
},
};
/**
@@ -373,6 +405,12 @@ export interface OAuthOptions {
noIncognito?: boolean;
/** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */
import?: boolean;
/** GitLab auth mode override. */
gitlabAuthMode?: 'oauth' | 'pat';
/** GitLab self-hosted base URL override. */
gitlabBaseUrl?: string;
/** GitLab personal access token for PAT login. */
gitlabPersonalAccessToken?: string;
/** Enable paste-callback mode: show auth URL and prompt for callback paste */
pasteCallback?: boolean;
/** If true, use port-forwarding mode (skip interactive prompt in headless) */
+90
View File
@@ -0,0 +1,90 @@
const GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH = 500;
const GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
const HTML_ERROR_RESPONSE_OMITTED = '[HTML error response omitted]';
function sanitizeGitLabPatErrorDetail(
detail: string | undefined,
submittedToken?: string
): string | undefined {
const trimmed = detail?.trim();
if (!trimmed) {
return undefined;
}
if (/^<!doctype html/i.test(trimmed) || /^<html/i.test(trimmed) || /^<[^>]+>/.test(trimmed)) {
return HTML_ERROR_RESPONSE_OMITTED;
}
let sanitized = trimmed.replace(
/"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token|personal_access_token)"\s*:\s*"[^"]*"/gi,
'"$1":"[redacted]"'
);
if (submittedToken) {
sanitized = sanitized.split(submittedToken).join('[redacted]');
}
sanitized = sanitized
.replace(/glpat-[A-Za-z0-9._-]+/gi, '[redacted]')
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]')
.replace(/\s+/g, ' ');
if (sanitized.length > GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH) {
sanitized = `${sanitized.slice(
0,
GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH - GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX.length
)}${GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX}`;
}
return sanitized;
}
export function parseGitLabPatAuthResponse(
responseOk: boolean,
responseStatus: number,
responseBody: string,
submittedToken?: string
):
| { ok: true; payload: Record<string, unknown> }
| { ok: false; payload: Record<string, unknown>; errorMessage: string } {
const trimmedBody = responseBody.trim();
let payload: Record<string, unknown> = {};
if (trimmedBody) {
try {
payload = JSON.parse(trimmedBody) as Record<string, unknown>;
} catch {
payload = {
error:
sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) ||
`GitLab PAT login failed with status ${responseStatus}`,
};
}
}
const payloadError =
typeof payload.error === 'string'
? sanitizeGitLabPatErrorDetail(payload.error, submittedToken)
: undefined;
const fallbackError =
sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) ||
`GitLab PAT login failed with status ${responseStatus}`;
if (!responseOk) {
return {
ok: false,
payload,
errorMessage: payloadError || fallbackError,
};
}
if (payload.status !== 'ok') {
return {
ok: false,
payload,
errorMessage: payloadError || fallbackError,
};
}
return { ok: true, payload };
}
+183 -4
View File
@@ -56,6 +56,7 @@ import {
} from './token-manager';
import { executeOAuthProcess } from './oauth-process';
import { importKiroToken } from './kiro-import';
import { parseGitLabPatAuthResponse } from './gitlab-pat-response';
import {
getProxyTarget,
buildProxyUrl,
@@ -69,6 +70,7 @@ import {
warnPossible403Ban,
} from '../account-safety';
import { ensureCliAntigravityResponsibility } from '../antigravity-responsibility';
import { InteractivePrompt } from '../../utils/prompt';
interface PasteCallbackStartData {
url?: string;
@@ -83,9 +85,12 @@ const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
export async function requestPasteCallbackStart(
provider: CLIProxyProvider,
target: ProxyTarget,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
options?: {
kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
}
): Promise<PasteCallbackStartData> {
const startPath = getPasteCallbackStartPath(provider, {
let startPath = getPasteCallbackStartPath(provider, {
kiroMethod: options?.kiroMethod,
});
if (!startPath) {
@@ -93,6 +98,11 @@ export async function requestPasteCallbackStart(
`Paste-callback start is not available for ${provider} with the selected method`
);
}
const normalizedGitLabBaseUrl =
provider === 'gitlab' ? normalizeGitLabBaseUrl(options?.gitlabBaseUrl) : undefined;
if (normalizedGitLabBaseUrl) {
startPath += `&base_url=${encodeURIComponent(normalizedGitLabBaseUrl)}`;
}
const response = await fetch(buildProxyUrl(target, startPath), {
headers: buildManagementHeaders(target),
});
@@ -142,6 +152,49 @@ function parseAuthUrlState(url: string | null | undefined): string | null {
}
}
export function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined {
const normalized = baseUrl?.trim();
if (!normalized) {
return undefined;
}
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error('GitLab URL must be a valid http:// or https:// URL');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('GitLab URL must use http:// or https://');
}
parsed.hash = '';
parsed.search = '';
parsed.username = '';
parsed.password = '';
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
return normalizedPath ? `${parsed.origin}${normalizedPath}` : parsed.origin;
}
export async function promptGitLabPersonalAccessToken(): Promise<string | null> {
try {
const token = (await InteractivePrompt.password('GitLab Personal Access Token')).trim();
return token.length > 0 ? token : null;
} catch (error) {
if ((error as Error).message.includes('TTY')) {
console.log(
fail(
'GitLab Personal Access Token prompt requires an interactive TTY. Set the token explicitly or use Browser OAuth.'
)
);
return null;
}
throw error;
}
}
export function findNewTokenSnapshotForManualAuth(
provider: CLIProxyProvider,
tokenDir: string,
@@ -458,7 +511,10 @@ async function handlePasteCallbackMode(
tokenDir: string,
nickname?: string,
expectedAccountId?: string,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
options?: {
kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
}
): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget();
@@ -647,6 +703,91 @@ async function handlePasteCallbackMode(
}
}
async function handleGitLabPatLogin(
provider: CLIProxyProvider,
oauthConfig: ProviderOAuthConfig,
verbose: boolean,
tokenDir: string,
nickname?: string,
expectedAccountId?: string,
options?: {
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
gitlabPersonalAccessToken?: OAuthOptions['gitlabPersonalAccessToken'];
}
): Promise<AccountInfo | null> {
const target = getProxyTarget();
const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl);
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
const suppliedToken = options?.gitlabPersonalAccessToken?.trim();
const personalAccessToken =
suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
let token = personalAccessToken;
if (!token) {
console.log('');
console.log(info(`Starting ${oauthConfig.displayName} PAT login...`));
console.log('Paste a Personal Access Token with api and read_user scopes.');
token = (await promptGitLabPersonalAccessToken()) || undefined;
}
if (!token) {
console.log(info('Cancelled'));
return null;
}
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildManagementHeaders(target),
},
body: JSON.stringify({
...(baseUrl ? { base_url: baseUrl } : {}),
personal_access_token: token,
}),
});
const responseBody = await response.text();
const parsedResponse = parseGitLabPatAuthResponse(
response.ok,
response.status,
responseBody,
token
);
if (!parsedResponse.ok) {
console.log(fail(parsedResponse.errorMessage));
return null;
}
const tokenSnapshot = findNewTokenSnapshotForAuthAttempt(
provider,
tokenDir,
knownTokenFiles,
expectedAccountId
);
if (!tokenSnapshot) {
console.log(fail('GitLab PAT login completed, but CCS could not find the saved token file.'));
return null;
}
const account = registerAccountFromToken(
provider,
tokenDir,
nickname,
verbose,
expectedAccountId || tokenSnapshot.file
);
if (!account) {
console.log(fail('Authenticated GitLab token could not be registered as a CCS account.'));
return null;
}
console.log(ok('Authentication successful!'));
return account;
}
/**
* Trigger OAuth flow for provider
* Auto-detects headless environment and uses --no-browser flag accordingly
@@ -666,6 +807,17 @@ export async function triggerOAuth(
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
const resolvedKiroIDCFlow =
provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW;
const resolvedGitLabAuthMode =
provider === 'gitlab' && options.gitlabAuthMode === 'pat' ? 'pat' : 'oauth';
let resolvedGitLabBaseUrl: string | undefined;
if (provider === 'gitlab') {
try {
resolvedGitLabBaseUrl = normalizeGitLabBaseUrl(options.gitlabBaseUrl);
} catch (error) {
console.log(fail((error as Error).message));
return null;
}
}
if (provider === 'agy') {
if (fromUI && !acceptAgyRisk) {
@@ -744,6 +896,14 @@ export async function triggerOAuth(
}
}
if (provider === 'gitlab' && resolvedGitLabBaseUrl && !selectedPasteCallback) {
selectedPasteCallback = true;
console.log('');
console.log(
info('GitLab custom base URL selected. Switching to paste-callback mode for OAuth.')
);
}
const useSelectedKiroLocalPasteCallback =
selectedPasteCallback &&
provider === 'kiro' &&
@@ -765,6 +925,22 @@ export async function triggerOAuth(
}
}
if (provider === 'gitlab' && resolvedGitLabAuthMode === 'pat') {
const tokenDir = getProviderTokenDir(provider);
return handleGitLabPatLogin(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id,
{
gitlabBaseUrl: resolvedGitLabBaseUrl,
gitlabPersonalAccessToken: options.gitlabPersonalAccessToken,
}
);
}
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
@@ -774,7 +950,10 @@ export async function triggerOAuth(
tokenDir,
nickname,
existingNameMatch?.id,
{ kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined }
{
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
}
);
}
+73 -4
View File
@@ -19,7 +19,10 @@ import {
normalizeProtocol,
CLIPROXY_DEFAULT_PORT,
} from './port-manager';
import { getProviderSettingsPath } from './path-resolver';
import {
getLegacyProviderSettingsPath,
migrateLegacyProviderSettingsIfNeeded,
} from './path-resolver';
import {
canonicalizeModelIdForProvider,
MODEL_ENV_VAR_KEYS,
@@ -49,6 +52,15 @@ const REQUIRED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_API_KEY',
]);
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stripCodexEffortSuffix(modelId: string): string {
return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
@@ -285,6 +297,63 @@ export function getClaudeEnvVars(
return normalizeModelEnvVarsForProvider(mergedEnv, provider);
}
function buildCursorProviderSettingsFromLegacy(
legacySettings: Record<string, unknown>
): Record<string, unknown> {
const defaultEnv = getClaudeEnvVars('cursor');
const legacyEnvSource = legacySettings.env;
const legacyEnv = isObjectRecord(legacyEnvSource) ? legacyEnvSource : {};
const migratedEnv: NodeJS.ProcessEnv = { ...defaultEnv };
for (const [key, value] of Object.entries(legacyEnv)) {
if (typeof value !== 'string' || CURSOR_LEGACY_ENV_OVERRIDE_KEYS.has(key)) {
continue;
}
migratedEnv[key] = value;
}
delete migratedEnv.ANTHROPIC_API_KEY;
return {
...legacySettings,
env: normalizeModelEnvVarsForProvider(migratedEnv, 'cursor'),
};
}
/**
* Resolve the provider settings path, migrating legacy Cursor provider settings into
* the dedicated cliproxy/providers namespace on first access.
*/
export function resolveProviderSettingsPath(provider: CLIProxyProvider): string {
const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider);
if (provider !== 'cursor' || fs.existsSync(settingsPath)) {
return settingsPath;
}
const legacySettingsPath = getLegacyProviderSettingsPath(provider);
if (!fs.existsSync(legacySettingsPath)) {
return settingsPath;
}
try {
const parsed = JSON.parse(fs.readFileSync(legacySettingsPath, 'utf-8')) as unknown;
if (!isObjectRecord(parsed)) {
return settingsPath;
}
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(
settingsPath,
JSON.stringify(buildCursorProviderSettingsFromLegacy(parsed), null, 2) + '\n',
{ mode: 0o600 }
);
} catch {
// Best-effort migration only. Callers will fall back to defaults if the legacy file is invalid.
}
return settingsPath;
}
/**
* Get global env vars to inject into all third-party profiles.
* Returns empty object if disabled.
@@ -465,7 +534,7 @@ export function getEffectiveEnvVars(
}
// Priority 2: Default provider settings file
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
// Check for user override file
if (fs.existsSync(settingsPath)) {
@@ -505,7 +574,7 @@ export function getEffectiveEnvVars(
* Called during installation/first run
*/
export function ensureProviderSettings(provider: CLIProxyProvider): void {
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
const defaultEnv = getClaudeEnvVars(provider);
const writeSettings = (settings: Record<string, unknown>): void => {
@@ -663,7 +732,7 @@ export function getRemoteEnvVars(
// Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json)
if (Object.keys(userEnvVars).length === 0) {
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
if (fs.existsSync(settingsPath)) {
try {
const content = fs.readFileSync(settingsPath, 'utf-8');
+42
View File
@@ -16,6 +16,13 @@ export function getCliproxyDir(): string {
return path.join(getCcsDir(), 'cliproxy');
}
/**
* Get CLIProxy provider settings directory.
*/
export function getCliproxyProvidersDir(): string {
return path.join(getCliproxyDir(), 'providers');
}
/**
* Get CLIProxy writable directory for logs and runtime files.
* This directory is set as WRITABLE_PATH env var when spawning CLIProxy.
@@ -75,5 +82,40 @@ export function getBinDir(): string {
* Example: ~/.ccs/gemini.settings.json
*/
export function getProviderSettingsPath(provider: CLIProxyProvider): string {
if (provider === 'cursor') {
return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`);
}
return getLegacyProviderSettingsPath(provider);
}
/**
* Get CLIProxy provider settings path in the dedicated cliproxy/providers namespace.
* Used only for providers that must not collide with legacy top-level settings files.
*/
export function getDedicatedProviderSettingsPath(provider: CLIProxyProvider): string {
return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`);
}
/**
* Get legacy provider settings file path in ~/.ccs root.
* This is kept for compatibility reads/migration of older provider settings.
*/
export function getLegacyProviderSettingsPath(provider: CLIProxyProvider): string {
return path.join(getCcsDir(), `${provider}.settings.json`);
}
/**
* Resolve the effective provider settings path.
*
* Cursor uses a dedicated cliproxy/providers namespace so it does not collide
* with the deprecated Cursor IDE bridge raw settings file.
*/
export function migrateLegacyProviderSettingsIfNeeded(provider: CLIProxyProvider): string {
if (provider !== 'cursor') {
return getProviderSettingsPath(provider);
}
const targetPath = getDedicatedProviderSettingsPath(provider);
return targetPath;
}
+32
View File
@@ -152,6 +152,14 @@ export function readOptionValue(
return { present: true, value: next.trim(), missingValue: false };
}
export function hasGitLabTokenLoginFlag(args: string[]): boolean {
return args.includes('--gitlab-token-login') || args.includes('--token-login');
}
function getGitLabTokenLoginFlagName(args: string[]): '--gitlab-token-login' | '--token-login' {
return args.includes('--gitlab-token-login') ? '--gitlab-token-login' : '--token-login';
}
/**
* Execute Claude CLI with CLIProxy (main entry point)
*
@@ -353,6 +361,7 @@ export async function execClaudeWithCLIProxy(
const addAccount = argsWithoutProxy.includes('--add');
const showAccounts = argsWithoutProxy.includes('--accounts');
const forceImport = argsWithoutProxy.includes('--import');
const gitlabTokenLogin = hasGitLabTokenLoginFlag(argsWithoutProxy);
const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy);
const incognitoFlag = argsWithoutProxy.includes('--incognito');
@@ -444,6 +453,16 @@ export async function execClaudeWithCLIProxy(
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
}
let gitlabBaseUrl: string | undefined;
const gitlabBaseUrlValue = readOptionValue(argsWithoutProxy, '--gitlab-url');
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
} else if (gitlabBaseUrlValue.present) {
console.error(fail('--gitlab-url requires a value'));
process.exitCode = 1;
return;
}
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
process.exitCode = 1;
@@ -491,6 +510,15 @@ export async function execClaudeWithCLIProxy(
return;
}
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
const flagName = gitlabTokenLogin
? getGitLabTokenLoginFlagName(argsWithoutProxy)
: '--gitlab-url';
console.error(fail(`${flagName} is only valid for ccs gitlab`));
process.exitCode = 1;
return;
}
// Parse --thinking / --effort flags (aliases; first occurrence wins)
const thinkingParse = parseThinkingOverride(argsWithoutProxy);
if (thinkingParse.error) {
@@ -730,6 +758,8 @@ export async function execClaudeWithCLIProxy(
...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}),
...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}),
...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}),
...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
@@ -775,6 +805,8 @@ export async function execClaudeWithCLIProxy(
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}),
...(gitlabBaseUrl ? { gitlabBaseUrl } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
+5 -5
View File
@@ -9,7 +9,7 @@ import * as fs from 'fs';
import * as os from 'os';
import { InteractivePrompt } from '../utils/prompt';
import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog';
import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator';
import { getClaudeEnvVars, resolveProviderSettingsPath } from './config-generator';
import { CLIProxyProvider } from './types';
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
@@ -31,7 +31,7 @@ function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string)
* Check if provider has user settings configured
*/
export function hasUserSettings(provider: CLIProxyProvider): boolean {
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
return fs.existsSync(settingsPath);
}
@@ -46,7 +46,7 @@ export function getCurrentModel(
): string | undefined {
const settingsPath = customSettingsPath
? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
: resolveProviderSettingsPath(provider);
if (!fs.existsSync(settingsPath)) return undefined;
try {
@@ -116,7 +116,7 @@ export async function configureProviderModel(
// Use custom settings path for CLIProxy variants, otherwise use default provider path
const settingsPath = customSettingsPath
? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
: resolveProviderSettingsPath(provider);
// Skip if already configured with a model (unless --config flag).
// A settings file can exist without model env keys (e.g., hook-only writes).
@@ -249,7 +249,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
await initUI();
const currentModel = getCurrentModel(provider);
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
const normalizedCurrentModel = currentModel
? canonicalizeModelForProvider(provider, currentModel)
: undefined;
+48
View File
@@ -134,6 +134,54 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
tokenTypeValues: ['kimi'],
aliases: ['moonshot'],
},
cursor: {
displayName: 'Cursor',
description: 'Cursor browser-authenticated provider',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'cursor',
authUrlProviderName: 'cursor',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['cursor.', 'cursor-'],
tokenTypeValues: ['cursor'],
aliases: [],
},
gitlab: {
displayName: 'GitLab Duo',
description: 'GitLab Duo with OAuth or PAT auth',
oauthFlow: 'authorization_code',
callbackPort: 17171,
callbackProviderName: 'gitlab',
authUrlProviderName: 'gitlab',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['gitlab-'],
tokenTypeValues: ['gitlab'],
aliases: ['gitlab-duo'],
},
codebuddy: {
displayName: 'CodeBuddy (Tencent)',
description: 'Tencent CodeBuddy AI assistant',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'codebuddy',
authUrlProviderName: 'codebuddy',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['codebuddy-'],
tokenTypeValues: ['codebuddy'],
aliases: ['tencent'],
},
kilo: {
displayName: 'Kilo AI',
description: 'Kilo AI coding assistant',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'kilo',
authUrlProviderName: 'kilo',
refreshOwnership: 'unsupported',
authFilePrefixes: ['kilo-'],
tokenTypeValues: ['kilo'],
aliases: [],
},
};
export const CLIPROXY_PROVIDER_IDS = Object.freeze(
+19 -4
View File
@@ -122,6 +122,10 @@ export interface DownloadResult {
* - ghcp: GitHub Copilot via Device Code (OAuth through CLIProxyAPIPlus)
* - claude: Claude (Anthropic) via OAuth
* - kimi: Kimi (Moonshot AI) via Device Code OAuth
* - cursor: Cursor via PKCE browser polling
* - gitlab: GitLab Duo via OAuth or PAT
* - codebuddy: Tencent CodeBuddy via browser polling
* - kilo: Kilo AI via device flow
*/
export type CLIProxyProvider =
| 'gemini'
@@ -132,12 +136,16 @@ export type CLIProxyProvider =
| 'kiro'
| 'ghcp'
| 'claude'
| 'kimi';
| 'kimi'
| 'cursor'
| 'gitlab'
| 'codebuddy'
| 'kilo';
/**
* CLIProxy backend selection
* - original: CLIProxyAPI (no Kiro/ghcp support)
* - plus: CLIProxyAPIPlus (Kiro/ghcp support, default)
* - original: CLIProxyAPI (legacy provider subset)
* - plus: CLIProxyAPIPlus (expanded provider support, default)
*/
export type CLIProxyBackend = 'original' | 'plus';
@@ -149,7 +157,14 @@ export type CliproxyRoutingStrategy = 'round-robin' | 'fill-first';
/**
* Providers that require CLIProxyAPIPlus backend
*/
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = ['kiro', 'ghcp'];
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = [
'kiro',
'ghcp',
'cursor',
'gitlab',
'codebuddy',
'kilo',
];
/**
* CLIProxy config.yaml structure (minimal)
+13 -3
View File
@@ -1,5 +1,4 @@
import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
import { CURSOR_SUBCOMMANDS } from '../cursor/constants';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets';
@@ -105,7 +104,7 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
},
{
name: 'cursor',
summary: 'Run or manage the Cursor bridge',
summary: 'Run Cursor via CLIProxy or manage Cursor provider auth',
group: 'runtime',
visibility: 'public',
},
@@ -183,6 +182,10 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
ghcp: 'GitHub Copilot via CLIProxy OAuth',
claude: 'Claude via CLIProxy OAuth',
kimi: 'Kimi via CLIProxy OAuth',
cursor: 'Cursor via CLIProxy OAuth',
gitlab: 'GitLab Duo via CLIProxy OAuth',
codebuddy: 'CodeBuddy via CLIProxy OAuth',
kilo: 'Kilo AI via CLIProxy OAuth',
}[name] || 'CLIProxy OAuth provider',
})
);
@@ -304,7 +307,14 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
update: ['--force', '--beta', '--dev', '--help', '-h'],
};
export const CURSOR_COMPLETION_SUBCOMMANDS = [...CURSOR_SUBCOMMANDS] as const;
export const CURSOR_COMPLETION_SUBCOMMANDS = [
'--auth',
'--accounts',
'--config',
'--logout',
'--help',
'-h',
] as const;
export const COPILOT_COMPLETION_SUBCOMMANDS = [...COPILOT_SUBCOMMANDS, 'help'] as const;
export function getPublicRootCommands(): readonly RootCommandEntry[] {
+46 -30
View File
@@ -4,6 +4,9 @@ import type { CursorConfig } from '../config/unified-config-types';
import { getCcsDirDisplay } from '../utils/config-manager';
import { color } from '../utils/ui';
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
function printLines(lines: string[]): void {
for (const line of lines) {
console.log(line);
@@ -12,37 +15,46 @@ function printLines(lines: string[]): void {
export function renderCursorHelp(): number {
printLines([
'Cursor IDE Integration',
'Legacy Cursor Compatibility',
'',
'Usage: ccs cursor <subcommand>',
'Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.',
`Supported auth path: ${CLIPROXY_CURSOR_COMMAND} --auth`,
'Supported dashboard path: ccs config -> CLIProxy -> Cursor',
'',
'Subcommands:',
' auth Import Cursor IDE authentication token',
' status Show integration, authentication, and daemon status',
`Usage: ${LEGACY_CURSOR_COMMAND} <subcommand>`,
'',
'Subcommands (deprecated compatibility for the local reverse-engineered bridge):',
' auth Import Cursor IDE authentication token (deprecated)',
' status Show legacy integration, authentication, and daemon status',
' probe Run a live authenticated runtime probe',
' models List available models',
' start Start cursor daemon',
' stop Stop cursor daemon',
' enable Enable cursor integration in unified config',
' disable Disable cursor integration in unified config',
' start Start local cursor daemon',
' stop Stop local cursor daemon',
' enable Enable legacy cursor integration in unified config',
' disable Disable legacy cursor integration in unified config',
' help Show this help message',
'',
'Runtime entry:',
' ccs cursor [claude args] # Run Claude via the local Cursor proxy',
'Supported CLIProxy path:',
` ${CLIPROXY_CURSOR_COMMAND} --auth # Authenticate Cursor via CLIProxy`,
` ${CLIPROXY_CURSOR_COMMAND} --accounts # Manage CLIProxy Cursor accounts`,
` ${CLIPROXY_CURSOR_COMMAND} --config # Open CLIProxy Cursor settings`,
'',
'Auth options:',
' ccs cursor auth # Auto-detect from Cursor SQLite',
' ccs cursor auth --manual --token <t> --machine-id <id>',
'Legacy runtime entry (deprecated compatibility):',
` ${LEGACY_CURSOR_COMMAND} [claude args] # Run Claude via the local Cursor bridge`,
'',
'Quick start:',
' 1. ccs cursor enable # Enable integration',
' 2. ccs cursor auth # Import Cursor IDE token',
' 3. ccs cursor start # Start daemon',
' 4. ccs cursor probe # Verify live runtime health',
' 5. ccs cursor "task" # Run Claude through Cursor',
' 6. ccs cursor status # Inspect auth/daemon wiring',
'Legacy auth options:',
` ${LEGACY_CURSOR_COMMAND} auth # Auto-detect from Cursor SQLite (deprecated)`,
` ${LEGACY_CURSOR_COMMAND} auth --manual --token <t> --machine-id <id>`,
'',
'Or use the web UI: ccs config -> Cursor page',
'Legacy bridge quick start:',
` 1. ${LEGACY_CURSOR_COMMAND} enable # Deprecated compatibility: enable local bridge`,
` 2. ${LEGACY_CURSOR_COMMAND} auth # Deprecated compatibility: import Cursor IDE token`,
` 3. ${LEGACY_CURSOR_COMMAND} start # Start local daemon`,
` 4. ${LEGACY_CURSOR_COMMAND} probe # Verify live runtime health`,
` 5. ${LEGACY_CURSOR_COMMAND} "task" # Run Claude through the local bridge`,
` 6. ${LEGACY_CURSOR_COMMAND} status # Inspect auth/daemon wiring`,
'',
'Web UI: ccs config -> Deprecated -> Cursor IDE',
'',
]);
@@ -100,10 +112,13 @@ export function renderCursorStatus(
console.log('');
console.log('Client setup:');
console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`);
console.log(' Runtime entry: ccs cursor [claude args]');
console.log(' Live probe: ccs cursor probe');
console.log(' Status command: ccs cursor status');
console.log(' Help command: ccs cursor help');
console.log(
` Runtime entry: ${LEGACY_CURSOR_COMMAND} [claude args] (deprecated compatibility)`
);
console.log(` Supported auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` Live probe: ${LEGACY_CURSOR_COMMAND} probe`);
console.log(` Status command: ${LEGACY_CURSOR_COMMAND} status`);
console.log(` Help command: ${LEGACY_CURSOR_COMMAND} help`);
if (isReady) {
return;
@@ -113,15 +128,16 @@ export function renderCursorStatus(
console.log('Next steps:');
if (!cursorConfig.enabled) {
console.log(' - Enable: ccs cursor enable');
console.log(` - Enable: ${LEGACY_CURSOR_COMMAND} enable`);
}
if (!authStatus.authenticated || authStatus.expired) {
console.log(' - Auth: ccs cursor auth');
console.log(` - Supported: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` - Legacy auth: ${LEGACY_CURSOR_COMMAND} auth`);
}
if (!daemonStatus.running) {
console.log(' - Start: ccs cursor start');
console.log(` - Start: ${LEGACY_CURSOR_COMMAND} start`);
}
console.log(' - Help: ccs cursor help');
console.log(` - Help: ${LEGACY_CURSOR_COMMAND} help`);
}
export function renderCursorModels(models: CursorModel[], defaultModel: string): void {
+36 -13
View File
@@ -26,6 +26,18 @@ import {
} from './cursor-command-display';
import { ok, fail, info } from '../utils/ui';
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
function printLegacyCursorDeprecationNotice(): void {
console.log(
info(
`Deprecated compatibility path. \`${CLIPROXY_CURSOR_COMMAND}\` now belongs to the CLIProxy Cursor provider; use \`${LEGACY_CURSOR_COMMAND}\` for the old bridge.`
)
);
console.log('');
}
/**
* Handle cursor subcommand.
*/
@@ -114,6 +126,7 @@ function printAutoDetectFailure(result: {
* Handle auth subcommand.
*/
async function handleAuth(args: string[]): Promise<number> {
printLegacyCursorDeprecationNotice();
const manual = args.includes('--manual');
if (manual) {
@@ -125,7 +138,7 @@ async function handleAuth(args: string[]): Promise<number> {
if (!accessToken || !machineId) {
console.error(
fail(
'Manual auth requires both token and machine ID.\n\nExample:\n ccs cursor auth --manual --token <token> --machine-id <machine-id>'
`Manual auth requires both token and machine ID.\n\nExample:\n ${LEGACY_CURSOR_COMMAND} auth --manual --token <token> --machine-id <machine-id>`
)
);
return 1;
@@ -146,8 +159,9 @@ async function handleAuth(args: string[]): Promise<number> {
console.log(ok('Cursor credentials imported (manual mode)'));
console.log('');
console.log('Next steps:');
console.log(' 1. Enable integration: ccs cursor enable');
console.log(' 2. Start daemon: ccs cursor start');
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`);
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
return 0;
}
@@ -168,9 +182,10 @@ async function handleAuth(args: string[]): Promise<number> {
console.log(ok('Auto-detected Cursor credentials'));
console.log('');
console.log('Next steps:');
console.log(' 1. Enable integration: ccs cursor enable');
console.log(' 2. Start daemon: ccs cursor start');
console.log(' 3. Check status: ccs cursor status');
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`);
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`);
return 0;
}
@@ -178,13 +193,14 @@ async function handleAuth(args: string[]): Promise<number> {
printAutoDetectFailure(autoResult);
console.log('');
console.log('Manual fallback:');
console.log(' ccs cursor auth --manual --token <token> --machine-id <machine-id>');
console.log(` ${LEGACY_CURSOR_COMMAND} auth --manual --token <token> --machine-id <machine-id>`);
console.log('');
return 1;
}
async function handleStatus(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
const authStatus = checkAuthStatus();
const daemonStatus = await getDaemonStatus(cursorConfig.port);
@@ -193,6 +209,7 @@ async function handleStatus(): Promise<number> {
}
async function handleProbe(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
const result = await probeCursorRuntime(cursorConfig);
renderCursorProbe(result);
@@ -200,6 +217,7 @@ async function handleProbe(): Promise<number> {
}
async function handleModels(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
const models = await getAvailableModels(cursorConfig.port);
const defaultModel = getDefaultModel();
@@ -211,20 +229,21 @@ async function handleModels(): Promise<number> {
* Handle start subcommand.
*/
async function handleStart(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
if (!cursorConfig.enabled) {
console.error(fail('Cursor integration is disabled. Run: ccs cursor enable'));
console.error(fail(`Cursor integration is disabled. Run: ${LEGACY_CURSOR_COMMAND} enable`));
return 1;
}
const authStatus = checkAuthStatus();
if (!authStatus.authenticated) {
console.error(fail('Not authenticated. Run: ccs cursor auth'));
console.error(fail(`Not authenticated. Run: ${LEGACY_CURSOR_COMMAND} auth`));
return 1;
}
if (authStatus.expired) {
console.error(fail('Credentials expired. Run: ccs cursor auth'));
console.error(fail(`Credentials expired. Run: ${LEGACY_CURSOR_COMMAND} auth`));
return 1;
}
@@ -248,6 +267,7 @@ async function handleStart(): Promise<number> {
* Handle stop subcommand.
*/
async function handleStop(): Promise<number> {
printLegacyCursorDeprecationNotice();
console.log(info('Stopping cursor daemon...'));
const result = await stopDaemon();
@@ -265,6 +285,7 @@ async function handleStop(): Promise<number> {
* Handle enable subcommand.
*/
async function handleEnable(): Promise<number> {
printLegacyCursorDeprecationNotice();
mutateUnifiedConfig((config) => {
if (!config.cursor) {
config.cursor = { ...DEFAULT_CURSOR_CONFIG };
@@ -276,9 +297,10 @@ async function handleEnable(): Promise<number> {
console.log(ok('Cursor integration enabled'));
console.log('');
console.log('Next steps:');
console.log(' 1. Authenticate: ccs cursor auth');
console.log(' 2. Start daemon: ccs cursor start');
console.log(' 3. Check status: ccs cursor status');
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` 1. Authenticate: ${LEGACY_CURSOR_COMMAND} auth`);
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`);
return 0;
}
@@ -287,6 +309,7 @@ async function handleEnable(): Promise<number> {
* Handle disable subcommand.
*/
async function handleDisable(): Promise<number> {
printLegacyCursorDeprecationNotice();
mutateUnifiedConfig((config) => {
if (config.cursor) {
config.cursor.enabled = false;
+72 -2
View File
@@ -1,4 +1,5 @@
import packageJson from '../../package.json';
import type { CLIProxyProvider } from '../cliproxy';
import { color, dim, header, initUI, subheader } from '../utils/ui';
import {
BUILTIN_PROVIDER_SHORTCUTS,
@@ -82,10 +83,80 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
],
writeLine
);
writeCommandTable(
'GitLab Duo Flags',
[
{
name: 'ccs gitlab --auth --gitlab-token-login',
summary: 'Authenticate with a GitLab Personal Access Token',
},
{
name: 'ccs gitlab --auth --token-login',
summary: 'Legacy alias for GitLab PAT login (still supported)',
},
{
name: 'ccs gitlab --auth --gitlab-url <url>',
summary: 'Use a self-hosted GitLab base URL during OAuth or PAT auth',
},
],
writeLine
);
writeLine(` ${dim('Deep help: ccs cliproxy --help | ccs api --help')}`);
writeLine('');
}
export async function showProviderShortcutHelp(
provider: CLIProxyProvider,
writeLine: HelpWriter = console.log
): Promise<void> {
if (provider === 'kiro') {
await showKiroHelp(writeLine);
return;
}
await initUI();
const providerEntry = BUILTIN_PROVIDER_SHORTCUTS.find((entry) => entry.name === provider);
writeLine(header(`CCS ${provider} Shortcut Help`));
writeLine('');
writeLine(` ${providerEntry?.summary || 'CLIProxy OAuth provider shortcut'}.`);
writeLine('');
writeCommandTable(
'Common Commands',
[
{ name: `ccs ${provider} --auth`, summary: 'Authenticate the provider account via CLIProxy' },
{ name: `ccs ${provider} --accounts`, summary: 'List or manage stored CLIProxy accounts' },
{ name: `ccs ${provider} --config`, summary: 'Open the provider config flow' },
{ name: `ccs ${provider} "task"`, summary: 'Run Claude through this provider shortcut' },
],
writeLine
);
if (provider === 'gitlab') {
writeCommandTable(
'GitLab Duo Flags',
[
{
name: '--gitlab-token-login',
summary: 'Use a GitLab Personal Access Token instead of browser OAuth',
},
{
name: '--token-login',
summary: 'Legacy alias for `--gitlab-token-login`',
},
{
name: '--gitlab-url <url>',
summary: 'Target a self-hosted GitLab base URL',
},
],
writeLine
);
}
writeLine(` ${dim('See also: ccs help providers | ccs cliproxy --help')}`);
writeLine('');
}
async function showKiroHelp(writeLine: HelpWriter): Promise<void> {
await initUI();
writeLine(header('CCS Kiro Help'));
@@ -273,8 +344,7 @@ export async function handleHelpRoute(
cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(),
copilot: async () =>
process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])),
cursor: async () =>
process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])),
cursor: async () => await showProviderShortcutHelp('cursor', writeLine),
docker: async () => (await import('./docker/help-subcommand')).showHelp(),
migrate: async () => (await import('./migrate-command')).printMigrateHelp(),
setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']),
+9
View File
@@ -9,10 +9,19 @@ export const RESERVED_PROFILE_NAMES = [
'agy',
'qwen',
'iflow',
'kiro',
'ghcp',
'claude',
'kimi',
'gitlab',
'codebuddy',
'kilo',
// Copilot API (GitHub Copilot proxy)
'copilot',
// Cursor IDE (Cursor proxy daemon)
'cursor',
'legacy-cursor',
'legacy',
// CLI commands and special names
'default',
'config',
+29
View File
@@ -1,3 +1,5 @@
export const LEGACY_CURSOR_PROFILE_NAME = 'legacy-cursor';
export const CURSOR_SUBCOMMANDS = [
'auth',
'status',
@@ -12,8 +14,35 @@ export const CURSOR_SUBCOMMANDS = [
'-h',
] as const;
export const CURSOR_CLIPROXY_SHORTCUT_FLAGS = new Set([
'--auth',
'--logout',
'--config',
'--accounts',
]);
export function isCursorSubcommandToken(token?: string): boolean {
return (
Boolean(token) && CURSOR_SUBCOMMANDS.includes(token as (typeof CURSOR_SUBCOMMANDS)[number])
);
}
export function shouldUseCursorCliproxyShortcut(args: string[]): boolean {
if (args[0] !== 'cursor') {
return false;
}
for (const token of args.slice(1)) {
if (token === '--') {
break;
}
if (CURSOR_CLIPROXY_SHORTCUT_FLAGS.has(token)) {
return true;
}
if (!token.startsWith('-')) {
return false;
}
}
return false;
}
+2 -2
View File
@@ -248,7 +248,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
const authStatus = checkAuthStatus();
if (!authStatus.authenticated || !authStatus.credentials) {
const message = 'Cursor credentials not found. Run `ccs cursor auth` first.';
const message = 'Cursor credentials not found. Run `ccs legacy cursor auth` first.';
if (isAnthropicRoute) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(401, 'authentication_error', message),
@@ -266,7 +266,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
}
if (authStatus.expired) {
const message = 'Cursor credentials expired. Run `ccs cursor auth` again.';
const message = 'Cursor credentials expired. Run `ccs legacy cursor auth` again.';
if (isAnthropicRoute) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(401, 'authentication_error', message),
+4 -4
View File
@@ -97,7 +97,7 @@ export async function executeCursorProfile(
if (!config.enabled) {
console.error(fail('Cursor integration is not enabled.'));
console.error('');
console.error('Enable it first: ccs cursor enable');
console.error('Enable it first: ccs legacy cursor enable');
return 1;
}
@@ -105,13 +105,13 @@ export async function executeCursorProfile(
if (!authStatus.authenticated) {
console.error(fail('Cursor credentials not found.'));
console.error('');
console.error('Authenticate first: ccs cursor auth');
console.error('Authenticate first: ccs legacy cursor auth');
return 1;
}
if (authStatus.expired) {
console.error(fail('Cursor credentials have expired.'));
console.error('');
console.error('Refresh them with: ccs cursor auth');
console.error('Refresh them with: ccs legacy cursor auth');
return 1;
}
@@ -133,7 +133,7 @@ export async function executeCursorProfile(
console.error(fail('Cursor daemon is not running.'));
console.error('');
console.error('Start the daemon:');
console.error(' ccs cursor start');
console.error(' ccs legacy cursor start');
console.error('Or enable auto_start in the Cursor config section.');
return 1;
}
+3 -3
View File
@@ -120,7 +120,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
stage: 'auth',
status: 401,
duration_ms: Date.now() - startedAt,
message: 'Cursor credentials not found. Run `ccs cursor auth` first.',
message: 'Cursor credentials not found. Run `ccs legacy cursor auth` first.',
error_type: 'authentication_error',
};
}
@@ -131,7 +131,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
stage: 'auth',
status: 401,
duration_ms: Date.now() - startedAt,
message: 'Cursor credentials expired. Run `ccs cursor auth` again.',
message: 'Cursor credentials expired. Run `ccs legacy cursor auth` again.',
error_type: 'authentication_error',
};
}
@@ -168,7 +168,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
status: 503,
duration_ms: Date.now() - startedAt,
message:
'Cursor daemon is not running. Start it with `ccs cursor start` or enable auto_start.',
'Cursor daemon is not running. Start it with `ccs legacy cursor start` or enable auto_start.',
error_type: 'daemon_not_running',
};
}
+1 -1
View File
@@ -121,7 +121,7 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] {
'default',
...all.accounts,
...all.settings,
...all.cliproxy,
...all.cliproxy.filter((profileName) => profileName !== 'cursor'),
...all.cliproxyVariants,
];
const deduped = [...new Set(orderedNames)];
@@ -396,7 +396,7 @@ function resolveBackend(
};
}
if (profileType === 'cursor' || profileName === 'cursor') {
if (profileType === 'cursor') {
return {
backendId: null,
backendDisplayName: null,
+149 -1
View File
@@ -43,6 +43,7 @@ import {
listProviderTokenSnapshots,
registerAccountFromToken,
} from '../../cliproxy/auth/token-manager';
import { parseGitLabPatAuthResponse } from '../../cliproxy/auth/gitlab-pat-response';
import {
CLIPROXY_CALLBACK_PROVIDER_MAP,
CLIPROXY_AUTH_URL_PROVIDER_MAP,
@@ -229,6 +230,20 @@ function parseKiroIDCFlow(raw: unknown): { flow: KiroIDCFlow; invalid: boolean }
return { flow: normalizeKiroIDCFlow(normalized), invalid: false };
}
function parseGitLabAuthMode(raw: unknown): { mode: 'oauth' | 'pat'; invalid: boolean } {
if (raw === undefined || raw === null || raw === '') {
return { mode: 'oauth', invalid: false };
}
if (typeof raw !== 'string') {
return { mode: 'oauth', invalid: true };
}
const normalized = raw.trim().toLowerCase();
if (normalized === 'oauth' || normalized === 'pat') {
return { mode: normalized, invalid: false };
}
return { mode: 'oauth', invalid: true };
}
export function getKiroStartIDCValidationError(options: {
kiroMethod: KiroAuthMethod;
kiroIDCStartUrl?: string;
@@ -603,6 +618,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const kiroIDCRegion =
typeof requestBody.kiroIDCRegion === 'string' ? requestBody.kiroIDCRegion.trim() : undefined;
const kiroIDCFlowRaw = requestBody.kiroIDCFlow;
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
const gitlabBaseUrl =
typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined;
const gitlabPersonalAccessToken =
typeof requestBody.gitlabPersonalAccessToken === 'string'
? requestBody.gitlabPersonalAccessToken.trim()
: undefined;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const target = getProxyTarget();
if (target.isRemote) {
@@ -613,6 +635,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
const { flow: kiroIDCFlow, invalid: invalidKiroIDCFlow } = parseKiroIDCFlow(kiroIDCFlowRaw);
const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } =
parseGitLabAuthMode(gitlabAuthModeRaw);
// Validate provider
if (!validProviders.includes(provider as CLIProxyProvider)) {
@@ -628,6 +652,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
return;
}
if (provider === 'gitlab' && invalidGitLabAuthMode) {
res.status(400).json({
error: 'Invalid gitlabAuthMode. Supported: oauth, pat',
code: 'INVALID_GITLAB_AUTH_MODE',
});
return;
}
if (provider === 'kiro') {
const kiroIDCValidationError = getKiroStartIDCValidationError({
kiroMethod,
@@ -651,6 +683,92 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
}
}
if (provider === 'gitlab' && gitlabAuthMode === 'pat') {
if (!gitlabPersonalAccessToken) {
res.status(400).json({
error: 'gitlabPersonalAccessToken is required when gitlabAuthMode=pat',
code: 'MISSING_GITLAB_PAT',
});
return;
}
try {
const localProvider = provider as CLIProxyProvider;
const knownTokenFiles = listProviderTokenSnapshots(localProvider);
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildManagementHeaders(target),
},
body: JSON.stringify({
...(gitlabBaseUrl ? { base_url: gitlabBaseUrl } : {}),
personal_access_token: gitlabPersonalAccessToken,
}),
});
const responseBody = await response.text();
const parsedResponse = parseGitLabPatAuthResponse(
response.ok,
response.status,
responseBody,
gitlabPersonalAccessToken
);
if (!parsedResponse.ok) {
res.status(response.ok ? 400 : response.status).json({
error: parsedResponse.errorMessage || 'GitLab PAT authentication failed',
});
return;
}
const tokenSnapshot = findNewTokenSnapshot(
listProviderTokenSnapshots(localProvider),
knownTokenFiles
);
if (!tokenSnapshot) {
res.status(409).json({
error: 'GitLab PAT authentication completed, but CCS could not find the saved token.',
});
return;
}
const account = registerAccountFromToken(
localProvider,
getProviderTokenDir(localProvider),
nickname,
false,
tokenSnapshot.file
);
if (!account) {
res.status(409).json({
error: 'GitLab PAT authentication succeeded, but account registration failed.',
});
return;
}
try {
await ensureManagedModelPrefixes([account.provider]);
} catch {
// Keep auth success path non-fatal when prefix repair cannot run.
}
res.json({
success: true,
account: {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
},
});
return;
} catch (error) {
respondInternalError(res, error, 'Failed to start GitLab PAT flow.');
return;
}
}
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
@@ -681,6 +799,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
kiroIDCRegion: provider === 'kiro' ? kiroIDCRegion : undefined,
kiroIDCFlow: provider === 'kiro' && kiroMethod === 'idc' ? kiroIDCFlow : undefined,
gitlabAuthMode: provider === 'gitlab' ? gitlabAuthMode : undefined,
gitlabBaseUrl: provider === 'gitlab' ? gitlabBaseUrl : undefined,
fromUI: true, // Enable project selection prompt in UI
noIncognito, // Kiro: use normal browser if enabled
});
@@ -837,9 +957,14 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
const gitlabBaseUrl =
typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } =
parseGitLabAuthMode(gitlabAuthModeRaw);
// Check remote mode
const target = getProxyTarget();
@@ -862,6 +987,22 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return;
}
if (provider === 'gitlab' && invalidGitLabAuthMode) {
res.status(400).json({
error: 'Invalid gitlabAuthMode. Supported: oauth, pat',
code: 'INVALID_GITLAB_AUTH_MODE',
});
return;
}
if (provider === 'gitlab' && gitlabAuthMode === 'pat') {
res.status(400).json({
error: 'GitLab PAT login must use /api/cliproxy/auth/gitlab/start',
code: 'GITLAB_PAT_REQUIRES_START',
});
return;
}
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
if (!validation.valid) {
@@ -900,11 +1041,18 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
provider === 'kiro' && kiroManagementMethod
? `&method=${encodeURIComponent(kiroManagementMethod)}`
: '';
const gitlabQuery =
provider === 'gitlab' && gitlabBaseUrl
? `&base_url=${encodeURIComponent(gitlabBaseUrl)}`
: '';
// Call CLIProxyAPI to start OAuth and get auth URL
// CLIProxyAPI management routes are under /v0/management prefix
const response = await fetch(
buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}`),
buildProxyUrl(
target,
`/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}`
),
{ headers: buildManagementHeaders(target) }
);
+1
View File
@@ -104,6 +104,7 @@ apiRoutes.use('/copilot', copilotRoutes);
// ==================== Cursor ====================
apiRoutes.use('/cursor', cursorRoutes);
apiRoutes.use('/legacy/cursor', cursorRoutes);
// ==================== Droid ====================
apiRoutes.use('/droid', droidRoutes);
+12
View File
@@ -30,6 +30,7 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar
import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import { resolveProviderSettingsPath } from '../../cliproxy/config/env-builder';
import { expandPath } from '../../utils/helpers';
import {
canonicalizeModelIdForProvider,
@@ -101,6 +102,17 @@ function resolveSettingsPath(profileOrVariant: string): string {
const ccsDir = getCcsDir();
const resolvedCcsDir = path.resolve(ccsDir);
const directProvider = mapExternalProviderName(profileOrVariant);
if (directProvider) {
if (profileOrVariant !== directProvider) {
return resolvePathWithin(
resolvedCcsDir,
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
);
}
return path.resolve(resolveProviderSettingsPath(directProvider));
}
// Check if this is a variant
const variants = listVariants();
const variant = variants[profileOrVariant];
@@ -74,7 +74,7 @@ describe('cursor daemon lifecycle smoke', () => {
};
expect(anthropicBody.type).toBe('error');
expect(anthropicBody.error?.type).toBe('authentication_error');
expect(anthropicBody.error?.message).toContain('Run `ccs cursor auth` first');
expect(anthropicBody.error?.message).toContain('Run `ccs legacy cursor auth` first');
const stopResult = await stopDaemon();
expect(stopResult.success).toBe(true);
+23 -4
View File
@@ -87,17 +87,36 @@ describe('npm CLI', () => {
});
it('routes cursor probe through the cursor command handler', function() {
let output = '';
try {
execSync(`bun "${srcCcsPath}" cursor probe`, {
output = execSync(`bun "${srcCcsPath}" cursor probe`, {
encoding: 'utf8',
stdio: 'pipe',
timeout: 3000,
env: { ...process.env, CCS_HOME: testCcsHome }
});
} catch (e) {
const 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');
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') || 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() {
const output = execSync(`bun "${srcCcsPath}" gitlab --help`, {
encoding: 'utf8',
timeout: 3000,
env: { ...process.env, CCS_HOME: testCcsHome }
});
assert(output.includes('CCS gitlab Shortcut Help'), 'Should render provider shortcut help');
assert(output.includes('--gitlab-token-login'), 'Should document canonical GitLab PAT flag');
assert(output.includes('--token-login'), 'Should document legacy GitLab PAT alias');
assert(output.includes('--gitlab-url <url>'), 'Should document self-hosted GitLab URL flag');
assert(!output.includes('Starting GitLab Duo OAuth'), 'Should not start OAuth when help is requested');
});
});
+22 -7
View File
@@ -93,6 +93,12 @@ describe('ProfileDetector', () => {
expect(result.provider).toBe('gemini');
});
it('should detect newly added cliproxy providers', () => {
expect(detector.detectProfileType('gitlab').provider).toBe('gitlab');
expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy');
expect(detector.detectProfileType('kilo').provider).toBe('kilo');
});
it('should detect settings-based profile from unified config', () => {
const settingsPath = path.join(tempDir, 'glm.settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } }));
@@ -196,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 getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({
enabled: true,
@@ -207,9 +220,9 @@ describe('ProfileDetector', () => {
});
try {
const result = detector.detectProfileType('cursor');
const result = detector.detectProfileType('legacy-cursor');
expect(result.type).toBe('cursor');
expect(result.name).toBe('cursor');
expect(result.name).toBe('legacy-cursor');
expect(result.cursorConfig?.auto_start).toBe(true);
} finally {
isUnifiedModeSpy.mockRestore();
@@ -217,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;
process.env.CCS_HOME = tempDir;
const ccsDir = path.join(tempDir, '.ccs');
@@ -229,7 +242,7 @@ describe('ProfileDetector', () => {
try {
const localDetector = new ProfileDetector();
const result = localDetector.detectProfileType('cursor');
const result = localDetector.detectProfileType('legacy-cursor');
expect(result.type).toBe('cursor');
expect(result.cursorConfig).toEqual({
enabled: true,
@@ -247,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 getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({
enabled: false,
@@ -258,7 +271,9 @@ describe('ProfileDetector', () => {
});
try {
expect(() => detector.detectProfileType('cursor')).toThrow(/Cursor profile is not enabled/);
expect(() => detector.detectProfileType('legacy-cursor')).toThrow(
/Legacy Cursor profile is not enabled/
);
} finally {
isUnifiedModeSpy.mockRestore();
getCursorConfigSpy.mockRestore();
@@ -121,6 +121,13 @@ describe('Backend Selection', () => {
assert(types.PLUS_ONLY_PROVIDERS.includes('ghcp'));
});
it('includes the newer CLIProxyAPIPlus providers as plus-only', () => {
assert(types.PLUS_ONLY_PROVIDERS.includes('cursor'));
assert(types.PLUS_ONLY_PROVIDERS.includes('gitlab'));
assert(types.PLUS_ONLY_PROVIDERS.includes('codebuddy'));
assert(types.PLUS_ONLY_PROVIDERS.includes('kilo'));
});
it('does not include gemini as plus-only provider', () => {
assert(!types.PLUS_ONLY_PROVIDERS.includes('gemini'));
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
import { loadBaseConfig } from '../../../src/cliproxy/base-config-loader';
describe('base-config-loader new providers', () => {
it.each([
['cursor', '/api/provider/cursor', 'composer-2'],
['gitlab', '/api/provider/gitlab', 'gitlab-duo'],
['codebuddy', '/api/provider/codebuddy', 'auto'],
['kilo', '/api/provider/kilo', 'kilo/auto'],
] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => {
const config = loadBaseConfig(provider);
expect(config.env.ANTHROPIC_BASE_URL).toContain(baseUrlPath);
expect(config.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
expect(config.env.ANTHROPIC_MODEL).toBe(defaultModel);
expect(config.env.ANTHROPIC_DEFAULT_OPUS_MODEL.length).toBeGreaterThan(0);
expect(config.env.ANTHROPIC_DEFAULT_SONNET_MODEL.length).toBeGreaterThan(0);
expect(config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL.length).toBeGreaterThan(0);
});
});
@@ -318,6 +318,65 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeDefined();
});
it('imports legacy cursor settings into the dedicated provider path without reusing legacy transport auth', () => {
process.env.CCS_HOME = tempHome;
const legacySettingsPath = path.join(tempHome, '.ccs', 'cursor.settings.json');
const providerSettingsPath = path.join(
tempHome,
'.ccs',
'cliproxy',
'providers',
'cursor.settings.json'
);
fs.mkdirSync(path.dirname(legacySettingsPath), { recursive: true });
fs.writeFileSync(
legacySettingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:20129',
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
ANTHROPIC_API_KEY: 'legacy-cursor-api-key',
ANTHROPIC_MODEL: 'claude-4-sonnet',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-4-opus',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-4-sonnet',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'cursor-small',
ANTHROPIC_SMALL_FAST_MODEL: 'cursor-small',
DISABLE_TELEMETRY: '1',
},
hooks: {
PreToolUse: [{ matcher: 'Read', hooks: [] }],
},
},
null,
2
)
);
const env = getEffectiveEnvVars('cursor');
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/cursor');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
expect(env.ANTHROPIC_MODEL).toBe('claude-4-sonnet');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-4-opus');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('cursor-small');
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('cursor-small');
expect(env.DISABLE_TELEMETRY).toBe('1');
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
const migrated = JSON.parse(fs.readFileSync(providerSettingsPath, 'utf-8')) as {
env: Record<string, string>;
hooks?: Record<string, unknown>;
};
expect(migrated.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/cursor');
expect(migrated.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
expect(migrated.env.ANTHROPIC_MODEL).toBe('claude-4-sonnet');
expect(migrated.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-4-opus');
expect(migrated.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('cursor-small');
expect(migrated.env.ANTHROPIC_API_KEY).toBeUndefined();
expect(migrated.hooks?.PreToolUse).toBeDefined();
});
it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => {
process.env.CCS_HOME = tempHome;
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'bun:test';
import { readOptionValue } from '../../../src/cliproxy/executor/index';
import {
hasGitLabTokenLoginFlag,
readOptionValue,
} from '../../../src/cliproxy/executor/index';
describe('readOptionValue', () => {
it('parses split-token option values', () => {
@@ -30,4 +33,10 @@ describe('readOptionValue', () => {
missingValue: true,
});
});
it('treats both GitLab token-login flags as enabled', () => {
expect(hasGitLabTokenLoginFlag(['--gitlab-token-login'])).toBe(true);
expect(hasGitLabTokenLoginFlag(['--token-login'])).toBe(true);
expect(hasGitLabTokenLoginFlag(['--gitlab-url', 'https://gitlab.example.com'])).toBe(false);
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'bun:test';
import { parseGitLabPatAuthResponse } from '../../../src/cliproxy/auth/gitlab-pat-response';
describe('parseGitLabPatAuthResponse', () => {
it('sanitizes HTML error bodies for non-ok responses', () => {
const result = parseGitLabPatAuthResponse(
false,
502,
'<html><body>gateway error</body></html>',
'glpat-secret-token'
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errorMessage).toBe('[HTML error response omitted]');
}
});
it('sanitizes reflected PAT tokens from structured error payloads', () => {
const result = parseGitLabPatAuthResponse(
false,
400,
JSON.stringify({ status: 'error', error: 'Rejected token glpat-secret-token for login' }),
'glpat-secret-token'
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errorMessage).toContain('[redacted]');
expect(result.errorMessage).not.toContain('glpat-secret-token');
}
});
it('rejects ok responses whose body is not valid success JSON', () => {
const result = parseGitLabPatAuthResponse(
true,
200,
'upstream temporarily unavailable',
'glpat-secret-token'
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errorMessage).toContain('upstream temporarily unavailable');
}
});
it('accepts explicit success payloads', () => {
const result = parseGitLabPatAuthResponse(
true,
200,
JSON.stringify({ status: 'ok', saved_path: '/tmp/gitlab.json' }),
'glpat-secret-token'
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.payload.status).toBe('ok');
}
});
});
@@ -1,8 +1,9 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver';
import { InteractivePrompt } from '../../../src/utils/prompt';
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
const remoteTarget: ProxyTarget = {
@@ -233,3 +234,65 @@ describe('getCliAuthNicknameError', () => {
expect(getCliAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull();
});
});
describe('promptGitLabPersonalAccessToken', () => {
it('uses the masked password prompt and trims the token', async () => {
const passwordSpy = spyOn(InteractivePrompt, 'password').mockImplementation(
mock(async () => ' glpat-secret-token ')
);
const { promptGitLabPersonalAccessToken } = await import(
`../../../src/cliproxy/auth/oauth-handler?gitlab-pat-prompt=${Date.now()}`
);
await expect(promptGitLabPersonalAccessToken()).resolves.toBe('glpat-secret-token');
expect(passwordSpy).toHaveBeenCalledWith('GitLab Personal Access Token');
});
it('returns null when the masked prompt is left blank', async () => {
const passwordSpy = spyOn(InteractivePrompt, 'password').mockImplementation(
mock(async () => ' ')
);
const { promptGitLabPersonalAccessToken } = await import(
`../../../src/cliproxy/auth/oauth-handler?gitlab-pat-prompt-blank=${Date.now()}`
);
await expect(promptGitLabPersonalAccessToken()).resolves.toBeNull();
expect(passwordSpy).toHaveBeenCalledWith('GitLab Personal Access Token');
});
});
describe('normalizeGitLabBaseUrl', () => {
it('returns undefined for blank values', async () => {
const { normalizeGitLabBaseUrl } = await import(
`../../../src/cliproxy/auth/oauth-handler?gitlab-url-empty=${Date.now()}`
);
expect(normalizeGitLabBaseUrl(undefined)).toBeUndefined();
expect(normalizeGitLabBaseUrl(' ')).toBeUndefined();
});
it('normalizes whitespace and trailing slashes for self-hosted URLs', async () => {
const { normalizeGitLabBaseUrl } = await import(
`../../../src/cliproxy/auth/oauth-handler?gitlab-url-normalize=${Date.now()}`
);
expect(normalizeGitLabBaseUrl(' https://gitlab.example.com/custom/ ')).toBe(
'https://gitlab.example.com/custom'
);
});
it('rejects malformed or scheme-less URLs before hitting CLIProxy', async () => {
const { normalizeGitLabBaseUrl } = await import(
`../../../src/cliproxy/auth/oauth-handler?gitlab-url-invalid=${Date.now()}`
);
expect(() => normalizeGitLabBaseUrl('gitlab.example.com')).toThrow(
'GitLab URL must be a valid http:// or https:// URL'
);
expect(() => normalizeGitLabBaseUrl('ftp://gitlab.example.com')).toThrow(
'GitLab URL must use http:// or https://'
);
});
});
@@ -41,24 +41,38 @@ describe('provider-capabilities', () => {
'ghcp',
'claude',
'kimi',
'cursor',
'gitlab',
'codebuddy',
'kilo',
]);
});
it('validates provider IDs', () => {
expect(isCLIProxyProvider('gemini')).toBe(true);
expect(isCLIProxyProvider('ghcp')).toBe(true);
expect(isCLIProxyProvider('gitlab')).toBe(true);
expect(isCLIProxyProvider('not-a-provider')).toBe(false);
expect(isCLIProxyProvider('Gemini')).toBe(false);
});
it('returns providers by OAuth flow capability', () => {
expect(getProvidersByOAuthFlow('device_code')).toEqual(['qwen', 'kiro', 'ghcp', 'kimi']);
expect(getProvidersByOAuthFlow('device_code')).toEqual([
'qwen',
'kiro',
'ghcp',
'kimi',
'cursor',
'codebuddy',
'kilo',
]);
expect(getProvidersByOAuthFlow('authorization_code')).toEqual([
'gemini',
'codex',
'agy',
'iflow',
'claude',
'gitlab',
]);
});
@@ -69,6 +83,8 @@ describe('provider-capabilities', () => {
expect(mapExternalProviderName('github-copilot')).toBe('ghcp');
expect(mapExternalProviderName('copilot')).toBe('ghcp');
expect(mapExternalProviderName('anthropic')).toBe('claude');
expect(mapExternalProviderName('gitlab-duo')).toBe('gitlab');
expect(mapExternalProviderName('tencent')).toBe('codebuddy');
expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp');
expect(mapExternalProviderName('')).toBeNull();
expect(mapExternalProviderName('unknown-provider')).toBeNull();
@@ -99,8 +115,11 @@ describe('provider-capabilities', () => {
it('exposes callback port and display name capabilities', () => {
expect(getOAuthCallbackPort('qwen')).toBeNull();
expect(getOAuthCallbackPort('kiro')).toBeNull();
expect(getOAuthCallbackPort('cursor')).toBeNull();
expect(getOAuthCallbackPort('gitlab')).toBe(17171);
expect(getOAuthCallbackPort('gemini')).toBe(8085);
expect(getProviderDisplayName('agy')).toBe('Antigravity');
expect(getProviderDisplayName('kilo')).toBe('Kilo AI');
});
it('throws when provider aliases collide across providers', () => {
@@ -82,6 +82,9 @@ describe('completion backend', () => {
expect(values).toContain('cursor');
expect(values).toContain('copilot');
expect(values).toContain('gemini');
expect(values).toContain('gitlab');
expect(values).toContain('codebuddy');
expect(values).toContain('kilo');
expect(values).toContain('localglm');
expect(values).toContain('work');
expect(values).toContain('my-codex');
@@ -137,6 +140,15 @@ describe('completion 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', () => {
const values = suggestionValues([], 'do');
expect(values).toEqual(expect.arrayContaining(['docker', 'doctor']));
@@ -50,6 +50,12 @@ describe('help command parity', () => {
expect(rendered.includes('gemini')).toBe(true);
expect(rendered.includes('codex')).toBe(true);
expect(rendered.includes('ghcp')).toBe(true);
expect(rendered.includes('gitlab')).toBe(true);
expect(rendered.includes('codebuddy')).toBe(true);
expect(rendered.includes('kilo')).toBe(true);
expect(rendered.includes('--gitlab-token-login')).toBe(true);
expect(rendered.includes('--token-login')).toBe(true);
expect(rendered.includes('--gitlab-url <url>')).toBe(true);
});
test('kiro topic documents IDC and callback flags', async () => {
+21 -6
View File
@@ -321,8 +321,10 @@ describe('handleCursorCommand', () => {
expect(exitCode).toBe(0);
expect(errors).toHaveLength(0);
expect(logs.some((line) => line.includes('Cursor IDE Integration'))).toBe(true);
expect(logs.some((line) => line.includes('Usage: ccs cursor <subcommand>'))).toBe(true);
expect(logs.some((line) => line.includes('Legacy Cursor Compatibility'))).toBe(true);
expect(logs.some((line) => line.includes('Usage: ccs legacy cursor <subcommand>'))).toBe(
true
);
} finally {
console.log = originalLog;
console.error = originalError;
@@ -423,7 +425,9 @@ describe('renderCursorStatus', () => {
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 {
console.log = originalLog;
}
@@ -471,7 +475,7 @@ describe('renderCursorStatus', () => {
});
describe('renderCursorHelp', () => {
it('shows bare ccs cursor as the runtime entrypoint', () => {
it('marks the legacy Cursor surface deprecated while keeping compatibility guidance', () => {
const originalLog = console.log;
const logs: string[] = [];
@@ -483,12 +487,23 @@ describe('renderCursorHelp', () => {
const exitCode = renderCursorHelp();
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(
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(
true
);
expect(
logs.some((line) => line.includes('ccs cursor [claude args]'))
logs.some((line) => line.includes('ccs cursor --auth'))
).toBe(true);
expect(
logs.some((line) => line.includes('ccs legacy cursor [claude args]'))
).toBe(true);
} finally {
console.log = originalLog;
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'bun:test';
import { shouldUseCursorCliproxyShortcut } from '../../../src/cursor/constants';
describe('cursor CLIProxy shortcut routing', () => {
it('accepts shortcut flags after leading generic flags', () => {
expect(shouldUseCursorCliproxyShortcut(['cursor', '--auth'])).toBe(true);
expect(shouldUseCursorCliproxyShortcut(['cursor', '--verbose', '--auth'])).toBe(true);
expect(shouldUseCursorCliproxyShortcut(['cursor', '-v', '--accounts'])).toBe(true);
});
it('stops scanning once a positional legacy runtime argument appears', () => {
expect(shouldUseCursorCliproxyShortcut(['cursor', 'write', '--auth'])).toBe(false);
expect(shouldUseCursorCliproxyShortcut(['cursor', 'status'])).toBe(false);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'bun:test';
import {
getDeviceCodeProviderInstruction,
getProviderDisplayName,
} from '../../../ui/src/lib/provider-config';
describe('provider-config fallbacks', () => {
it('uses translated fallback copy for unknown providers', () => {
expect(getProviderDisplayName('not-a-provider')).toBe('Unknown provider: not-a-provider');
expect(getProviderDisplayName(undefined)).toBe('Unknown provider: unknown');
});
it('uses translated default device-code guidance for unknown providers', () => {
expect(getDeviceCodeProviderInstruction('not-a-provider')).toBe(
'Complete the authorization in your browser.'
);
});
});
@@ -82,6 +82,21 @@ describe('image-analysis-backend-resolver', () => {
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', () => {
const status = resolveImageAnalysisStatus(
{
@@ -13,6 +13,15 @@ describe('cliproxy-auth-routes start-url guard', () => {
);
expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow");
expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow");
expect(getStartUrlUnsupportedReason('cursor')).toContain(
"Provider 'cursor' uses Device Code flow"
);
expect(getStartUrlUnsupportedReason('codebuddy')).toContain(
"Provider 'codebuddy' uses Device Code flow"
);
expect(getStartUrlUnsupportedReason('kilo')).toContain(
"Provider 'kilo' uses Device Code flow"
);
});
it('allows Kiro social methods on start-url', () => {
@@ -36,6 +45,7 @@ describe('cliproxy-auth-routes start-url guard', () => {
expect(getStartUrlUnsupportedReason('gemini')).toBeNull();
expect(getStartUrlUnsupportedReason('codex')).toBeNull();
expect(getStartUrlUnsupportedReason('claude')).toBeNull();
expect(getStartUrlUnsupportedReason('gitlab')).toBeNull();
});
});
@@ -87,6 +97,7 @@ describe('cliproxy-auth-routes start failure messaging', () => {
it('keeps generic failure text for other providers', () => {
expect(getStartAuthFailureMessage('gemini')).toBe('Authentication failed or was cancelled');
expect(getStartAuthFailureMessage('kiro')).toBe('Authentication failed or was cancelled');
expect(getStartAuthFailureMessage('gitlab')).toBe('Authentication failed or was cancelled');
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="16" fill="#111827"/>
<path d="M44 21.5a17.4 17.4 0 0 0-11.7-4.5c-9.6 0-17.3 7.6-17.3 17s7.7 17 17.3 17c4.6 0 8.9-1.7 12.1-4.9" stroke="#F9FAFB" stroke-width="6" stroke-linecap="round"/>
<path d="M37.5 25.5 46 32l-8.5 6.5" stroke="#22C55E" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="112 112 157 157">
<defs>
<style>
.cls-1 {
fill: #fca326;
}
.cls-2 {
fill: #fc6d26;
}
.cls-3 {
fill: #e24329;
}
</style>
</defs>
<path class="cls-3" d="M265.26416,174.37243l-.2134-.55822-21.19899-55.30908c-.4236-1.08359-1.18542-1.99642-2.17699-2.62689-.98837-.63373-2.14749-.93253-3.32305-.87014-1.1689.06239-2.29195.48925-3.20809,1.21821-.90957.73554-1.56629,1.73047-1.87493,2.85346l-14.31327,43.80662h-57.90965l-14.31327-43.80662c-.30864-1.12299-.96536-2.11791-1.87493-2.85346-.91614-.72895-2.03911-1.15582-3.20809-1.21821-1.17548-.06239-2.33468.23641-3.32297.87014-.99166.63047-1.75348,1.5433-2.17707,2.62689l-21.19891,55.31237-.21348.55493c-6.28158,16.38521-.92929,34.90803,13.05891,45.48782.02621.01641.04922.03611.07552.05582l.18719.14119,32.29094,24.17392,15.97151,12.09024,9.71951,7.34871c2.34117,1.77316,5.57877,1.77316,7.92002,0l9.71943-7.34871,15.96822-12.09024,32.48142-24.31511c.02958-.02299.05588-.04269.08538-.06568,13.97834-10.57977,19.32735-29.09604,13.04905-45.47796Z"/>
<path class="cls-2" d="M265.26416,174.37243l-.2134-.55822c-10.5174,2.16062-20.20405,6.6099-28.49844,12.81593-.1346.0985-25.20497,19.05805-46.55171,35.19699,15.84998,11.98517,29.6477,22.40405,29.6477,22.40405l32.48142-24.31511c.02958-.02299.05588-.04269.08538-.06568,13.97834-10.57977,19.32735-29.09604,13.04905-45.47796Z"/>
<path class="cls-1" d="M160.34962,244.23117l15.97151,12.09024,9.71951,7.34871c2.34117,1.77316,5.57877,1.77316,7.92002,0l9.71943-7.34871,15.96822-12.09024s-13.79772-10.41888-29.6477-22.40405c-15.85327,11.98517-29.65099,22.40405-29.65099,22.40405Z"/>
<path class="cls-2" d="M143.44561,186.63014c-8.29111-6.20274-17.97446-10.65531-28.49507-12.81264l-.21348.55493c-6.28158,16.38521-.92929,34.90803,13.05891,45.48782.02621.01641.04922.03611.07552.05582l.18719.14119,32.29094,24.17392s13.79772-10.41888,29.65099-22.40405c-21.34673-16.13894-46.42031-35.09848-46.55499-35.19699Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

+5 -1
View File
@@ -1,5 +1,5 @@
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 { Toaster } from 'sonner';
import { queryClient } from '@/lib/query-client';
@@ -129,6 +129,10 @@ export default function App() {
/>
<Route
path="/cursor"
element={<Navigate to="/cliproxy?provider=cursor" replace />}
/>
<Route
path="/legacy/cursor"
element={
<Suspense fallback={<PageLoader />}>
<CursorPage />
@@ -91,6 +91,9 @@ export function AddAccountDialog({
const [kiroIDCStartUrl, setKiroIDCStartUrl] = useState('');
const [kiroIDCRegion, setKiroIDCRegion] = useState('');
const [kiroIDCFlow, setKiroIDCFlow] = useState<KiroIDCFlow>(DEFAULT_KIRO_IDC_FLOW);
const [gitlabAuthMode, setGitlabAuthMode] = useState<'oauth' | 'pat'>('oauth');
const [gitlabBaseUrl, setGitlabBaseUrl] = useState('');
const [gitlabPersonalAccessToken, setGitlabPersonalAccessToken] = useState('');
const { t } = useTranslation();
const wasAuthenticatingRef = useRef(false);
const powerUserModeRequestIdRef = useRef(0);
@@ -99,6 +102,7 @@ export function AddAccountDialog({
const kiroImportMutation = useKiroImport();
const isKiro = provider === 'kiro';
const isGitLab = provider === 'gitlab';
const supportsPowerUserMode = provider === 'agy' || provider === 'gemini';
const requiresGeminiSafetyAcknowledgement = provider === 'gemini' && !powerUserModeEnabled;
const requiresAgyResponsibilityFlow = provider === 'agy' && !powerUserModeEnabled;
@@ -120,6 +124,8 @@ export function AddAccountDialog({
const nicknameTrimmed = nickname.trim();
const kiroIDCStartUrlTrimmed = kiroIDCStartUrl.trim();
const kiroIDCRegionTrimmed = kiroIDCRegion.trim();
const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim();
const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim();
const errorMessage = localError || authFlow.error;
const fetchPowerUserModeState = useCallback(async (): Promise<boolean> => {
@@ -191,6 +197,9 @@ export function AddAccountDialog({
setKiroIDCStartUrl('');
setKiroIDCRegion('');
setKiroIDCFlow(DEFAULT_KIRO_IDC_FLOW);
setGitlabAuthMode('oauth');
setGitlabBaseUrl('');
setGitlabPersonalAccessToken('');
powerUserModeRequestIdRef.current += 1;
powerUserModeLoadErrorShownRef.current = false;
wasAuthenticatingRef.current = false;
@@ -310,6 +319,10 @@ export function AddAccountDialog({
setLocalError('IDC Start URL is required for Kiro IAM Identity Center login.');
return;
}
if (isGitLab && gitlabAuthMode === 'pat' && !gitlabPersonalAccessTokenTrimmed) {
setLocalError(t('addAccountDialog.gitlabPatRequired'));
return;
}
wasAuthenticatingRef.current = true;
authFlow.startAuth(provider, {
nickname: nicknameTrimmed || undefined,
@@ -317,8 +330,18 @@ export function AddAccountDialog({
kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined,
kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined,
kiroIDCFlow: isKiroIdc ? kiroIDCFlow : undefined,
gitlabAuthMode: isGitLab ? gitlabAuthMode : undefined,
gitlabBaseUrl: isGitLab && gitlabBaseUrlTrimmed ? gitlabBaseUrlTrimmed : undefined,
gitlabPersonalAccessToken:
isGitLab && gitlabAuthMode === 'pat' && gitlabPersonalAccessTokenTrimmed
? gitlabPersonalAccessTokenTrimmed
: undefined,
flowType: isKiro ? selectedKiroFlowType : undefined,
startEndpoint: isKiro ? selectedKiroStartEndpoint : undefined,
startEndpoint: isKiro
? selectedKiroStartEndpoint
: isGitLab && gitlabAuthMode === 'pat'
? 'start'
: undefined,
riskAcknowledgement: requiresAgyResponsibilityFlow
? {
version: ANTIGRAVITY_ACK_VERSION,
@@ -512,6 +535,70 @@ export function AddAccountDialog({
</div>
)}
{isGitLab && !showAuthUI && (
<div className="space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="space-y-2">
<Label htmlFor="gitlab-auth-mode">{t('addAccountDialog.gitlabAuthMethod')}</Label>
<Select
value={gitlabAuthMode}
onValueChange={(value) => {
setGitlabAuthMode(value as 'oauth' | 'pat');
setLocalError(null);
}}
>
<SelectTrigger id="gitlab-auth-mode">
<SelectValue placeholder={t('addAccountDialog.selectGitlabAuthMethod')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="oauth">{t('addAccountDialog.gitlabAuthOAuth')}</SelectItem>
<SelectItem value="pat">{t('addAccountDialog.gitlabAuthPat')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('addAccountDialog.gitlabAuthHint')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="gitlab-base-url">{t('addAccountDialog.gitlabUrl')}</Label>
<Input
id="gitlab-base-url"
value={gitlabBaseUrl}
onChange={(e) => {
setGitlabBaseUrl(e.target.value);
setLocalError(null);
}}
placeholder={t('addAccountDialog.gitlabUrlPlaceholder')}
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
{t('addAccountDialog.gitlabUrlHint')}
</p>
</div>
{gitlabAuthMode === 'pat' && (
<div className="space-y-2">
<Label htmlFor="gitlab-pat">{t('addAccountDialog.gitlabPat')}</Label>
<Input
id="gitlab-pat"
type="password"
value={gitlabPersonalAccessToken}
onChange={(e) => {
setGitlabPersonalAccessToken(e.target.value);
setLocalError(null);
}}
placeholder={t('addAccountDialog.gitlabPatPlaceholder')}
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
{t('addAccountDialog.gitlabPatHint')} <span className="font-mono">api</span> and{' '}
<span className="font-mono">read_user</span> scopes.
</p>
</div>
)}
</div>
)}
{/* Nickname input - only show before auth starts */}
{!showAuthUI && (
<div className="space-y-2">
@@ -86,8 +86,12 @@ export function ProviderEditor({
gemini: ['google'],
agy: ['antigravity'],
codex: ['openai'],
cursor: ['cursor'],
gitlab: ['gitlab', 'duo'],
codebuddy: ['codebuddy'],
qwen: ['alibaba', 'qwen'],
iflow: ['iflow'],
kilo: ['kilo'],
kiro: ['kiro', 'aws'],
ghcp: ['github', 'copilot'],
kimi: ['kimi', 'moonshot'],
@@ -25,6 +25,17 @@ interface ProviderEditorHeaderProps {
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({
displayName,
logoProvider,
@@ -63,7 +74,7 @@ export function ProviderEditorHeader({
)}
{!isRemoteMode && data?.path && (
<Badge variant="outline" className="text-xs">
{data.path.replace(/^.*[\\/]/, '')}
{formatSettingsPathBadgeLabel(data.path)}
</Badge>
)}
</div>
@@ -15,6 +15,7 @@ import {
isAnthropicModelEnvKey,
} from '@/lib/extended-context-utils';
import { supportsExtendedContext } from '@/lib/model-catalogs';
import { isValidProvider } from '@/lib/provider-config';
/** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
@@ -39,12 +40,18 @@ export function useProviderEditor(
queryFn: async () => {
const res = await fetch(`/api/settings/${provider}/raw`);
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 {
profile: provider,
settings: { env: {} },
mtime: Date.now(),
path: `~/.ccs/profiles/${provider}/settings.json`,
path: fallbackPath,
};
}
return res.json();
+10 -1
View File
@@ -103,7 +103,6 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
],
},
{ path: '/copilot', icon: Github, label: t('nav.githubCopilot') },
{ path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: t('nav.cursorIde') },
{
path: '/accounts',
icon: Users,
@@ -124,6 +123,16 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
{ path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') },
],
},
{
title: t('nav.deprecated'),
items: [
{
path: '/legacy/cursor',
iconSrc: '/assets/sidebar/cursor.svg',
label: `${t('nav.cursorIde')} (Legacy)`,
},
],
},
{
title: t('nav.system'),
items: [
+5 -1
View File
@@ -1,7 +1,7 @@
/**
* Constants for Quick Setup Wizard
* Provider display info with custom ordering for wizard UI.
* Provider IDs must match CLIPROXY_PROVIDERS from provider-config.ts
* IDs come from the provider-config presentation layer.
*/
import type { ProviderOption } from './types';
@@ -17,9 +17,13 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
'claude',
'gemini',
'codex',
'cursor',
'gitlab',
'codebuddy',
'qwen',
'kimi',
'iflow',
'kilo',
'kiro',
'ghcp',
];
+6
View File
@@ -30,6 +30,9 @@ interface StartAuthOptions {
kiroIDCStartUrl?: string;
kiroIDCRegion?: string;
kiroIDCFlow?: 'authcode' | 'device';
gitlabAuthMode?: 'oauth' | 'pat';
gitlabBaseUrl?: string;
gitlabPersonalAccessToken?: string;
flowType?: 'authorization_code' | 'device_code';
startEndpoint?: 'start' | 'start-url';
riskAcknowledgement?: {
@@ -272,6 +275,9 @@ export function useCliproxyAuthFlow() {
kiroIDCStartUrl: options?.kiroIDCStartUrl,
kiroIDCRegion: options?.kiroIDCRegion,
kiroIDCFlow: options?.kiroIDCFlow,
gitlabAuthMode: options?.gitlabAuthMode,
gitlabBaseUrl: options?.gitlabBaseUrl,
gitlabPersonalAccessToken: options?.gitlabPersonalAccessToken,
riskAcknowledgement: options?.riskAcknowledgement,
};
+19 -11
View File
@@ -57,6 +57,8 @@ interface CursorAuthResult {
message: string;
}
const LEGACY_CURSOR_API_BASE = '/legacy/cursor';
export interface CursorProbeResult {
ok: boolean;
stage: 'config' | 'auth' | 'daemon' | 'runtime';
@@ -87,25 +89,25 @@ function getProbeErrorMessage(value: unknown): string | null {
}
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');
return res.json();
}
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');
return res.json();
}
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');
return res.json();
}
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');
return res.json();
}
@@ -113,7 +115,7 @@ async function fetchCursorRawSettings(): Promise<CursorRawSettings> {
async function updateCursorConfig(
updates: Partial<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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
@@ -126,7 +128,7 @@ async function saveCursorRawSettings(data: {
settings: CursorRawSettings['settings'];
expectedMtime?: 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
@@ -137,7 +139,9 @@ async function saveCursorRawSettings(data: {
}
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) {
const error = await res.json().catch(() => ({ error: 'Auto-detect failed' }));
throw new Error(error.error || 'Auto-detect failed');
@@ -149,7 +153,7 @@ async function importCursorAuthManual(data: {
accessToken: string;
machineId: string;
}): Promise<CursorAuthResult> {
const res = await fetch(withApiBase('/cursor/auth/import'), {
const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/auth/import`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
@@ -162,19 +166,23 @@ async function importCursorAuthManual(data: {
}
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');
return res.json();
}
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');
return res.json();
}
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);
if (isCursorProbeResult(payload)) {
+128 -34
View File
@@ -1,6 +1,6 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getInitialLocale } from '@/lib/locales';
import { getInitialLocale } from './locales';
const resources = {
en: {
@@ -30,6 +30,7 @@ const resources = {
allAccounts: 'All Accounts',
sharedData: 'Shared Data',
compatibleClis: 'Compatible',
deprecated: 'Deprecated',
factoryDroid: 'Factory Droid',
system: 'System',
health: 'Health',
@@ -687,6 +688,20 @@ const resources = {
'Power user mode is unavailable. Complete the required provider safety step and retry.',
authMethod: 'Auth Method',
selectKiroAuthMethod: 'Select Kiro auth method',
gitlabAuthMethod: 'GitLab auth method',
selectGitlabAuthMethod: 'Select GitLab auth method',
gitlabAuthOAuth: 'Browser OAuth',
gitlabAuthPat: 'Personal Access Token',
gitlabAuthHint:
'Use Browser OAuth for gitlab.com, or PAT for self-hosted and admin-managed setups.',
gitlabUrl: 'GitLab URL',
gitlabUrlPlaceholder: 'https://gitlab.com',
gitlabUrlHint:
'Optional. Leave blank for gitlab.com, or set your self-hosted GitLab base URL.',
gitlabPat: 'Personal Access Token',
gitlabPatPlaceholder: 'glpat-...',
gitlabPatHint: 'Token must include at least',
gitlabPatRequired: 'GitLab Personal Access Token is required for PAT login.',
nicknameRequired: 'Nickname (required)',
nicknameOptional: 'Nickname (optional)',
nicknamePlaceholder: 'e.g., work, personal',
@@ -1257,11 +1272,17 @@ const resources = {
cursorPage: {
title: 'Cursor',
beta: 'Beta',
subtitle: 'Dedicated Cursor integration controls',
unofficialTitle: 'Unofficial API - Use at Your Own Risk',
unofficialItem1: 'Reverse-engineered integration may break anytime',
unofficialItem2: 'Abuse or excessive usage may risk account restrictions',
unofficialItem3: 'No warranty, no responsibility from CCS',
deprecated: 'Deprecated',
subtitle: 'Deprecated local bridge for legacy Cursor setups',
unofficialTitle: 'Deprecated legacy bridge - prefer CLIProxy-backed Cursor',
unofficialItem1: 'Supported path: CLIProxy-backed Cursor auth and account management',
unofficialItem2: 'This reverse-engineered local bridge remains only for existing setups',
unofficialItem3: 'CCS provides no warranty for this legacy path',
supportedPathTitle: 'Supported path',
supportedPathDesc:
'Use CLIProxy-backed Cursor auth and account management for new setups. Keep the legacy bridge below only if you still rely on the old local daemon flow.',
startCliproxyAuth: 'Start CLIProxy Cursor Auth',
openCliproxyCursor: 'Open CLIProxy Cursor',
integration: 'Integration',
authentication: 'Authentication',
daemon: 'Daemon',
@@ -1272,11 +1293,11 @@ const resources = {
notConnected: 'Not connected',
running: 'Running',
stopped: 'Stopped',
actions: 'Actions',
actions: 'Legacy Actions',
disableIntegration: 'Disable Integration',
enableIntegration: 'Enable Integration',
autoDetectAuth: 'Auto-detect Auth',
manualAuthImport: 'Manual Auth Import',
autoDetectAuth: 'Legacy IDE Auto-detect',
manualAuthImport: 'Legacy Manual Import',
stopDaemon: 'Stop Daemon',
startDaemon: 'Start Daemon',
port: 'Port',
@@ -2206,6 +2227,9 @@ const resources = {
profileExportDownloaded: 'Profile export downloaded',
profileImportFailed: 'Failed to import profile bundle',
},
providerConfig: {
defaultDeviceCodeInstruction: 'Complete the authorization in your browser.',
},
// ========================================
// Domain 7: Profiles / Settings / Pages
@@ -2475,6 +2499,7 @@ const resources = {
allAccounts: '全部账号',
sharedData: '共享数据',
compatibleClis: '兼容',
deprecated: '已弃用',
factoryDroid: 'Factory Droid',
system: '系统',
health: '健康',
@@ -3079,6 +3104,18 @@ const resources = {
powerUserUnavailableRetry: '高级用户模式不可用。请完成当前提供商要求的安全步骤后重试。',
authMethod: '认证方式',
selectKiroAuthMethod: '选择 Kiro 认证方式',
gitlabAuthMethod: 'GitLab 认证方式',
selectGitlabAuthMethod: '选择 GitLab 认证方式',
gitlabAuthOAuth: '浏览器 OAuth',
gitlabAuthPat: '个人访问令牌',
gitlabAuthHint: 'gitlab.com 推荐使用浏览器 OAuth;自托管或管理员管理场景可使用 PAT。',
gitlabUrl: 'GitLab URL',
gitlabUrlPlaceholder: 'https://gitlab.com',
gitlabUrlHint: '可选。留空表示 gitlab.com,或填写你的自托管 GitLab 基础 URL。',
gitlabPat: '个人访问令牌',
gitlabPatPlaceholder: 'glpat-...',
gitlabPatHint: '令牌至少需要包含',
gitlabPatRequired: 'PAT 登录需要提供 GitLab 个人访问令牌。',
nicknameRequired: '昵称(必填)',
nicknameOptional: '昵称(选填)',
nicknamePlaceholder: '例如:工作、个人',
@@ -3628,11 +3665,17 @@ const resources = {
cursorPage: {
title: 'Cursor',
beta: 'Beta',
subtitle: 'Cursor 集成专用控制面板',
unofficialTitle: '非官方 API - 风险自负',
unofficialItem1: '逆向集成可能随时失效',
unofficialItem2: '滥用或过量使用可能导致账号受限',
unofficialItem3: 'CCS 不提供担保,也不承担责任',
deprecated: '已弃用',
subtitle: '面向旧版 Cursor 使用场景的已弃用本地桥接',
unofficialTitle: '旧版桥接已弃用,优先使用 CLIProxy 支持的 Cursor',
unofficialItem1: '推荐路径:使用 CLIProxy 管理 Cursor 认证与账号',
unofficialItem2: '这个逆向本地桥接仅为仍在使用旧流程的用户保留',
unofficialItem3: 'CCS 不为这个旧路径提供担保,也不承担责任',
supportedPathTitle: '推荐路径',
supportedPathDesc:
'新的配置请使用 CLIProxy 支持的 Cursor 认证与账号管理。只有在你仍依赖旧的本地守护进程流程时,才继续使用下方旧版桥接。',
startCliproxyAuth: '启动 CLIProxy Cursor 认证',
openCliproxyCursor: '打开 CLIProxy Cursor',
integration: '集成',
authentication: '认证',
daemon: '守护进程',
@@ -3643,11 +3686,11 @@ const resources = {
notConnected: '未连接',
running: '运行中',
stopped: '已停止',
actions: '操作',
actions: '旧版操作',
disableIntegration: '禁用集成',
enableIntegration: '启用集成',
autoDetectAuth: '自动检测认证',
manualAuthImport: '手动导入认证',
autoDetectAuth: '旧版 IDE 自动检测',
manualAuthImport: '旧版手动导入',
stopDaemon: '停止守护进程',
startDaemon: '启动守护进程',
port: '端口',
@@ -4543,6 +4586,9 @@ const resources = {
profileExportDownloaded: '配置导出已下载',
profileImportFailed: '导入配置包失败',
},
providerConfig: {
defaultDeviceCodeInstruction: '请在浏览器中完成授权。',
},
profileEditorSections: {
imageAnalysis: '图片分析',
loadingImageSettings: '加载图片设置中...',
@@ -4805,6 +4851,7 @@ const resources = {
allAccounts: 'Tất cả tài khoản',
sharedData: 'Dữ liệu dùng chung',
compatibleClis: 'Tương thích',
deprecated: 'Đã ngừng ưu tiên',
factoryDroid: 'Factory Droid',
system: 'Hệ thống',
health: 'Sức khỏe',
@@ -5474,6 +5521,20 @@ const resources = {
'Chế độ power user hiện không khả dụng. Hãy hoàn tất bước an toàn bắt buộc của nhà cung cấp rồi thử lại.',
authMethod: 'Phương thức xác thực',
selectKiroAuthMethod: 'Chọn phương thức xác thực Kiro',
gitlabAuthMethod: 'Phương thức xác thực GitLab',
selectGitlabAuthMethod: 'Chọn phương thức xác thực GitLab',
gitlabAuthOAuth: 'OAuth trên trình duyệt',
gitlabAuthPat: 'Personal Access Token',
gitlabAuthHint:
'Dùng OAuth trên trình duyệt cho gitlab.com, hoặc PAT cho GitLab tự lưu trữ và môi trường do quản trị viên quản lý.',
gitlabUrl: 'GitLab URL',
gitlabUrlPlaceholder: 'https://gitlab.com',
gitlabUrlHint:
'Tùy chọn. Để trống cho gitlab.com, hoặc nhập URL GitLab tự lưu trữ của bạn.',
gitlabPat: 'Personal Access Token',
gitlabPatPlaceholder: 'glpat-...',
gitlabPatHint: 'Token phải có ít nhất các scope',
gitlabPatRequired: 'PAT login yêu cầu GitLab Personal Access Token.',
nicknameRequired: 'Biệt danh (bắt buộc)',
nicknameOptional: 'Biệt danh (tùy chọn)',
nicknamePlaceholder: 'ví dụ: công việc, cá nhân',
@@ -6051,11 +6112,17 @@ const resources = {
cursorPage: {
title: 'Cursor',
beta: 'Beta',
subtitle: 'Bảng điều khiển tích hợp Cursor',
unofficialTitle: 'API không chính thức - Bạn phải tự chịu rủi ro khi sử dụng',
unofficialItem1: 'Tích hợp thiết kế ngược có thể bị hỏng bất cứ lúc nào',
unofficialItem2: 'Lạm dụng hoặc sử dụng quá mức có thể có nguy cơ bị hạn chế tài khoản',
unofficialItem3: 'Không bảo hành, không chịu trách nhiệm từ CCS',
deprecated: 'Đã ngừng ưu tiên',
subtitle: 'Cầu nối cục bộ đã bị ngừng ưu tiên cho các thiết lập Cursor cũ',
unofficialTitle: 'Cầu nối cũ đã bị ngừng ưu tiên - hãy dùng Cursor qua CLIProxy',
unofficialItem1: 'Đường dẫn được hỗ trợ: xác thực và quản lý tài khoản Cursor qua CLIProxy',
unofficialItem2: 'Cầu nối cục bộ reverse-engineered này chỉ còn dành cho các thiết lập cũ',
unofficialItem3: 'CCS không bảo hành và không chịu trách nhiệm cho đường dẫn cũ này',
supportedPathTitle: 'Đường dẫn được hỗ trợ',
supportedPathDesc:
'Hãy dùng xác thực và quản lý tài khoản Cursor qua CLIProxy cho các thiết lập mới. Chỉ giữ cầu nối cũ bên dưới nếu bạn vẫn phụ thuộc vào luồng daemon cục bộ trước đây.',
startCliproxyAuth: 'Bắt đầu xác thực Cursor qua CLIProxy',
openCliproxyCursor: 'Mở Cursor trong CLIProxy',
integration: 'Tích hợp',
authentication: 'Xác thực',
daemon: 'Daemon',
@@ -6066,11 +6133,11 @@ const resources = {
notConnected: 'Chưa kết nối',
running: 'Đang chạy',
stopped: 'Đã dừng',
actions: 'Hành động',
actions: 'Hành động cũ',
disableIntegration: 'Vô hiệu hóa tích hợp',
enableIntegration: 'Kích hoạt tích hợp',
autoDetectAuth: 'Tự động phát hiện xác thực',
manualAuthImport: 'Nhập xác thực thủ công',
autoDetectAuth: 'Tự dò IDE cũ',
manualAuthImport: 'Nhập thủ công kiểu cũ',
stopDaemon: 'Dừng Daemon',
startDaemon: 'Khởi động Daemon',
port: 'Cổng',
@@ -6978,6 +7045,9 @@ const resources = {
profileExportDownloaded: 'Đã tải xuống xuất hồ sơ',
profileImportFailed: 'Không nhập được gói hồ sơ',
},
providerConfig: {
defaultDeviceCodeInstruction: 'Hoàn tất việc cấp quyền trong trình duyệt của bạn.',
},
profileEditorSections: {
imageAnalysis: 'Phân tích hình ảnh',
loadingImageSettings: 'Đang tải cài đặt hình ảnh...',
@@ -7243,6 +7313,7 @@ const resources = {
allAccounts: 'すべてのアカウント',
sharedData: '共有データ',
compatibleClis: '互換',
deprecated: '非推奨',
factoryDroid: 'Factory Droid',
system: 'システム',
health: 'ヘルス',
@@ -7911,6 +7982,20 @@ const resources = {
'パワーユーザーモードは利用できません。必要なプロバイダーの安全確認を完了してから再試行してください。',
authMethod: '認証方法',
selectKiroAuthMethod: 'Kiro の認証方法を選択',
gitlabAuthMethod: 'GitLab 認証方法',
selectGitlabAuthMethod: 'GitLab 認証方法を選択',
gitlabAuthOAuth: 'ブラウザー OAuth',
gitlabAuthPat: 'Personal Access Token',
gitlabAuthHint:
'gitlab.com ではブラウザー OAuth を使い、自前ホストや管理者運用環境では PAT を使ってください。',
gitlabUrl: 'GitLab URL',
gitlabUrlPlaceholder: 'https://gitlab.com',
gitlabUrlHint:
'任意です。gitlab.com を使う場合は空欄のままにし、自前ホストの場合はベース URL を指定してください。',
gitlabPat: 'Personal Access Token',
gitlabPatPlaceholder: 'glpat-...',
gitlabPatHint: 'トークンには少なくとも次のスコープが必要です:',
gitlabPatRequired: 'PAT ログインには GitLab Personal Access Token が必要です。',
nicknameRequired: 'ニックネーム(必須)',
nicknameOptional: 'ニックネーム(任意)',
nicknamePlaceholder: '例: work, personal',
@@ -8494,12 +8579,18 @@ const resources = {
cursorPage: {
title: 'Cursor',
beta: 'ベータ',
subtitle: 'Cursor 専用の連携設定',
unofficialTitle: '非公式API - 自己責任で利用',
unofficialItem1:
'リバースエンジニアリング連携のため、いつでも利用不能になる可能性があります',
unofficialItem2: '不正利用や過度な利用により、アカウントが制限されるおそれがあります',
unofficialItem3: 'CCSは保証も責任も負いません',
deprecated: '非推奨',
subtitle: '旧 Cursor セットアップ向けの非推奨ローカルブリッジ',
unofficialTitle: 'レガシーブリッジは非推奨です。CLIProxy 管理の Cursor を優先してください',
unofficialItem1: '推奨パス: CLIProxy で Cursor の認証とアカウント管理を行う',
unofficialItem2:
'このリバースエンジニアリングされたローカルブリッジは、既存セットアップ向けの互換用です',
unofficialItem3: 'このレガシーパスについて CCS は保証も責任も負いません',
supportedPathTitle: '推奨パス',
supportedPathDesc:
'新しいセットアップでは、CLIProxy 管理の Cursor 認証とアカウント管理を使ってください。旧来のローカルデーモンに依存している場合のみ、下のレガシーブリッジを使い続けてください。',
startCliproxyAuth: 'CLIProxy で Cursor 認証を開始',
openCliproxyCursor: 'CLIProxy の Cursor を開く',
integration: '連携',
authentication: '認証',
daemon: 'デーモン',
@@ -8510,11 +8601,11 @@ const resources = {
notConnected: '未接続',
running: '稼働中',
stopped: '停止中',
actions: '操作',
actions: 'レガシー操作',
disableIntegration: '連携を無効化',
enableIntegration: '連携を有効化',
autoDetectAuth: '認証を自動検出',
manualAuthImport: '認証情報を手動インポート',
autoDetectAuth: '旧 IDE から自動検出',
manualAuthImport: '旧方式で手動インポート',
stopDaemon: 'デーモンを停止',
startDaemon: 'デーモンを起動',
port: 'ポート',
@@ -9648,6 +9739,9 @@ const resources = {
profileExportDownloaded: 'プロファイルエクスポートをダウンロードしました',
profileImportFailed: 'プロファイルバンドルのインポートに失敗しました',
},
providerConfig: {
defaultDeviceCodeInstruction: 'ブラウザーで認証を完了してください。',
},
updatesSpotlight: {
openUpdatesCenter: '更新センターを開く',
},
+27 -11
View File
@@ -10,18 +10,17 @@ import {
getProvidersByOAuthFlow,
} from '../../../src/cliproxy/provider-capabilities';
import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers';
import i18n from './i18n';
// Monorepo contract: UI consumes provider capability constants directly from backend
// to enforce one source of truth and prevent provider drift across surfaces.
/** Canonical list of CLIProxy provider IDs (shared with backend). */
export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS;
/** Union type for CLIProxy provider IDs */
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex';
/** Check if a string is a valid CLIProxy provider */
/** Check if a string is a backend-supported CLIProxy provider. */
export function isValidProvider(provider: string): provider is CLIProxyProvider {
return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider);
}
@@ -37,9 +36,13 @@ interface ProviderMetadata {
const SPECIAL_PROVIDER_VISUAL_IDS = ['openai', 'vertex'] as const;
function isPresentationProvider(provider: string): provider is CLIProxyProvider {
return isValidProvider(provider);
}
function isProviderVisualId(provider: string): provider is ProviderVisualId {
return (
isValidProvider(provider) ||
isPresentationProvider(provider) ||
SPECIAL_PROVIDER_VISUAL_IDS.includes(provider as (typeof SPECIAL_PROVIDER_VISUAL_IDS)[number])
);
}
@@ -64,6 +67,10 @@ export const PROVIDER_ASSETS: Partial<Record<ProviderVisualId, string>> = {
qwen: '/assets/providers/qwen-color.svg',
iflow: '/assets/providers/iflow.png',
kiro: '/assets/providers/kiro.png',
cursor: '/assets/sidebar/cursor.svg',
gitlab: '/assets/providers/gitlab.svg',
codebuddy: '/assets/providers/codebuddy.png',
kilo: '/assets/providers/kilo.png',
ghcp: '/assets/providers/copilot.svg',
claude: '/assets/providers/claude.svg',
kimi: '/assets/providers/kimi.svg',
@@ -90,6 +97,10 @@ export const PROVIDER_FALLBACK_VISUALS: Record<ProviderVisualId, ProviderFallbac
qwen: { textClass: 'text-cyan-600', letter: 'Q' },
iflow: { textClass: 'text-indigo-600', letter: 'i' },
kiro: { textClass: 'text-teal-600', letter: 'K' },
cursor: { textClass: 'text-slate-900', letter: 'C' },
gitlab: { textClass: 'text-orange-600', letter: 'G' },
codebuddy: { textClass: 'text-blue-600', letter: 'B' },
kilo: { textClass: 'text-rose-600', letter: 'K' },
ghcp: { textClass: 'text-green-600', letter: 'C' },
kimi: { textClass: 'text-orange-500', letter: 'K' },
openai: { textClass: 'text-slate-900', letter: 'O' },
@@ -223,6 +234,10 @@ export const PROVIDER_COLORS: Record<string, string> = {
iflow: '#f94144',
qwen: '#6236FF',
kiro: '#4d908e',
cursor: '#111827',
gitlab: '#FC6D26',
codebuddy: '#2563EB',
kilo: '#E11D48',
ghcp: '#43aa8b',
claude: '#D97757',
kimi: '#FF6B35',
@@ -239,15 +254,15 @@ const PROVIDER_NAMES: Record<string, string> = {
export function getProviderDisplayName(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!normalized) {
return 'Unknown provider'; // TODO i18n: missing key
return i18n.t('toasts.providerUnknown', { provider: 'unknown' });
}
return PROVIDER_NAMES[normalized] || String(provider);
return PROVIDER_NAMES[normalized] || i18n.t('toasts.providerUnknown', { provider: normalized });
}
/** Map provider to user-facing short description */
export function getProviderDescription(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!isValidProvider(normalized)) return '';
if (!isPresentationProvider(normalized)) return '';
return PROVIDER_METADATA[normalized].description;
}
@@ -283,12 +298,12 @@ export function isDeviceCodeProvider(provider: unknown): boolean {
export function getDeviceCodeProviderDisplayName(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!normalized) {
return 'Unknown provider'; // TODO i18n: missing key
return i18n.t('toasts.providerUnknown', { provider: 'unknown' });
}
if (isValidProvider(normalized)) {
return DEVICE_CODE_PROVIDER_DISPLAY_NAMES[normalized] || getProviderDisplayName(normalized);
}
return String(provider);
return i18n.t('toasts.providerUnknown', { provider: normalized });
}
/** Provider-specific helper text for device-code dialog. */
@@ -296,10 +311,11 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (isValidProvider(normalized)) {
return (
DEVICE_CODE_PROVIDER_INSTRUCTIONS[normalized] || 'Complete the authorization in your browser.' // TODO i18n: missing key
DEVICE_CODE_PROVIDER_INSTRUCTIONS[normalized] ||
i18n.t('providerConfig.defaultDeviceCodeInstruction')
);
}
return 'Complete the authorization in your browser.'; // TODO i18n: missing key
return i18n.t('providerConfig.defaultDeviceCodeInstruction');
}
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */
+36 -4
View File
@@ -5,6 +5,7 @@
import { useMemo, useState, type ElementType } from 'react';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import {
AlertTriangle,
CheckCircle2,
@@ -289,6 +290,7 @@ function StatusItem({
export function CursorPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const {
status,
statusLoading,
@@ -733,9 +735,9 @@ export function CursorPage() {
<h1 className="font-semibold">{t('cursorPage.title')}</h1>
<Badge
variant="outline"
className="h-5 border-amber-500/60 bg-amber-500/10 px-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:text-amber-300"
className="h-5 border-red-500/50 bg-red-500/10 px-1.5 text-[10px] font-semibold uppercase tracking-wide text-red-700 dark:text-red-300"
>
{t('cursorPage.beta')}
{t('cursorPage.deprecated')}
</Badge>
{integrationBadge}
</div>
@@ -770,6 +772,36 @@ export function CursorPage() {
</ul>
</div>
<div className="rounded-md border border-border/70 bg-background/90 p-3 space-y-3">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('cursorPage.supportedPathTitle')}
</p>
<p className="text-xs text-muted-foreground">
{t('cursorPage.supportedPathDesc')}
</p>
</div>
<div className="grid gap-2">
<Button
size="sm"
className="w-full"
onClick={() => navigate('/cliproxy?provider=cursor&action=auth')}
>
<Key className="w-3.5 h-3.5 mr-1.5" />
{t('cursorPage.startCliproxyAuth')}
</Button>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => navigate('/cliproxy?provider=cursor')}
>
<Zap className="w-3.5 h-3.5 mr-1.5" />
{t('cursorPage.openCliproxyCursor')}
</Button>
</div>
</div>
<div className="space-y-2">
<StatusItem
icon={ShieldCheck}
@@ -1251,7 +1283,7 @@ export function CursorPage() {
<span className="font-medium text-muted-foreground">
{t('cursorPage.provider')}
</span>
<span className="font-mono">Cursor IDE</span>
<span className="font-mono">Cursor IDE (Legacy)</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">
@@ -1263,7 +1295,7 @@ export function CursorPage() {
</div>
{/* TODO i18n: missing key for model mapping env var info paragraph */}
<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_HAIKU_MODEL` in `cursor.settings.json`.
</p>
@@ -59,6 +59,22 @@ describe('AddAccountDialog power user mode', () => {
vi.clearAllMocks();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
configurable: true,
value: () => false,
});
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: () => {},
});
Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', {
configurable: true,
value: () => {},
});
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: () => {},
});
});
afterEach(() => {
@@ -168,4 +184,26 @@ describe('AddAccountDialog power user mode', () => {
expect(screen.queryByText('Power user mode enabled')).not.toBeInTheDocument();
expect(authMocks.startAuth).not.toHaveBeenCalled();
});
it('submits GitLab PAT mode with base URL and token through the shared auth hook', async () => {
render(<AddAccountDialog open onClose={vi.fn()} provider="gitlab" displayName="GitLab Duo" />);
await userEvent.click(screen.getByRole('combobox', { name: 'GitLab auth method' }));
await userEvent.click(screen.getByRole('option', { name: 'Personal Access Token' }));
await userEvent.type(screen.getByLabelText('GitLab URL'), 'https://gitlab.example.com');
await userEvent.type(screen.getByLabelText('Personal Access Token'), 'glpat-test-token');
await userEvent.click(screen.getByRole('button', { name: 'Authenticate' }));
expect(authMocks.startAuth).toHaveBeenCalledWith(
'gitlab',
expect.objectContaining({
gitlabAuthMode: 'pat',
gitlabBaseUrl: 'https://gitlab.example.com',
gitlabPersonalAccessToken: 'glpat-test-token',
startEndpoint: 'start',
})
);
});
});
@@ -307,6 +307,56 @@ describe('useCliproxyAuthFlow', () => {
kiroIDCStartUrl: 'https://d-123.awsapps.com/start',
kiroIDCRegion: 'ca-central-1',
kiroIDCFlow: 'authcode',
gitlabAuthMode: undefined,
gitlabBaseUrl: undefined,
gitlabPersonalAccessToken: undefined,
riskAcknowledgement: undefined,
});
});
it('forwards GitLab PAT options to the backend start endpoint payload', async () => {
let requestBody: Record<string, unknown> | null = null;
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/start')) {
requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
return createJsonResponse({
success: true,
account: {
id: 'gitlab-account',
provider: 'gitlab',
},
});
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
await act(async () => {
await result.current.startAuth('gitlab', {
startEndpoint: 'start',
gitlabAuthMode: 'pat',
gitlabBaseUrl: 'https://gitlab.example.com',
gitlabPersonalAccessToken: 'glpat-test-token',
});
});
expect(requestBody).toEqual({
nickname: undefined,
kiroMethod: undefined,
kiroIDCStartUrl: undefined,
kiroIDCRegion: undefined,
kiroIDCFlow: undefined,
gitlabAuthMode: 'pat',
gitlabBaseUrl: 'https://gitlab.example.com',
gitlabPersonalAccessToken: 'glpat-test-token',
riskAcknowledgement: undefined,
});
});
+11 -9
View File
@@ -32,7 +32,7 @@ describe('useCursor', () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/cursor/status')) {
if (url.endsWith('/api/legacy/cursor/status')) {
return Promise.resolve(
createJsonResponse({
enabled: true,
@@ -48,7 +48,7 @@ describe('useCursor', () => {
);
}
if (url.endsWith('/api/cursor/settings')) {
if (url.endsWith('/api/legacy/cursor/settings')) {
return Promise.resolve(
createJsonResponse({
enabled: true,
@@ -60,7 +60,7 @@ describe('useCursor', () => {
);
}
if (url.endsWith('/api/cursor/models')) {
if (url.endsWith('/api/legacy/cursor/models')) {
return Promise.resolve(
createJsonResponse({
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(
createJsonResponse({
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));
}
@@ -102,14 +102,16 @@ describe('useCursor', () => {
await waitFor(() => expect(result.current.probeResult).toMatchObject(probeFailure));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/status'))
.length
fetchMock.mock.calls.filter(([input]) =>
String(input).endsWith('/api/legacy/cursor/status')
).length
).toBeGreaterThanOrEqual(2)
);
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/models'))
.length
fetchMock.mock.calls.filter(([input]) =>
String(input).endsWith('/api/legacy/cursor/models')
).length
).toBeGreaterThanOrEqual(2)
);
});
@@ -2,9 +2,14 @@ import { describe, expect, it } from 'vitest';
import {
formatRequestedUpstreamModelRules,
getProviderDescription,
getProviderDisplayName,
getProviderFallbackVisual,
getProviderLogoAsset,
getRequestedUpstreamModelRuleErrors,
getRequestedModelId,
parseRequestedUpstreamModelRules,
PROVIDER_COLORS,
} from '@/lib/provider-config';
describe('provider model mapping helpers', () => {
@@ -42,3 +47,45 @@ describe('provider model mapping helpers', () => {
]);
});
});
describe('provider presentation metadata', () => {
it.each([
['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'],
['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'],
[
'codebuddy',
'CodeBuddy (Tencent)',
'Tencent CodeBuddy AI assistant',
'/assets/providers/codebuddy.png',
],
['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.png'],
])('recognizes %s across dashboard display helpers', (provider, name, description, asset) => {
expect(getProviderDisplayName(provider)).toBe(name);
expect(getProviderDescription(provider)).toBe(description);
expect(getProviderLogoAsset(provider)).toBe(asset);
});
it('provides fallback visuals and brand colors for new providers', () => {
expect(getProviderFallbackVisual('cursor')).toEqual({
textClass: 'text-slate-900',
letter: 'C',
});
expect(getProviderFallbackVisual('gitlab')).toEqual({
textClass: 'text-orange-600',
letter: 'G',
});
expect(getProviderFallbackVisual('codebuddy')).toEqual({
textClass: 'text-blue-600',
letter: 'B',
});
expect(getProviderFallbackVisual('kilo')).toEqual({
textClass: 'text-rose-600',
letter: 'K',
});
expect(PROVIDER_COLORS.cursor).toBe('#111827');
expect(PROVIDER_COLORS.gitlab).toBe('#FC6D26');
expect(PROVIDER_COLORS.codebuddy).toBe('#2563EB');
expect(PROVIDER_COLORS.kilo).toBe('#E11D48');
});
});